listless

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

commit fd394d0ced12629c25d27e171d40474bb02eef21
parent 54a3d9759c703f67c1c9a33c8763ee0360dbb90d
Author: Michael Camilleri <[email protected]>
Date:   Thu, 19 Mar 2026 08:25:22 +0900

Reimplement pan gestures for iOS 26 compatibility

Much to my frustration, I have discovered that Apple changed the way
that gesture recognisers interact in iOS 26 in a way that is completely
incompatible with the approach I was taking in Listless. There remain
broken aspects and I'm not sure the approach taken in this commmit is
correct but I want to record progress so am saving it to the repository
for now.

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

Diffstat:
MListlessiOS/Helpers/TaskRowDragGesture.swift | 111++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------
MListlessiOS/Helpers/TaskRowSwipeGesture.swift | 125+++++++++++--------------------------------------------------------------------
MListlessiOS/Views/TaskListView.swift | 115+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MListlessiOS/Views/TaskRowView.swift | 34++++++++++++++++------------------
4 files changed, 238 insertions(+), 147 deletions(-)

diff --git a/ListlessiOS/Helpers/TaskRowDragGesture.swift b/ListlessiOS/Helpers/TaskRowDragGesture.swift @@ -27,28 +27,97 @@ struct TaskRowDragGesture: ViewModifier { let onDragEnded: () -> Void func body(content: Content) -> some View { - content - .simultaneousGesture( - LongPressGesture(minimumDuration: 0.4) - .sequenced(before: DragGesture(minimumDistance: 0, coordinateSpace: .global)) - .onChanged { value in - switch value { - case .second(true, let drag): - // Fire onDragStart as soon as the long press completes so the - // row lifts visually before any finger movement. onDragStart is - // idempotent (guarded in TaskListView). - onDragStart() - if let drag { - onDragChanged(drag.location) + if #available(iOS 18, *) { + content + .gesture( + SimultaneousDragGesture( + isActive: isActive, + onDragStart: onDragStart, + onDragChanged: onDragChanged, + onDragEnded: onDragEnded + ) + ) + } else { + content + .simultaneousGesture( + LongPressGesture(minimumDuration: 0.4) + .sequenced(before: DragGesture(minimumDistance: 0, coordinateSpace: .global)) + .onChanged { value in + switch value { + case .second(true, let drag): + onDragStart() + if let drag { + onDragChanged(drag.location) + } + default: + break } - default: - break } - } - .onEnded { _ in - onDragEnded() - }, - including: isActive ? .all : .none - ) + .onEnded { _ in + onDragEnded() + }, + including: isActive ? .all : .none + ) + } + } +} + +// MARK: - iOS 26 workaround + +/// Uses UILongPressGestureRecognizer (minimumPressDuration: 0.4) via +/// UIGestureRecognizerRepresentable to avoid iOS 26's child-gesture-blocks- +/// ancestor issue. The delegate returns shouldRecognizeSimultaneouslyWith:true +/// so the ScrollView's pan gesture is preserved. +@available(iOS 18.0, *) +private struct SimultaneousDragGesture: UIGestureRecognizerRepresentable { + let isActive: Bool + let onDragStart: () -> Void + let onDragChanged: (CGPoint) -> Void + let onDragEnded: () -> Void + + func makeUIGestureRecognizer(context: Context) -> UILongPressGestureRecognizer { + let recognizer = UILongPressGestureRecognizer() + recognizer.minimumPressDuration = 0.4 + recognizer.delegate = context.coordinator + recognizer.isEnabled = isActive + return recognizer + } + + func updateUIGestureRecognizer(_ recognizer: UILongPressGestureRecognizer, context: Context) { + recognizer.isEnabled = isActive + } + + func handleUIGestureRecognizerAction( + _ recognizer: UILongPressGestureRecognizer, context: Context + ) { + switch recognizer.state { + case .began: + // Long press completed — fire drag start immediately so the row + // lifts visually before any finger movement. + onDragStart() + + case .changed: + let location = recognizer.location(in: recognizer.view?.window) + onDragChanged(location) + + case .ended, .cancelled: + onDragEnded() + + default: + break + } + } + + func makeCoordinator(converter: CoordinateSpaceConverter) -> Coordinator { + Coordinator() + } + + class Coordinator: NSObject, UIGestureRecognizerDelegate { + func gestureRecognizer( + _ gestureRecognizer: UIGestureRecognizer, + shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer + ) -> Bool { + true + } } } diff --git a/ListlessiOS/Helpers/TaskRowSwipeGesture.swift b/ListlessiOS/Helpers/TaskRowSwipeGesture.swift @@ -1,38 +1,30 @@ import SwiftUI extension View { - func taskSwipeGesture( - isDragging: Binding<Bool>, - swipeOffset: Binding<CGFloat>, - swipeDirection: Binding<TaskRowSwipeGesture.SwipeDirection>, - isTriggered: Binding<Bool>, - completeColor: Color = .green, - onComplete: @escaping () -> Void, - onDelete: @escaping () -> Void + func taskSwipeVisuals( + swipeOffset: CGFloat, + swipeDirection: TaskRowSwipeGesture.SwipeDirection, + isTriggered: Bool, + completeColor: Color = .green ) -> some View { self.modifier( TaskRowSwipeGesture( - isDragging: isDragging, swipeOffset: swipeOffset, swipeDirection: swipeDirection, isTriggered: isTriggered, - completeColor: completeColor, - onComplete: onComplete, - onDelete: onDelete + completeColor: completeColor )) } } +/// Visual-only modifier that draws the swipe background (complete/delete) and +/// offsets the row content horizontally. The actual gesture is handled at the +/// ScrollView level to avoid iOS 26's child-gesture-blocks-ancestor issue. struct TaskRowSwipeGesture: ViewModifier { - @Binding var isDragging: Bool - @Binding var swipeOffset: CGFloat - @Binding var swipeDirection: SwipeDirection - @Binding var isTriggered: Bool + let swipeOffset: CGFloat + let swipeDirection: SwipeDirection + let isTriggered: Bool let completeColor: Color - let onComplete: () -> Void - let onDelete: () -> Void - - @State private var hapticTrigger = false enum SwipeDirection: Equatable { case left @@ -40,10 +32,10 @@ struct TaskRowSwipeGesture: ViewModifier { case none } - private let completeThreshold: CGFloat = 40 // Pixels to swipe right before triggering complete - private let deleteThreshold: CGFloat = 80 // Pixels to swipe left before triggering delete - private let horizontalBufferPt: CGFloat = 10 // Horizontal movement must exceed vertical by this amount - private let offsetDamping: CGFloat = 0.9 // Damping factor for responsive feel + static let completeThreshold: CGFloat = 40 + static let deleteThreshold: CGFloat = 80 + static let horizontalBufferPt: CGFloat = 10 + static let offsetDamping: CGFloat = 0.9 func body(content: Content) -> some View { ZStack(alignment: .leading) { @@ -57,24 +49,6 @@ struct TaskRowSwipeGesture: ViewModifier { .offset(x: swipeOffset) .contentShape(Rectangle()) } - .simultaneousGesture( - DragGesture(minimumDistance: 10, coordinateSpace: .local) - .onChanged { value in - guard !isDragging else { return } - handleDragChanged( - horizontalTranslation: value.translation.width, - verticalTranslation: abs(value.translation.height) - ) - } - .onEnded { _ in - handleDragEnded() - }, - including: isDragging ? .none : .all - ) - .sensoryFeedback(.impact(weight: .medium), trigger: hapticTrigger) - .onDisappear { - resetSwipeState() - } } @ViewBuilder @@ -97,73 +71,8 @@ struct TaskRowSwipeGesture: ViewModifier { } } - private func handleDragChanged(horizontalTranslation: CGFloat, verticalTranslation: CGFloat) { - // Require horizontal > vertical + buffer to activate swipe - guard abs(horizontalTranslation) > verticalTranslation + horizontalBufferPt else { - return - } - - if horizontalTranslation > 0 { - swipeDirection = .right - } else if horizontalTranslation < 0 { - swipeDirection = .left - } - - // Update offset with damping - swipeOffset = horizontalTranslation * offsetDamping - - // Track whether threshold is currently crossed — reversible until release - if swipeDirection == .right { - isTriggered = swipeOffset >= completeThreshold - } else if swipeDirection == .left { - isTriggered = abs(swipeOffset) >= deleteThreshold - } - } - - private func handleDragEnded() { - guard !isDragging else { - // A drag-reorder started during or after this swipe — spring back, no action. - withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { - resetSwipeState() - } - return - } - if isTriggered { - if swipeDirection == .right { - // Complete: spring back and let SwiftUI animate the row to the completed section - triggerAction(action: onComplete) - withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { - resetSwipeState() - } - } else { - // Delete: slide off screen - triggerAction(action: onDelete) - withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { - swipeOffset = -400 - } - } - } else { - // Released below threshold — spring back with no action - withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { - resetSwipeState() - } - } - } - - private func triggerAction(action: () -> Void) { - isTriggered = true - hapticTrigger.toggle() - action() - } - - private func resetSwipeState() { - swipeOffset = 0 - swipeDirection = .none - isTriggered = false - } - private func backgroundOpacity(offset: CGFloat) -> CGFloat { - let threshold = offset >= 0 ? completeThreshold : deleteThreshold + let threshold = offset >= 0 ? Self.completeThreshold : Self.deleteThreshold return min(abs(offset) / threshold, 1.0) } } diff --git a/ListlessiOS/Views/TaskListView.swift b/ListlessiOS/Views/TaskListView.swift @@ -14,6 +14,13 @@ struct TaskListView: View, TaskListViewProtocol { var undoToast: UndoToastData? = nil var draftPlacement: DraftTaskPlacement? var draftTitle: String = "" + + // ScrollView-level swipe state (iOS 26 workaround) + var isScrolling: Bool = false + var swipingTaskID: UUID? = nil + var swipeOffset: CGFloat = 0 + var swipeDirection: TaskRowSwipeGesture.SwipeDirection = .none + var isSwipeTriggered: Bool = false } @AppStorage("headingText") var headingText = "Items" @@ -197,8 +204,94 @@ struct TaskListView: View, TaskListViewProtocol { func didStartDrag() { iState.isDragging = true + resetSwipeState() + let generator = UIImpactFeedbackGenerator(style: .medium) + generator.impactOccurred() + } + + // MARK: - ScrollView-level swipe gesture + + private func handleScrollSwipeChanged(_ value: DragGesture.Value) { + guard !iState.isDragging, !iState.isScrolling else { return } + + // On first change, hit-test to find which row the gesture started in. + if iState.swipingTaskID == nil { + iState.swipeOffset = 0 + iState.swipeDirection = .none + iState.isSwipeTriggered = false + + for (id, frame) in iState.rowFrames { + if frame.contains(value.startLocation) { + iState.swipingTaskID = id + break + } + } + } + + guard iState.swipingTaskID != nil else { return } + + let horizontal = value.translation.width + let vertical = abs(value.translation.height) + + // Require horizontal > vertical + buffer to activate swipe + guard abs(horizontal) > vertical + TaskRowSwipeGesture.horizontalBufferPt else { + return + } + + if horizontal > 0 { + iState.swipeDirection = .right + } else { + iState.swipeDirection = .left + } + + iState.swipeOffset = horizontal * TaskRowSwipeGesture.offsetDamping + + if iState.swipeDirection == .right { + iState.isSwipeTriggered = iState.swipeOffset >= TaskRowSwipeGesture.completeThreshold + } else { + iState.isSwipeTriggered = abs(iState.swipeOffset) >= TaskRowSwipeGesture.deleteThreshold + } + } + + private func handleScrollSwipeEnded(_ value: DragGesture.Value) { + guard !iState.isDragging else { + withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { + resetSwipeState() + } + return + } + + guard let taskID = iState.swipingTaskID, + iState.isSwipeTriggered, + let task = tasks.first(where: { $0.id == taskID }) + else { + withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { + resetSwipeState() + } + return + } + let generator = UIImpactFeedbackGenerator(style: .medium) generator.impactOccurred() + + if iState.swipeDirection == .right { + toggleCompletion(task) + withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { + resetSwipeState() + } + } else if iState.swipeDirection == .left { + deleteTaskWithUndo(task) + withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { + iState.swipeOffset = -400 + } + } + } + + private func resetSwipeState() { + iState.swipingTaskID = nil + iState.swipeOffset = 0 + iState.swipeDirection = .none + iState.isSwipeTriggered = false } func showSyncDiagnostics() { @@ -286,6 +379,7 @@ struct TaskListView: View, TaskListViewProtocol { text: draftTitleBinding, isCompleted: false, isDragging: false, + onEditingChanged: { editing, _ in DispatchQueue.main.async { if editing { @@ -349,6 +443,7 @@ struct TaskListView: View, TaskListViewProtocol { text: draftTitleBinding, isCompleted: false, isDragging: false, + onEditingChanged: { editing, shouldCreateNewTask in DispatchQueue.main.async { if editing { @@ -414,6 +509,9 @@ struct TaskListView: View, TaskListViewProtocol { isDragging: isDraggingStateBinding, isLastActiveTask: index == displayActiveTasks.count - 1, focusedField: $focusedFieldBinding, + swipeOffset: iState.swipingTaskID == taskID ? iState.swipeOffset : 0, + swipeDirection: iState.swipingTaskID == taskID ? iState.swipeDirection : .none, + isSwipeTriggered: iState.swipingTaskID == taskID ? iState.isSwipeTriggered : false, onToggle: { toggleCompletion($0) }, onTitleChange: { updateTitle($0, $1) }, onDelete: { deleteTaskWithUndo($0) }, @@ -457,6 +555,9 @@ struct TaskListView: View, TaskListViewProtocol { taskID: taskID, isSelected: fState.selectedTaskID == taskID, focusedField: $focusedFieldBinding, + swipeOffset: iState.swipingTaskID == taskID ? iState.swipeOffset : 0, + swipeDirection: iState.swipingTaskID == taskID ? iState.swipeDirection : .none, + isSwipeTriggered: iState.swipingTaskID == taskID ? iState.isSwipeTriggered : false, onToggle: { toggleCompletion($0) }, onTitleChange: { updateTitle($0, $1) }, onDelete: { deleteTaskWithUndo($0) }, @@ -464,6 +565,11 @@ struct TaskListView: View, TaskListViewProtocol { ) .opacity(isBeingCleared ? 0 : 1) .offset(y: isBeingCleared ? 40 : 0) + .onGeometryChange(for: CGRect.self) { proxy in + proxy.frame(in: .global) + } action: { frame in + iState.rowFrames[taskID] = frame + } .id(taskID) } } @@ -596,6 +702,9 @@ struct TaskListView: View, TaskListViewProtocol { } .scrollDisabled(draggedTaskID != nil) .scrollBounceBehavior(.always) + .onScrollPhaseChange { _, newPhase in + iState.isScrolling = newPhase != .idle + } .contentMargins(.bottom, 20) .background { Color.outerBackground.ignoresSafeArea() @@ -631,5 +740,11 @@ struct TaskListView: View, TaskListViewProtocol { } } ) + .simultaneousGesture( + DragGesture(minimumDistance: 10, coordinateSpace: .global) + .onChanged { handleScrollSwipeChanged($0) } + .onEnded { handleScrollSwipeEnded($0) }, + including: iState.isDragging ? .none : .all + ) } } diff --git a/ListlessiOS/Views/TaskRowView.swift b/ListlessiOS/Views/TaskRowView.swift @@ -16,11 +16,13 @@ struct TaskRowView: View { let onEndEdit: (UUID, _ shouldCreateNewTask: Bool) -> Void @FocusState.Binding var focusedField: FocusField? + // Swipe state driven by parent (ScrollView-level gesture) + let swipeOffset: CGFloat + let swipeDirection: TaskRowSwipeGesture.SwipeDirection + let isSwipeTriggered: Bool + @State private var editingTitle: String = "" @State private var isCurrentlyEditing: Bool = false - @State private var swipeOffset: CGFloat = 0 - @State private var swipeDirection: TaskRowSwipeGesture.SwipeDirection = .none - @State private var isSwipeTriggered: Bool = false @State private var cachedAccentColor: Color = .clear init( @@ -32,6 +34,9 @@ struct TaskRowView: View { isDragging: Binding<Bool> = .constant(false), isLastActiveTask: Bool = false, focusedField: FocusState<FocusField?>.Binding, + swipeOffset: CGFloat = 0, + swipeDirection: TaskRowSwipeGesture.SwipeDirection = .none, + isSwipeTriggered: Bool = false, onToggle: @escaping (TaskItem) -> Void, onTitleChange: @escaping (TaskItem, String) -> Void, onDelete: @escaping (TaskItem) -> Void, @@ -53,6 +58,9 @@ struct TaskRowView: View { self.onStartEdit = onStartEdit self.onEndEdit = onEndEdit _focusedField = focusedField + self.swipeOffset = swipeOffset + self.swipeDirection = swipeDirection + self.isSwipeTriggered = isSwipeTriggered } var body: some View { @@ -145,21 +153,11 @@ struct TaskRowView: View { .onChange(of: totalTasks) { _, _ in cachedAccentColor = computeAccentColor() } - .onChange(of: isDragging) { _, dragging in - if dragging { - swipeOffset = 0 - swipeDirection = .none - isSwipeTriggered = false - } - } - .taskSwipeGesture( - isDragging: $isDragging, - swipeOffset: $swipeOffset, - swipeDirection: $swipeDirection, - isTriggered: $isSwipeTriggered, - completeColor: cachedAccentColor, - onComplete: { onToggle(task) }, - onDelete: { onDelete(task) } + .taskSwipeVisuals( + swipeOffset: swipeOffset, + swipeDirection: swipeDirection, + isTriggered: isSwipeTriggered, + completeColor: cachedAccentColor ) .clipShape( UnevenRoundedRectangle(