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:
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
}