← All repositories

listless

A simple list app for Apple platforms

git clone https://code.inqk.net/listless.git


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
     }