← All repositories

listless

A simple list app for Apple platforms

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


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(