listless

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

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:
MListlessiOS/Helpers/TaskRowSwipeGesture.swift | 67+++++++++++++++++++++++--------------------------------------------
MListlessiOS/Views/TaskListView.swift | 11++++-------
MListlessiOS/Views/TaskRowView.swift | 8++++----
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,