listless

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

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:
MAGENTS.md | 6+++---
MListlessiOS/Extensions/TaskListView+Drag.swift | 8++++----
MListlessiOS/Extensions/TaskListView+PullToClear.swift | 4++--
MListlessiOS/Helpers/TappableTextField.swift | 34++++++++++++++++++++++++++++++++--
MListlessiOS/Helpers/TaskRowDragGesture.swift | 11+++++++++++
MListlessiOS/Helpers/TaskRowSwipeGesture.swift | 13++++++++-----
MListlessiOS/Views/TaskListView.swift | 63++++++++++++++++++++++++++++++++++-----------------------------
MListlessiOS/Views/TaskRowView.swift | 1+
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,