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:
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(