commit b988b41f1334626252b1c366355685c6bd16c3d8
parent 30f6bcb307a09d3c09303a59ac3e1720a35fa6f2
Author: Michael Camilleri <[email protected]>
Date: Sat, 21 Mar 2026 06:09:02 +0900
Improve gestures in iOS version
This commit improves certain gestures in the iOS version, namely
creation gestures now cause the instructional 'pull to create' message
to hide and a cycle that occurred during drag reordering has been
eliminated. The elimination required the use of a coordinator and is a
bit ugly.
Co-Authored-By: Claude 4.6 Opus <[email protected]>
Diffstat:
8 files changed, 95 insertions(+), 45 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md
@@ -109,14 +109,14 @@ Listless is a to-do list app for Apple platforms. It is intended to run on iPhon
- **Selection on iOS/iPadOS is intentionally limited**: iOS supports single-task cursor navigation via arrow keys (for marking complete/incomplete) but does not support multi-select, Select All (Cmd+A), or Cmd+Click toggling. Full selection semantics (range select, multi-select, select all) are macOS-only. Do not add selection features to the iOS target.
## SwiftUI Implementation Notes
-- **TaskListView architecture**: Declared separately per platform (`ListlessiOS/Views/TaskListView.swift`, `ListlessMac/Views/TaskListView.swift`, `ListlessWatch/Views/TaskListView.swift`). iOS and macOS conform to `TaskListViewProtocol`; watchOS is standalone (does not use the protocol or shared extensions). State is grouped by concern: `fState` (focus), `iState` (interaction), `tState` (task/view-local). Shared logic lives in `TaskListView+Logic.swift` as an extension on `TaskListViewProtocol`. Because `private` is file-scoped, stored properties accessed from extensions must be `internal`. Platform-specific extensions that would return `EmptyView()` on the other platform are simply omitted.
+- **TaskListView architecture**: Declared separately per platform (`ListlessiOS/Views/TaskListView.swift`, `ListlessMac/Views/TaskListView.swift`, `ListlessWatch/Views/TaskListView.swift`). iOS and macOS conform to `TaskListViewProtocol`; watchOS is standalone (does not use the protocol or shared extensions). State is grouped by concern: `fState` (focus), `iState` (interaction), `pState` (pull gestures, iOS only), `isDragging` (iOS only, separate `@State` to avoid dirtying `iState`), and `layoutStorage` (non-reactive `LayoutStorage` class holding `rowFrames` and `contentBottomY` — a plain class under `@State` so SwiftUI holds a stable reference without tracking field mutations). macOS uses `tState` (task/view-local) instead of `pState`/`isDragging`/`layoutStorage`. Shared logic lives in `TaskListView+Logic.swift` as an extension on `TaskListViewProtocol`. Because `private` is file-scoped, stored properties accessed from extensions must be `internal`. Platform-specific extensions that would return `EmptyView()` on the other platform are simply omitted.
- **Selection pattern**: Parent owns `@State var selectedTaskID`; children receive `isSelected: Bool` + `onSelect: () -> Void` callback (avoids SwiftUI ForEach update issues with @Binding). macOS supports Cmd+Click to toggle individual items in/out of the selection, creating discontinuous selections tracked via `FocusStateData.inactiveSelections`. Shift+Arrow after Cmd+Click preserves inactive items and merges them when the active range becomes adjacent. Read modifiers via `NSApp.currentEvent?.modifierFlags` (not the `NSEvent.modifierFlags` class property) so CGEvent-based UI tests can set modifier flags on synthesised mouse events.
- **Focus management**: Single `@FocusState` enum (`FocusField` in `TaskListTypes.swift`) with `.task(UUID)` and `.scrollView` cases. Never use multiple @FocusState variables for related focus. Keyboard handlers return `.ignored` when wrong focus state.
- **iOS focus cleanup**: On iOS, always dismiss focus by setting `focusedField = nil`, never directly to `.scrollView`. The `onChange(of: focusedFieldBinding)` handler intercepts `nil` to run cleanup (e.g. `deleteIfEmpty` for empty tasks) before redirecting to `.scrollView`. Skipping `nil` desyncs SwiftUI focus state from UIKit first responder and can cause crashes.
- **Pending focus**: Set both `pendingFocus` and `focusedField` in `createNewTask()`. Do NOT resolve in `.onAppear` (race conditions). Clear `pendingFocus` in `startEditing()`. Guard `deleteIfEmpty()` against `pendingFocus` matches.
- **macOS focus gating via `WindowCoordinator.allowedFocusTarget`**: When Return/Escape ends editing via `makeFirstResponder(nil)`, SwiftUI processes the `@FocusState` change asynchronously. During reconciliation it can traverse the view hierarchy and make the first `NSTextField` become first responder, overriding the `.scrollView` assignment. The per-window `allowedFocusTarget` property on `WindowCoordinator` prevents this: `doCommandBy` sets it to `.scrollView` before `makeFirstResponder(nil)`, and `ClickableNSTextField.acceptsFirstResponder` checks it — rejecting focus unless the field matches the allowed target. Each `ClickableNSTextField` carries a `taskID` for this comparison. The gate is cleared in `onChange(of: focusedFieldBinding)` once `newValue` is non-nil (reconciliation complete). When `pendingFocus` is set (e.g. revealing a draft row), the gate is updated to allow that specific target, and `ClickableNSTextField.viewDidMoveToWindow()` claims focus when the view enters the window hierarchy (handles the case where the NSView doesn't exist yet when focus is requested).
-- **Text editing**: iOS uses `TappableTextField` (UIViewRepresentable wrapping `UITextView`); macOS uses `ClickableTextField` (NSViewRepresentable wrapping `NSTextField`). Both use delegate/coordinator patterns to bridge to SwiftUI. Key gotcha: `onEditingChanged` callbacks from UIKit may arrive during a SwiftUI update pass — defer via `DispatchQueue.main.async`.
-- **Drag-and-drop**: Both platforms maintain `visualOrder` during drag and commit via `store.moveTask()` on release. macOS uses `onDrag` + `dropDestination`; iOS uses `LongPressGesture.sequenced(before: DragGesture)` via `.simultaneousGesture()`.
+- **Text editing**: iOS uses `TappableTextField` (UIViewRepresentable wrapping `UITextView`); macOS uses `ClickableTextField` (NSViewRepresentable wrapping `NSTextField`). Both use delegate/coordinator patterns to bridge to SwiftUI. Key gotcha: `onEditingChanged` callbacks from UIKit may arrive during a SwiftUI update pass — defer via `DispatchQueue.main.async`. On iOS, `TappableTextField` defers `isDragging`-driven changes to `isEditable`/`isSelectable` via the coordinator's `setDragging(_:)` method (called from `Task { @MainActor in }` in `updateUIView`) — setting these properties synchronously in `updateUIView` causes UITextView to call `invalidateIntrinsicContentSize()`, creating a layout-to-state backward edge that triggers AttributeGraph cycle warnings.
+- **Drag-and-drop**: Both platforms maintain `visualOrder` during drag and commit via `store.moveTask()` on release. macOS uses `onDrag` + `dropDestination`; iOS uses `UIGestureRecognizerRepresentable` (`TaskRowDragGesture`) with a long-press recognizer. The drag gesture is gated so it won't activate on a focused (editing) row — prevents the drag from stealing touches from the text cursor/loupe. The gesture's delegate uses `shouldBeRequiredToFailBy` to make UITextView gestures wait for the drag to fail, preventing the loupe from appearing during drag-reorder. Swipe gestures (`TaskRowSwipeGesture`) are also disabled on the focused row via an `isEditing` parameter, so horizontal swiping doesn't interfere with text selection.
- **Keyboard navigation**: macOS uses dictionary-based keybindings in `KeyboardNavigationModifier.swift`. iOS uses `KeyCommandBridge` (UIViewRepresentable) because iPadOS `@FocusState` silently fails with hardware keyboards — the iOS `body` does **not** use `.focusable()`, `.focused(equals: .scrollView)`, or `.keyboardNavigation()`.
- Avoid `Spacer` inside `ScrollView` (causes unwanted scrollbar).
- **Escape hatches** (`GeometryReader`, `PreferenceKey`, `UIViewRepresentable`/`NSViewRepresentable`): only after exhausting SwiftUI-native alternatives (`.frame`, `.overlay`, `.background`, `alignmentGuide`).
diff --git a/ListlessiOS/Extensions/TaskListView+Drag.swift b/ListlessiOS/Extensions/TaskListView+Drag.swift
@@ -5,7 +5,7 @@ extension TaskListView {
guard let draggedID = draggedTaskID,
var order = visualOrder,
let currentIndex = order.firstIndex(of: draggedID),
- let draggedFrame = iState.rowFrames[draggedID] else { return }
+ let draggedFrame = layoutStorage.rowFrames[draggedID] else { return }
let threshold = draggedFrame.height * 0.2
@@ -32,17 +32,17 @@ extension TaskListView {
let order = visualOrder,
let finalIndex = order.firstIndex(of: draggedID) else {
clearDragState()
- iState.isDragging = false
+ isDragging = false
return
}
do {
try store.moveTask(taskID: draggedID, toIndex: finalIndex)
clearDragState()
- iState.isDragging = false
+ isDragging = false
} catch {
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
clearDragState()
- iState.isDragging = false
+ isDragging = false
}
presentStoreError(error)
}
diff --git a/ListlessiOS/Extensions/TaskListView+PullToClear.swift b/ListlessiOS/Extensions/TaskListView+PullToClear.swift
@@ -2,8 +2,8 @@ import SwiftUI
extension TaskListView {
@ViewBuilder var pullToClearIndicatorRow: some View {
- if iState.pullUpOffset > 0 && !completedTasks.isEmpty {
- PullToClearIndicator(pullOffset: iState.pullUpOffset)
+ if pState.pullUpOffset > 0 && !completedTasks.isEmpty {
+ PullToClearIndicator(pullOffset: pState.pullUpOffset)
}
}
}
diff --git a/ListlessiOS/Helpers/TappableTextField.swift b/ListlessiOS/Helpers/TappableTextField.swift
@@ -38,6 +38,7 @@ struct TappableTextField: UIViewRepresentable {
placeholder.topAnchor.constraint(equalTo: textView.topAnchor),
])
+ context.coordinator.textView = textView
return textView
}
@@ -57,8 +58,20 @@ struct TappableTextField: UIViewRepresentable {
textView.reloadInputViews()
}
textView.accessibilityIdentifier = uiAccessibilityIdentifier
- textView.isEditable = !isCompleted && !isDragging
- textView.isSelectable = !isCompleted && !isDragging
+ textView.isEditable = !isCompleted
+ textView.isSelectable = !isCompleted
+ // Defer isDragging updates to break an AttributeGraph cycle: setting
+ // isEditable/isSelectable during updateUIView causes UITextView to
+ // invalidate its intrinsic content size, creating a layout-to-state
+ // backward edge that SwiftUI's dependency graph flags as a cycle.
+ // Deferring moves the UIView mutation outside of the evaluation pass.
+ let dragging = isDragging
+ if dragging != context.coordinator.isDragging {
+ let coordinator = context.coordinator
+ Task { @MainActor in
+ coordinator.setDragging(dragging)
+ }
+ }
if let placeholder = textView.viewWithTag(100) as? UILabel {
placeholder.isHidden = !text.isEmpty
}
@@ -93,6 +106,8 @@ struct TappableTextField: UIViewRepresentable {
let onEditingChanged: (Bool, _ shouldCreateNewTask: Bool) -> Void
let onContentChange: ((String) -> Void)?
var returnKeyPressed: Bool = false
+ weak var textView: UITextView?
+ private(set) var isDragging = false
init(
text: Binding<String>,
@@ -104,6 +119,21 @@ struct TappableTextField: UIViewRepresentable {
self.onContentChange = onContentChange
}
+ func setDragging(_ dragging: Bool) {
+ guard dragging != isDragging else { return }
+ isDragging = dragging
+ guard let textView else { return }
+ if dragging {
+ textView.isEditable = false
+ textView.isSelectable = false
+ } else {
+ // Restore based on current completion state — updateUIView
+ // will also set these on the next SwiftUI evaluation pass.
+ textView.isEditable = true
+ textView.isSelectable = true
+ }
+ }
+
func textViewDidChange(_ textView: UITextView) {
text = textView.text
onContentChange?(textView.text)
diff --git a/ListlessiOS/Helpers/TaskRowDragGesture.swift b/ListlessiOS/Helpers/TaskRowDragGesture.swift
@@ -119,5 +119,16 @@ private struct SimultaneousDragGesture: UIGestureRecognizerRepresentable {
) -> Bool {
true
}
+
+ func gestureRecognizer(
+ _ gestureRecognizer: UIGestureRecognizer,
+ shouldBeRequiredToFailBy otherGestureRecognizer: UIGestureRecognizer
+ ) -> Bool {
+ // Make text view gestures (loupe, selection) wait for the drag
+ // gesture to fail before they can fire. If the drag succeeds,
+ // the text view gesture is cancelled — preventing the loupe
+ // from appearing during drag.
+ otherGestureRecognizer.view is UITextView
+ }
}
}
diff --git a/ListlessiOS/Helpers/TaskRowSwipeGesture.swift b/ListlessiOS/Helpers/TaskRowSwipeGesture.swift
@@ -3,6 +3,7 @@ import SwiftUI
extension View {
func taskSwipeGesture(
isDragging: Binding<Bool>,
+ isEditing: Bool,
isSwiping: Binding<Bool>,
swipeOffset: Binding<CGFloat>,
swipeDirection: Binding<TaskRowSwipeGesture.SwipeDirection>,
@@ -14,6 +15,7 @@ extension View {
self.modifier(
TaskRowSwipeGesture(
isDragging: isDragging,
+ isEditing: isEditing,
isSwiping: isSwiping,
swipeOffset: swipeOffset,
swipeDirection: swipeDirection,
@@ -27,6 +29,7 @@ extension View {
struct TaskRowSwipeGesture: ViewModifier {
@Binding var isDragging: Bool
+ let isEditing: Bool
@Binding var isSwiping: Bool
@Binding var swipeOffset: CGFloat
@Binding var swipeDirection: SwipeDirection
@@ -68,9 +71,9 @@ struct TaskRowSwipeGesture: ViewModifier {
.contentShape(Rectangle())
}
.applySwipeGesture(
- isDragging: isDragging,
+ isDisabled: isDragging || isEditing,
onChanged: { translation in
- guard !isDragging else { return }
+ guard !isDragging, !isEditing else { return }
updateActiveGestureAxis(
horizontalTranslation: translation.width,
verticalTranslation: abs(translation.height)
@@ -201,18 +204,18 @@ struct TaskRowSwipeGesture: ViewModifier {
/// `shouldRecognizeSimultaneouslyWith: true` so scrolling is preserved.
private extension View {
func applySwipeGesture(
- isDragging: Bool,
+ isDisabled: Bool,
onChanged: @escaping (CGSize) -> Void,
onEnded: @escaping () -> Void
) -> some View {
self.gesture(
SimultaneousSwipeGesture(
onChanged: { _, translation in
- guard !isDragging else { return }
+ guard !isDisabled else { return }
onChanged(translation)
},
onEnded: { _, _ in
- guard !isDragging else { return }
+ guard !isDisabled else { return }
onEnded()
}
)
diff --git a/ListlessiOS/Views/TaskListView.swift b/ListlessiOS/Views/TaskListView.swift
@@ -2,23 +2,28 @@ import SwiftUI
import UIKit
struct TaskListView: View, TaskListViewProtocol {
+ class LayoutStorage {
+ var rowFrames: [UUID: CGRect] = [:]
+ var contentBottomY: CGFloat = 0
+ }
+
struct InteractionStateData {
var dragState: DragState = .idle
- var pullToCreate = PullToCreateState()
- var pullUpOffset: CGFloat = 0
- var isDragging: Bool = false
var isShowingSyncDiagnostics = false
var isShowingSettings = false
var clearingTaskIDs: Set<UUID> = []
- var rowFrames: [UUID: CGRect] = [:]
var undoToast: UndoToastData? = nil
var isSwiping: Bool = false
var draftPlacement: DraftTaskPlacement?
var draftTitle: String = ""
- var contentBottomY: CGFloat = 0
var fetchWorkaround: Int = 0
}
+ struct PullStateData {
+ var pullToCreate = PullToCreateState()
+ var pullUpOffset: CGFloat = 0
+ }
+
@AppStorage("headingText") var headingText = "Items"
@AppStorage("colorTheme") private var colorThemeRaw = 0
private var colorTheme: ColorTheme { ColorTheme(rawValue: colorThemeRaw) ?? .original }
@@ -35,6 +40,9 @@ struct TaskListView: View, TaskListViewProtocol {
@FocusState private var focusedFieldBinding: FocusField?
@State var fState = FocusStateData()
@State var iState = InteractionStateData()
+ @State var pState = PullStateData()
+ @State var isDragging = false
+ @State var layoutStorage = LayoutStorage()
var focusedField: FocusField? {
get { fState.focusedField }
@@ -75,23 +83,20 @@ struct TaskListView: View, TaskListViewProtocol {
}
private var isDraggingStateBinding: Binding<Bool> {
- Binding(
- get: { iState.isDragging },
- set: { iState.isDragging = $0 }
- )
+ $isDragging
}
private var pullToCreateStateBinding: Binding<PullToCreateState> {
Binding(
- get: { iState.pullToCreate },
- set: { iState.pullToCreate = $0 }
+ get: { pState.pullToCreate },
+ set: { pState.pullToCreate = $0 }
)
}
private var pullUpOffsetStateBinding: Binding<CGFloat> {
Binding(
- get: { iState.pullUpOffset },
- set: { iState.pullUpOffset = $0 }
+ get: { pState.pullUpOffset },
+ set: { pState.pullUpOffset = $0 }
)
}
@@ -175,10 +180,10 @@ struct TaskListView: View, TaskListViewProtocol {
guard placement == .prepend else { return }
- var state = iState.pullToCreate
+ var state = pState.pullToCreate
state.isInsertionPending = false
state.indicatorOffset = 0
- iState.pullToCreate = state
+ pState.pullToCreate = state
}
if placement == .prepend, !hasTitle {
@@ -201,7 +206,7 @@ struct TaskListView: View, TaskListViewProtocol {
}
func didStartDrag() {
- iState.isDragging = true
+ isDragging = true
let generator = UIImpactFeedbackGenerator(style: .medium)
generator.impactOccurred()
}
@@ -216,20 +221,20 @@ struct TaskListView: View, TaskListViewProtocol {
private func dragScaleEffect(for taskID: UUID) -> CGFloat {
let liftPoints: CGFloat = 20
- guard let width = iState.rowFrames[taskID]?.width, width > 0 else { return 1.05 }
+ guard let width = layoutStorage.rowFrames[taskID]?.width, width > 0 else { return 1.05 }
return (width + liftPoints) / width
}
private var pullToCreateRevealHeight: CGFloat {
min(
- iState.pullToCreate.indicatorDisplayOffset(threshold: pullCreateThreshold),
+ pState.pullToCreate.indicatorDisplayOffset(threshold: pullCreateThreshold),
PullToCreateIndicator.indicatorHeight
)
}
private var pullToCreateGap: CGFloat {
- guard iState.pullToCreate.shouldShowIndicator, !isPrependDraftVisible else { return 0 }
- let exposedPull = iState.pullToCreate.indicatorDisplayOffset(threshold: pullCreateThreshold)
+ guard pState.pullToCreate.shouldShowIndicator, !isPrependDraftVisible else { return 0 }
+ let exposedPull = pState.pullToCreate.indicatorDisplayOffset(threshold: pullCreateThreshold)
return min(
vStackSpacing,
max(0, exposedPull - PullToCreateIndicator.indicatorHeight)
@@ -237,7 +242,7 @@ struct TaskListView: View, TaskListViewProtocol {
}
private var pullToCreateRowOverlap: CGFloat {
- guard iState.pullToCreate.shouldShowIndicator, !isPrependDraftVisible else {
+ guard pState.pullToCreate.shouldShowIndicator, !isPrependDraftVisible else {
return 0
}
return PullToCreateIndicator.indicatorHeight - pullToCreateRevealHeight
@@ -247,12 +252,12 @@ struct TaskListView: View, TaskListViewProtocol {
/// The phantom's UITextView is created while the indicator is visible
/// (during the pull), so it's ready when the user releases.
@ViewBuilder var pullToCreateIndicatorRow: some View {
- let showIndicator = iState.pullToCreate.shouldShowIndicator
+ let showIndicator = pState.pullToCreate.shouldShowIndicator
let showPhantom = isPrependDraftVisible
if showIndicator || showPhantom {
ZStack(alignment: .topLeading) {
PullToCreateIndicator(
- pullOffset: iState.pullToCreate.indicatorDisplayOffset(
+ pullOffset: pState.pullToCreate.indicatorDisplayOffset(
threshold: pullCreateThreshold
),
threshold: pullCreateThreshold,
@@ -366,7 +371,7 @@ struct TaskListView: View, TaskListViewProtocol {
)
.zIndex(draggedTaskID == taskID ? 2 : 1)
.taskDragGesture(
- isActive: !task.isCompleted,
+ isActive: !task.isCompleted && focusedFieldBinding != .task(taskID),
taskID: taskID,
onDragStart: { startDrag(taskID: taskID) },
onDragChanged: { point in handleIOSDragChanged(taskID: taskID, point: point) },
@@ -375,7 +380,7 @@ struct TaskListView: View, TaskListViewProtocol {
.onGeometryChange(for: CGRect.self) { proxy in
proxy.frame(in: .global)
} action: { frame in
- iState.rowFrames[taskID] = frame
+ layoutStorage.rowFrames[taskID] = frame
}
.id(taskID)
}
@@ -406,7 +411,7 @@ struct TaskListView: View, TaskListViewProtocol {
taskScrollView
.simultaneousGesture(
SpatialTapGesture(coordinateSpace: .global).onEnded { value in
- guard value.location.y > iState.contentBottomY else { return }
+ guard value.location.y > layoutStorage.contentBottomY else { return }
handleBackgroundTap()
}
)
@@ -483,7 +488,7 @@ struct TaskListView: View, TaskListViewProtocol {
}
.padding(
.bottom,
- (iState.pullToCreate.shouldShowIndicator && !isPrependDraftVisible)
+ (pState.pullToCreate.shouldShowIndicator && !isPrependDraftVisible)
? (pullToCreateGap - vStackSpacing) : 0
)
taskRows
@@ -493,11 +498,11 @@ struct TaskListView: View, TaskListViewProtocol {
.onGeometryChange(for: CGFloat.self) {
$0.frame(in: .global).maxY
} action: {
- iState.contentBottomY = $0
+ layoutStorage.contentBottomY = $0
}
.padding(.trailing, 16)
.padding(.vertical, 12)
- .offset(y: -iState.pullToCreate.pullOffset)
+ .offset(y: -pState.pullToCreate.pullOffset)
.onChange(of: focusedFieldBinding) { oldValue, newValue in
fState.focusedField = newValue
handleFocusChange(from: oldValue, to: newValue)
diff --git a/ListlessiOS/Views/TaskRowView.swift b/ListlessiOS/Views/TaskRowView.swift
@@ -155,6 +155,7 @@ struct TaskRowView: View {
}
.taskSwipeGesture(
isDragging: $isDragging,
+ isEditing: focusedField == .task(taskID),
isSwiping: $isSwiping,
swipeOffset: $swipeOffset,
swipeDirection: $swipeDirection,