commit c345c5b50d3817879cf0d6bd0248ad2bd3cfc607
parent 03fc0ea2ba94d05daca8b79e54100f70b8602fb1
Author: Michael Camilleri <[email protected]>
Date: Fri, 20 Mar 2026 20:31:33 +0900
Prevent simultaneous scrolling and swiping in iOS version
Since the hacky workaround being used to support swipe gestures on rows
requires Listless to simultaneously recognise both horizontal swiping
and vertical scrolling, it was possible prior to this commit to be
swiping on a row while also creating a new row with the pull-to-create
gesture. This commit prevents that so that once scrolling begins,
swiping cannot happen and vice versa.
Co-Authored-By: Claude 4.6 Opus <[email protected]>
Diffstat:
3 files changed, 31 insertions(+), 55 deletions(-)
diff --git a/ListlessiOS/Helpers/TaskRowSwipeGesture.swift b/ListlessiOS/Helpers/TaskRowSwipeGesture.swift
@@ -3,7 +3,7 @@ import SwiftUI
extension View {
func taskSwipeGesture(
isDragging: Binding<Bool>,
- isScrolling: Bool,
+ isSwiping: Binding<Bool>,
swipeOffset: Binding<CGFloat>,
swipeDirection: Binding<TaskRowSwipeGesture.SwipeDirection>,
isTriggered: Binding<Bool>,
@@ -14,7 +14,7 @@ extension View {
self.modifier(
TaskRowSwipeGesture(
isDragging: isDragging,
- isScrolling: isScrolling,
+ isSwiping: isSwiping,
swipeOffset: swipeOffset,
swipeDirection: swipeDirection,
isTriggered: isTriggered,
@@ -27,7 +27,7 @@ extension View {
struct TaskRowSwipeGesture: ViewModifier {
@Binding var isDragging: Bool
- let isScrolling: Bool
+ @Binding var isSwiping: Bool
@Binding var swipeOffset: CGFloat
@Binding var swipeDirection: SwipeDirection
@Binding var isTriggered: Bool
@@ -76,10 +76,7 @@ struct TaskRowSwipeGesture: ViewModifier {
verticalTranslation: abs(translation.height)
)
guard activeGestureAxis == .horizontal else { return }
- handleDragChanged(
- horizontalTranslation: translation.width,
- verticalTranslation: abs(translation.height)
- )
+ handleDragChanged(horizontalTranslation: translation.width)
},
onEnded: {
handleDragEnded()
@@ -111,12 +108,7 @@ struct TaskRowSwipeGesture: ViewModifier {
}
}
- private func handleDragChanged(horizontalTranslation: CGFloat, verticalTranslation: CGFloat) {
- // Require horizontal > vertical + buffer to activate swipe
- guard abs(horizontalTranslation) > verticalTranslation + horizontalBufferPt else {
- return
- }
-
+ private func handleDragChanged(horizontalTranslation: CGFloat) {
if horizontalTranslation > 0 {
swipeDirection = .right
} else if horizontalTranslation < 0 {
@@ -135,7 +127,10 @@ struct TaskRowSwipeGesture: ViewModifier {
}
private func handleDragEnded() {
- defer { activeGestureAxis = .undecided }
+ defer {
+ activeGestureAxis = .undecided
+ isSwiping = false
+ }
guard !isDragging else {
// A drag-reorder started during or after this swipe — spring back, no action.
@@ -176,6 +171,7 @@ struct TaskRowSwipeGesture: ViewModifier {
swipeOffset = 0
swipeDirection = .none
isTriggered = false
+ isSwiping = false
activeGestureAxis = .undecided
}
@@ -189,58 +185,41 @@ struct TaskRowSwipeGesture: ViewModifier {
if abs(horizontalTranslation) > verticalTranslation + horizontalBufferPt {
activeGestureAxis = .horizontal
+ isSwiping = true
} else if verticalTranslation > abs(horizontalTranslation) + horizontalBufferPt {
activeGestureAxis = .vertical
}
}
}
-// MARK: - iOS 26 workaround: UIGestureRecognizerRepresentable
+// MARK: - UIGestureRecognizerRepresentable swipe gesture
/// 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
+ self.gesture(
+ SimultaneousSwipeGesture(
+ onChanged: { _, translation in
+ guard !isDragging else { return }
+ onChanged(translation)
+ },
+ onEnded: { _, _ in
+ guard !isDragging else { return }
+ onEnded()
+ }
)
- }
+ )
}
}
-@available(iOS 18.0, *)
private struct SimultaneousSwipeGesture: UIGestureRecognizerRepresentable {
let onChanged: (UILongPressGestureRecognizer, CGSize) -> Void
let onEnded: (UILongPressGestureRecognizer, CGSize) -> Void
diff --git a/ListlessiOS/Views/TaskListView.swift b/ListlessiOS/Views/TaskListView.swift
@@ -12,7 +12,7 @@ struct TaskListView: View, TaskListViewProtocol {
var clearingTaskIDs: Set<UUID> = []
var rowFrames: [UUID: CGRect] = [:]
var undoToast: UndoToastData? = nil
- var isScrolling: Bool = false
+ var isSwiping: Bool = false
var draftPlacement: DraftTaskPlacement?
var draftTitle: String = ""
var contentBottomY: CGFloat = 0
@@ -344,7 +344,7 @@ struct TaskListView: View, TaskListViewProtocol {
totalTasks: displayActiveTasks.count + draftTotal,
isSelected: fState.selectedTaskID == taskID,
isDragging: isDraggingStateBinding,
- isScrolling: iState.isScrolling,
+ isSwiping: $iState.isSwiping,
isLastActiveTask: index == displayActiveTasks.count - 1,
focusedField: $focusedFieldBinding,
onToggle: { toggleCompletion($0); withAnimation { iState.fetchWorkaround &+= 1 } },
@@ -389,7 +389,7 @@ struct TaskListView: View, TaskListViewProtocol {
task: task,
taskID: taskID,
isSelected: fState.selectedTaskID == taskID,
- isScrolling: iState.isScrolling,
+ isSwiping: $iState.isSwiping,
focusedField: $focusedFieldBinding,
onToggle: { toggleCompletion($0); withAnimation { iState.fetchWorkaround &+= 1 } },
onTitleChange: { updateTitle($0, $1) },
@@ -535,11 +535,8 @@ struct TaskListView: View, TaskListViewProtocol {
}
}
}
- .scrollDisabled(draggedTaskID != nil)
+ .scrollDisabled(draggedTaskID != nil || iState.isSwiping)
.scrollBounceBehavior(.always)
- .onScrollPhaseChange { _, newPhase in
- iState.isScrolling = newPhase != .idle
- }
.contentMargins(.bottom, 20)
.background {
Color.outerBackground.ignoresSafeArea()
diff --git a/ListlessiOS/Views/TaskRowView.swift b/ListlessiOS/Views/TaskRowView.swift
@@ -7,7 +7,7 @@ struct TaskRowView: View {
let totalTasks: Int
let isSelected: Bool
@Binding var isDragging: Bool
- let isScrolling: Bool
+ @Binding var isSwiping: Bool
let onToggle: (TaskItem) -> Void
let onTitleChange: (TaskItem, String) -> Void
let onDelete: (TaskItem) -> Void
@@ -33,7 +33,7 @@ struct TaskRowView: View {
totalTasks: Int = 1,
isSelected: Bool,
isDragging: Binding<Bool> = .constant(false),
- isScrolling: Bool = false,
+ isSwiping: Binding<Bool> = .constant(false),
isLastActiveTask: Bool = false,
focusedField: FocusState<FocusField?>.Binding,
onToggle: @escaping (TaskItem) -> Void,
@@ -49,7 +49,7 @@ struct TaskRowView: View {
self.totalTasks = totalTasks
self.isSelected = isSelected
_isDragging = isDragging
- self.isScrolling = isScrolling
+ _isSwiping = isSwiping
self.isLastActiveTask = isLastActiveTask
self.onToggle = onToggle
self.onTitleChange = onTitleChange
@@ -155,7 +155,7 @@ struct TaskRowView: View {
}
.taskSwipeGesture(
isDragging: $isDragging,
- isScrolling: isScrolling,
+ isSwiping: $isSwiping,
swipeOffset: $swipeOffset,
swipeDirection: $swipeDirection,
isTriggered: $isSwipeTriggered,