listless

A simple list app for Apple platforms
Log | Files | Refs | README | LICENSE

commit e403e24c46d0a2a1b9aaf33d0924997e7d188452
parent 8e45b34ce855229a0aa043883b6dfd684fbfed24
Author: Michael Camilleri <[email protected]>
Date:   Wed, 25 Mar 2026 08:57:29 +0900

Avoid using UIKit-based views outside of an editing context

In a further attempt to improve scrolling performance, this commit
avoids using explicit UIKit-based outside of an editing context. This
required tweaks to UI tests given the different views now in use.

Co-Authored-By: Claude 4.6 Opus <[email protected]>

Diffstat:
MListlessiOS/Helpers/TappableTextField.swift | 13++++++++++++-
MListlessiOS/Views/TaskRowView.swift | 82++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------
MTests/UI/ListlessiOSUITests.swift | 6+++---
3 files changed, 75 insertions(+), 26 deletions(-)

diff --git a/ListlessiOS/Helpers/TappableTextField.swift b/ListlessiOS/Helpers/TappableTextField.swift @@ -12,6 +12,7 @@ struct TappableTextField: UIViewRepresentable { var returnKeyType: UIReturnKeyType = .done var onContentChange: ((String) -> Void)? = nil var uiAccessibilityIdentifier: String? = nil + var initialCursorPoint: CGPoint? = nil func makeUIView(context: Context) -> UITextView { let textView = UITextView() @@ -87,7 +88,9 @@ struct TappableTextField: UIViewRepresentable { } func makeCoordinator() -> Coordinator { - Coordinator(text: $text, onEditingChanged: onEditingChanged, onContentChange: onContentChange) + let coordinator = Coordinator(text: $text, onEditingChanged: onEditingChanged, onContentChange: onContentChange) + coordinator.initialCursorPoint = initialCursorPoint + return coordinator } private func applyStyle(to textView: UITextView, text: String, isCompleted: Bool) { @@ -109,6 +112,7 @@ struct TappableTextField: UIViewRepresentable { var returnKeyPressed: Bool = false weak var textView: UITextView? private(set) var isDragging = false + var initialCursorPoint: CGPoint? init( text: Binding<String>, @@ -144,6 +148,13 @@ struct TappableTextField: UIViewRepresentable { } func textViewDidBeginEditing(_ textView: UITextView) { + if let point = initialCursorPoint { + initialCursorPoint = nil + textView.layoutIfNeeded() + if let position = textView.closestPosition(to: point) { + textView.selectedTextRange = textView.textRange(from: position, to: position) + } + } onEditingChanged(true, false) } diff --git a/ListlessiOS/Views/TaskRowView.swift b/ListlessiOS/Views/TaskRowView.swift @@ -24,6 +24,7 @@ struct TaskRowView: View { @State private var isSwipeTriggered: Bool = false @State private var editingTitle: String = "" @State private var isCurrentlyEditing: Bool = false + @State private var tapPoint: CGPoint? = nil @State private var cachedAccentColor: Color = .clear init( @@ -79,28 +80,44 @@ struct TaskRowView: View { .accessibilityIdentifier("task-checkbox") .accessibilityValue(task.isCompleted ? "checkmark.circle.fill" : "circle") - TappableTextField( - text: $editingTitle, - isCompleted: task.isCompleted, - isDragging: isDragging, - onEditingChanged: { editing, shouldCreateNewTask in - // TappableTextField is UIKit-backed; defer state mutations to avoid - // "Modifying state during view update" warnings from SwiftUI. - DispatchQueue.main.async { - isCurrentlyEditing = editing - if editing { onStartEdit(taskID) } - else { onEndEdit(taskID, shouldCreateNewTask) } - } - }, - returnKeyType: isLastActiveTask && !editingTitle.isEmpty ? .next : .done, - onContentChange: { newTitle in - guard !task.isCompleted else { return } - onTitleChange(task, newTitle) - }, - uiAccessibilityIdentifier: "task-text-\(taskID.uuidString)" - ) - .focused($focusedField, equals: .task(taskID)) - .frame(maxWidth: .infinity, alignment: .leading) + if !task.isCompleted && (isSelected || isEditing) { + TappableTextField( + text: $editingTitle, + isCompleted: task.isCompleted, + isDragging: isDragging, + onEditingChanged: { editing, shouldCreateNewTask in + DispatchQueue.main.async { + isCurrentlyEditing = editing + if editing { onStartEdit(taskID) } + else { + tapPoint = nil + onEndEdit(taskID, shouldCreateNewTask) + } + } + }, + returnKeyType: isLastActiveTask && !editingTitle.isEmpty ? .next : .done, + onContentChange: { newTitle in + guard !task.isCompleted else { return } + onTitleChange(task, newTitle) + }, + uiAccessibilityIdentifier: "task-text-\(taskID.uuidString)", + initialCursorPoint: tapPoint + ) + .focused($focusedField, equals: .task(taskID)) + .frame(maxWidth: .infinity, alignment: .leading) + } else if !task.isCompleted { + taskProxy + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + .gesture(SpatialTapGesture().onEnded { value in + tapPoint = value.location + onSelect(taskID) + focusedField = .task(taskID) + }) + } else { + taskProxy + .frame(maxWidth: .infinity, alignment: .leading) + } } .padding(.vertical, TaskRowMetrics.contentVerticalPadding) .padding(.trailing, TaskRowMetrics.contentHorizontalPadding) @@ -125,6 +142,7 @@ struct TaskRowView: View { if task.isCompleted { withAnimation { onToggle(task) } } else { + tapPoint = nil onSelect(taskID) focusedField = .task(taskID) } @@ -182,6 +200,26 @@ struct TaskRowView: View { ) } + private var isEditing: Bool { + focusedField == .task(taskID) + } + + @ViewBuilder + private var taskProxy: some View { + if task.isCompleted { + Text(editingTitle) + .font(TaskRowMetrics.bodySUI) + .foregroundStyle(.secondary) + .strikethrough(true, color: .secondary) + .accessibilityIdentifier("task-text-\(taskID.uuidString)") + } else { + Text(editingTitle) + .font(TaskRowMetrics.bodySUI) + .foregroundStyle(.primary) + .accessibilityIdentifier("task-text-\(taskID.uuidString)") + } + } + @MainActor private func computeAccentColor() -> Color { guard !task.isCompleted else { return .clear } diff --git a/Tests/UI/ListlessiOSUITests.swift b/Tests/UI/ListlessiOSUITests.swift @@ -32,10 +32,10 @@ final class ListlessiOSUITests: XCTestCase { app.textViews.matching(identifier: "draft-row-append").firstMatch } - /// Returns the text view for a committed task with the given title. + /// Returns the static text for a committed task with the given title. func taskText(_ title: String) -> XCUIElement { - app.textViews.matching( - NSPredicate(format: "identifier BEGINSWITH 'task-text-' AND value == %@", title) + app.staticTexts.matching( + NSPredicate(format: "identifier BEGINSWITH 'task-text-' AND label == %@", title) ).firstMatch }