← All repositories

listless

A simple list app for Apple platforms

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


commit f7d7a94f5cf39024ae4f5f77e04d5b2d8a5440f3
parent c345c5b50d3817879cf0d6bd0248ad2bd3cfc607
Author: Michael Camilleri <[email protected]>
Date:   Sat, 21 Mar 2026 03:22:16 +0900

Align pull-to-clear and pull-to-create gestures

Prior to this commit, the pull-to-clear gesture had been implemented
differently to the visually similar pull-to-create gesture. This commit
aligns them. This has the benefit of preventing a pull-to-clear action
registering while swiping on a row.

Co-Authored-By: Claude 4.6 Opus <[email protected]>

Diffstat

MListlessiOS/Extensions/TaskListView+PullGestures.swift | 58++++++++++++++++++++++------------------------------------
MListlessiOS/Views/PullToClear.swift | 2+-
MListlessiOS/Views/TaskListView.swift | 1-

3 files changed, 23 insertions(+), 38 deletions(-)

diff --git a/ListlessiOS/Extensions/TaskListView+PullGestures.swift b/ListlessiOS/Extensions/TaskListView+PullGestures.swift

@@ -65,10 +65,8 @@ extension TaskListView {
 private struct PullGesturesModifier: ViewModifier {
     @Binding var pullToCreate: TaskListView.PullToCreateState
     @Binding var pullUpOffset: CGFloat
-    @Binding var isDragging: Bool
 
-    @State private var isAtBottom = false
-    @State private var clearPullStartedAtBottom = false
+    @State private var isScrollInteracting = false
 
     let isDraftOpen: Bool
     let hasCompletedTasks: Bool
@@ -85,19 +83,28 @@ private struct PullGesturesModifier: ViewModifier {
             } action: { _, pullDistance in
                 pullToCreate.updatePullDistance(pullDistance)
             }
-            .onScrollGeometryChange(for: Bool.self) { geo in
-                // Match the same bottom inset adjustment used by the ScrollView.
+            .onScrollGeometryChange(for: CGFloat.self) { geo in
                 let adjustedBottomInset = geo.contentInsets.bottom - 20
                 let maxOffset = max(
                     -geo.contentInsets.top,
                     geo.contentSize.height - geo.bounds.size.height + adjustedBottomInset
                 )
-                return geo.contentOffset.y >= (maxOffset - 1)
-            } action: { _, atBottom in
-                isAtBottom = atBottom
+                return max(0, geo.contentOffset.y - maxOffset)
+            } action: { _, bottomOverscroll in
+                guard hasCompletedTasks, isScrollInteracting else { return }
+                pullUpOffset = bottomOverscroll
             }
             .onScrollPhaseChange { oldPhase, newPhase in
+                if newPhase == .interacting {
+                    isScrollInteracting = true
+                }
+
                 handlePullToCreateScrollPhaseChange(from: oldPhase, to: newPhase)
+
+                if oldPhase == .interacting, newPhase != .interacting {
+                    handlePullToClearRelease()
+                    isScrollInteracting = false
+                }
             }
             .sensoryFeedback(
                 .impact(weight: .medium),
@@ -108,7 +115,6 @@ private struct PullGesturesModifier: ViewModifier {
             .sensoryFeedback(.impact(weight: .medium), trigger: pullUpOffset >= pullClearThreshold) { old, new in
                 !old && new
             }
-            .simultaneousGesture(clearCompletedPullGesture)
     }
 
     private func handlePullToCreateScrollPhaseChange(from oldPhase: ScrollPhase, to newPhase: ScrollPhase) {
@@ -136,33 +142,15 @@ private struct PullGesturesModifier: ViewModifier {
         case .none:
             break
         }
-
     }
 
-    private var clearCompletedPullGesture: some Gesture {
-        DragGesture(minimumDistance: 0, coordinateSpace: .local)
-            .onChanged { value in
-                guard hasCompletedTasks, !isDragging else { return }
-
-                if !clearPullStartedAtBottom {
-                    clearPullStartedAtBottom = isAtBottom
-                }
-                guard clearPullStartedAtBottom else { return }
-
-                // Use finger travel, not ScrollView rubber-band displacement.
-                pullUpOffset = max(0, -value.translation.height)
-            }
-            .onEnded { _ in
-                defer {
-                    clearPullStartedAtBottom = false
-                    pullUpOffset = 0
-                }
-
-                guard hasCompletedTasks, clearPullStartedAtBottom else { return }
-                if pullUpOffset >= pullClearThreshold {
-                    onClearCompleted()
-                }
-            }
+    private func handlePullToClearRelease() {
+        guard hasCompletedTasks, pullUpOffset >= pullClearThreshold else {
+            pullUpOffset = 0
+            return
+        }
+        pullUpOffset = 0
+        onClearCompleted()
     }
 }
 
@@ -170,7 +158,6 @@ extension View {
     func pullGestures(
         pullToCreate: Binding<TaskListView.PullToCreateState>,
         pullUpOffset: Binding<CGFloat>,
-        isDragging: Binding<Bool>,
         isDraftOpen: Bool,
         hasCompletedTasks: Bool,
         pullCreateThreshold: CGFloat,
@@ -183,7 +170,6 @@ extension View {
             PullGesturesModifier(
                 pullToCreate: pullToCreate,
                 pullUpOffset: pullUpOffset,
-                isDragging: isDragging,
                 isDraftOpen: isDraftOpen,
                 hasCompletedTasks: hasCompletedTasks,
                 pullCreateThreshold: pullCreateThreshold,

diff --git a/ListlessiOS/Views/PullToClear.swift b/ListlessiOS/Views/PullToClear.swift

@@ -1,7 +1,7 @@
 import SwiftUI
 
 /// Pull distance at which the indicator signals readiness and completed task clearing triggers.
-let pullClearThreshold: CGFloat = 90
+let pullClearThreshold: CGFloat = 50
 
 struct PullToClearIndicator: View {
     let pullOffset: CGFloat

diff --git a/ListlessiOS/Views/TaskListView.swift b/ListlessiOS/Views/TaskListView.swift

@@ -556,7 +556,6 @@ struct TaskListView: View, TaskListViewProtocol {
             .pullGestures(
                 pullToCreate: pullToCreateStateBinding,
                 pullUpOffset: pullUpOffsetStateBinding,
-                isDragging: isDraggingStateBinding,
                 isDraftOpen: draftPlacement != nil,
                 hasCompletedTasks: !completedTasks.isEmpty,
             pullCreateThreshold: pullCreateThreshold,