listless

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

commit 3a7c2c8ce7c6919f291ef99732ef9c7a79044a83
parent 0b9be197dd29af886fcfc2131d89f3044f3270e5
Author: Michael Camilleri <[email protected]>
Date:   Fri, 27 Feb 2026 22:55:40 +0900

Improve pull-to-clear gesture to use drag distance for threshhold

Co-Authored-By: Codex GPT 5.3 <[email protected]>

Diffstat:
MListlessiOS/Extensions/TaskListView+PullGestures.swift | 45+++++++++++++++++++++++++++++++++++----------
MListlessiOS/Views/PullToClear.swift | 2+-
2 files changed, 36 insertions(+), 11 deletions(-)

diff --git a/ListlessiOS/Extensions/TaskListView+PullGestures.swift b/ListlessiOS/Extensions/TaskListView+PullGestures.swift @@ -62,6 +62,9 @@ private struct PullCreationGestureModifier: ViewModifier { @Binding var pullToCreate: TaskListView.PullToCreateState @Binding var pullUpOffset: CGFloat + @State private var isAtBottom = false + @State private var clearPullStartedAtBottom = false + let activeTaskIDs: [UUID] let hasCompletedTasks: Bool let pullCreateThreshold: CGFloat @@ -76,17 +79,16 @@ private struct PullCreationGestureModifier: ViewModifier { } action: { _, pullDistance in pullToCreate.updatePullDistance(pullDistance) } - .onScrollGeometryChange(for: CGFloat.self) { geo in - // Subtract the 20pt bottom content margin (set on ScrollView in TaskListView) - // so it doesn't create a dead zone before overscroll registers. + .onScrollGeometryChange(for: Bool.self) { geo in + // Match the same bottom inset adjustment used by the ScrollView. let adjustedBottomInset = geo.contentInsets.bottom - 20 let maxOffset = max( -geo.contentInsets.top, geo.contentSize.height - geo.bounds.size.height + adjustedBottomInset ) - return max(0, geo.contentOffset.y - maxOffset) - } action: { _, pullDistance in - pullUpOffset = pullDistance + return geo.contentOffset.y >= (maxOffset - 1) + } action: { _, atBottom in + isAtBottom = atBottom } .onScrollPhaseChange { oldPhase, newPhase in handlePullToCreateScrollPhaseChange(from: oldPhase, to: newPhase) @@ -107,6 +109,7 @@ private struct PullCreationGestureModifier: ViewModifier { .sensoryFeedback(.impact(weight: .medium), trigger: pullUpOffset >= pullClearThreshold) { old, new in !old && new } + .simultaneousGesture(clearCompletedPullGesture) } private func handlePullToCreateScrollPhaseChange(from oldPhase: ScrollPhase, to newPhase: ScrollPhase) { @@ -137,10 +140,32 @@ private struct PullCreationGestureModifier: ViewModifier { break } - if pullUpOffset >= pullClearThreshold && hasCompletedTasks { - onClearCompleted() - } - pullUpOffset = 0 + } + + private var clearCompletedPullGesture: some Gesture { + DragGesture(minimumDistance: 0, coordinateSpace: .local) + .onChanged { value in + guard hasCompletedTasks 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() + } + } } } 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 = 70 +let pullClearThreshold: CGFloat = 90 struct PullToClearIndicator: View { let pullOffset: CGFloat