commit c21a1e4aeb11ec20f2d2316044b4cee4cbf1a2fe
parent fd394d0ced12629c25d27e171d40474bb02eef21
Author: Michael Camilleri <[email protected]>
Date: Thu, 19 Mar 2026 09:18:10 +0900
Move gesture recognisers to row-level
The previous commit used a workaround for iOS 26 that put the horizontal
pan gestures at the list level (they had previously been at the row
level). It is possible to put them at the row level, though, and this
commit does that. It's not obvious which is the better approach.
Co-Authored-By: Claude 4.6 Opus <[email protected]>
Diffstat:
3 files changed, 238 insertions(+), 143 deletions(-)
diff --git a/ListlessiOS/Helpers/TaskRowSwipeGesture.swift b/ListlessiOS/Helpers/TaskRowSwipeGesture.swift
@@ -1,30 +1,41 @@
import SwiftUI
extension View {
- func taskSwipeVisuals(
- swipeOffset: CGFloat,
- swipeDirection: TaskRowSwipeGesture.SwipeDirection,
- isTriggered: Bool,
- completeColor: Color = .green
+ func taskSwipeGesture(
+ isDragging: Binding<Bool>,
+ isScrolling: Bool,
+ swipeOffset: Binding<CGFloat>,
+ swipeDirection: Binding<TaskRowSwipeGesture.SwipeDirection>,
+ isTriggered: Binding<Bool>,
+ completeColor: Color = .green,
+ onComplete: @escaping () -> Void,
+ onDelete: @escaping () -> Void
) -> some View {
self.modifier(
TaskRowSwipeGesture(
+ isDragging: isDragging,
+ isScrolling: isScrolling,
swipeOffset: swipeOffset,
swipeDirection: swipeDirection,
isTriggered: isTriggered,
- completeColor: completeColor
+ completeColor: completeColor,
+ onComplete: onComplete,
+ onDelete: onDelete
))
}
}
-/// 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 {
- let swipeOffset: CGFloat
- let swipeDirection: SwipeDirection
- let isTriggered: Bool
+ @Binding var isDragging: Bool
+ let isScrolling: Bool
+ @Binding var swipeOffset: CGFloat
+ @Binding var swipeDirection: SwipeDirection
+ @Binding var isTriggered: Bool
let completeColor: Color
+ let onComplete: () -> Void
+ let onDelete: () -> Void
+
+ @State private var hapticTrigger = false
enum SwipeDirection: Equatable {
case left
@@ -32,10 +43,10 @@ struct TaskRowSwipeGesture: ViewModifier {
case none
}
- static let completeThreshold: CGFloat = 40
- static let deleteThreshold: CGFloat = 80
- static let horizontalBufferPt: CGFloat = 10
- static let offsetDamping: CGFloat = 0.9
+ private let completeThreshold: CGFloat = 40
+ private let deleteThreshold: CGFloat = 80
+ private let horizontalBufferPt: CGFloat = 10
+ private let offsetDamping: CGFloat = 0.9
func body(content: Content) -> some View {
ZStack(alignment: .leading) {
@@ -49,6 +60,23 @@ struct TaskRowSwipeGesture: ViewModifier {
.offset(x: swipeOffset)
.contentShape(Rectangle())
}
+ .applySwipeGesture(
+ isDragging: isDragging,
+ onChanged: { translation in
+ guard !isDragging, !isScrolling else { return }
+ handleDragChanged(
+ horizontalTranslation: translation.width,
+ verticalTranslation: abs(translation.height)
+ )
+ },
+ onEnded: {
+ handleDragEnded()
+ }
+ )
+ .sensoryFeedback(.impact(weight: .medium), trigger: hapticTrigger)
+ .onDisappear {
+ resetSwipeState()
+ }
}
@ViewBuilder
@@ -71,8 +99,176 @@ 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 ? Self.completeThreshold : Self.deleteThreshold
+ let threshold = offset >= 0 ? completeThreshold : deleteThreshold
return min(abs(offset) / threshold, 1.0)
}
}
+
+// MARK: - iOS 26 workaround: UIGestureRecognizerRepresentable
+
+/// On iOS 26, `.simultaneousGesture(DragGesture())` on a child view blocks the
+/// ancestor ScrollView's scrolling. This uses a `UILongPressGestureRecognizer`
+/// (with zero press duration and infinite allowable movement) as a pan substitute,
+/// applied via `UIGestureRecognizerRepresentable`. The gesture delegate returns
+/// `shouldRecognizeSimultaneouslyWith: true` so scrolling is preserved.
+///
+/// On iOS < 18 (where UIGestureRecognizerRepresentable isn't available), the
+/// original `.simultaneousGesture(DragGesture(...))` is used.
+private extension View {
+ @ViewBuilder
+ func applySwipeGesture(
+ isDragging: Bool,
+ onChanged: @escaping (CGSize) -> Void,
+ onEnded: @escaping () -> Void
+ ) -> some View {
+ if #available(iOS 18, *) {
+ self.gesture(
+ SimultaneousSwipeGesture(
+ onChanged: { _, translation in
+ guard !isDragging else { return }
+ onChanged(translation)
+ },
+ onEnded: { _, _ in
+ guard !isDragging else { return }
+ onEnded()
+ }
+ )
+ )
+ } else {
+ self.simultaneousGesture(
+ DragGesture(minimumDistance: 10, coordinateSpace: .local)
+ .onChanged { value in
+ onChanged(value.translation)
+ }
+ .onEnded { _ in
+ onEnded()
+ },
+ including: isDragging ? .none : .all
+ )
+ }
+ }
+}
+
+@available(iOS 18.0, *)
+private struct SimultaneousSwipeGesture: UIGestureRecognizerRepresentable {
+ let onChanged: (UILongPressGestureRecognizer, CGSize) -> Void
+ let onEnded: (UILongPressGestureRecognizer, CGSize) -> Void
+
+ func makeUIGestureRecognizer(context: Context) -> UILongPressGestureRecognizer {
+ let recognizer = UILongPressGestureRecognizer()
+ recognizer.minimumPressDuration = 0.0
+ recognizer.allowableMovement = CGFloat.greatestFiniteMagnitude
+ recognizer.delegate = context.coordinator
+ return recognizer
+ }
+
+ func handleUIGestureRecognizerAction(
+ _ recognizer: UILongPressGestureRecognizer, context: Context
+ ) {
+ switch recognizer.state {
+ case .began:
+ context.coordinator.startLocation = recognizer.location(in: recognizer.view)
+
+ case .changed:
+ let location = recognizer.location(in: recognizer.view)
+ let translation = CGSize(
+ width: location.x - context.coordinator.startLocation.x,
+ height: location.y - context.coordinator.startLocation.y
+ )
+ onChanged(recognizer, translation)
+
+ case .ended, .cancelled:
+ let location = recognizer.location(in: recognizer.view)
+ let translation = CGSize(
+ width: location.x - context.coordinator.startLocation.x,
+ height: location.y - context.coordinator.startLocation.y
+ )
+ context.coordinator.startLocation = .zero
+ onEnded(recognizer, translation)
+
+ default:
+ break
+ }
+ }
+
+ func makeCoordinator(converter: CoordinateSpaceConverter) -> Coordinator {
+ Coordinator()
+ }
+
+ class Coordinator: NSObject, UIGestureRecognizerDelegate {
+ var startLocation: CGPoint = .zero
+
+ func gestureRecognizer(
+ _ gestureRecognizer: UIGestureRecognizer,
+ shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer
+ ) -> Bool {
+ true
+ }
+ }
+}
diff --git a/ListlessiOS/Views/TaskListView.swift b/ListlessiOS/Views/TaskListView.swift
@@ -12,15 +12,9 @@ struct TaskListView: View, TaskListViewProtocol {
var clearingTaskIDs: Set<UUID> = []
var rowFrames: [UUID: CGRect] = [:]
var undoToast: UndoToastData? = nil
+ var isScrolling: Bool = false
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"
@@ -204,94 +198,8 @@ 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() {
@@ -507,11 +415,9 @@ struct TaskListView: View, TaskListViewProtocol {
totalTasks: displayActiveTasks.count,
isSelected: fState.selectedTaskID == taskID,
isDragging: isDraggingStateBinding,
+ isScrolling: iState.isScrolling,
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) },
@@ -554,10 +460,8 @@ struct TaskListView: View, TaskListViewProtocol {
task: task,
taskID: taskID,
isSelected: fState.selectedTaskID == taskID,
+ isScrolling: iState.isScrolling,
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) },
@@ -565,11 +469,6 @@ 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)
}
}
@@ -740,11 +639,5 @@ 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
@@ -7,6 +7,7 @@ struct TaskRowView: View {
let totalTasks: Int
let isSelected: Bool
@Binding var isDragging: Bool
+ let isScrolling: Bool
let onToggle: (TaskItem) -> Void
let onTitleChange: (TaskItem, String) -> Void
let onDelete: (TaskItem) -> Void
@@ -16,11 +17,9 @@ 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 swipeOffset: CGFloat = 0
+ @State private var swipeDirection: TaskRowSwipeGesture.SwipeDirection = .none
+ @State private var isSwipeTriggered: Bool = false
@State private var editingTitle: String = ""
@State private var isCurrentlyEditing: Bool = false
@State private var cachedAccentColor: Color = .clear
@@ -32,11 +31,9 @@ struct TaskRowView: View {
totalTasks: Int = 1,
isSelected: Bool,
isDragging: Binding<Bool> = .constant(false),
+ isScrolling: Bool = 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,
@@ -50,6 +47,7 @@ struct TaskRowView: View {
self.totalTasks = totalTasks
self.isSelected = isSelected
_isDragging = isDragging
+ self.isScrolling = isScrolling
self.isLastActiveTask = isLastActiveTask
self.onToggle = onToggle
self.onTitleChange = onTitleChange
@@ -58,9 +56,6 @@ struct TaskRowView: View {
self.onStartEdit = onStartEdit
self.onEndEdit = onEndEdit
_focusedField = focusedField
- self.swipeOffset = swipeOffset
- self.swipeDirection = swipeDirection
- self.isSwipeTriggered = isSwipeTriggered
}
var body: some View {
@@ -153,12 +148,23 @@ struct TaskRowView: View {
.onChange(of: totalTasks) { _, _ in
cachedAccentColor = computeAccentColor()
}
- .taskSwipeVisuals(
- swipeOffset: swipeOffset,
- swipeDirection: swipeDirection,
- isTriggered: isSwipeTriggered,
- completeColor: cachedAccentColor
+ .taskSwipeGesture(
+ isDragging: $isDragging,
+ isScrolling: isScrolling,
+ swipeOffset: $swipeOffset,
+ swipeDirection: $swipeDirection,
+ isTriggered: $isSwipeTriggered,
+ completeColor: cachedAccentColor,
+ onComplete: { onToggle(task) },
+ onDelete: { onDelete(task) }
)
+ .onChange(of: isDragging) { _, newValue in
+ if newValue {
+ swipeOffset = 0
+ swipeDirection = .none
+ isSwipeTriggered = false
+ }
+ }
.clipShape(
UnevenRoundedRectangle(
topLeadingRadius: 0, bottomLeadingRadius: 0,