listless

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

commit a6910ea1226dc5d075217897ba93a47e6b2576b4
parent db6c38cd1b8a5b2eceb3c91f4d1c9e4324b6f218
Author: Michael Camilleri <[email protected]>
Date:   Sun,  8 Mar 2026 14:28:21 +0900

Support flick-to-create gesture

It is a primary goal of Listless to allow a user to quickly enter tasks.
To this end, flicking down quickly should be recognised as evidencing an
intention to create a task even if the distance the screen is pulled is
less than the threshold for a shorter pull. This commit aims to do that.

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

Diffstat:
MListlessiOS/Extensions/TaskListView+PullGestures.swift | 20+++++++++++++++++---
MListlessiOS/Views/TaskListView.swift | 2++
2 files changed, 19 insertions(+), 3 deletions(-)

diff --git a/ListlessiOS/Extensions/TaskListView+PullGestures.swift b/ListlessiOS/Extensions/TaskListView+PullGestures.swift @@ -14,6 +14,8 @@ extension TaskListView { var pendingTaskID: UUID? var isScrollInteracting: Bool = false + private var pullStartTime: CFTimeInterval = 0 + var shouldShowIndicator: Bool { indicatorOffset > 0 || isInsertionPending } @@ -32,12 +34,20 @@ extension TaskListView { mutating func handlePhaseChange( from oldPhase: ScrollPhase, to newPhase: ScrollPhase, - threshold: CGFloat + pullThreshold: CGFloat, + flickThreshold: CGFloat ) -> Action { + if newPhase == .interacting, oldPhase != .interacting { + pullStartTime = CACurrentMediaTime() + } isScrollInteracting = (newPhase == .interacting) guard oldPhase == .interacting, newPhase != .interacting else { return .none } - if pullOffset >= threshold { + let elapsed = CACurrentMediaTime() - pullStartTime + let isFlick = pullOffset > 0 && elapsed > 0 + && (pullOffset / elapsed) >= flickThreshold + + if pullOffset >= pullThreshold || isFlick { isInsertionPending = true pendingTaskID = nil return .createTask @@ -69,6 +79,7 @@ private struct PullGesturesModifier: ViewModifier { let activeTaskIDs: [UUID] let hasCompletedTasks: Bool let pullCreateThreshold: CGFloat + let flickThreshold: CGFloat let pullClearThreshold: CGFloat let onCreateTaskAtTop: () -> UUID let onClearCompleted: () -> Void @@ -117,7 +128,8 @@ private struct PullGesturesModifier: ViewModifier { let action = pullToCreate.handlePhaseChange( from: oldPhase, to: newPhase, - threshold: pullCreateThreshold + pullThreshold: pullCreateThreshold, + flickThreshold: flickThreshold ) guard oldPhase == .interacting, newPhase != .interacting else { return } @@ -178,6 +190,7 @@ extension View { activeTaskIDs: [UUID], hasCompletedTasks: Bool, pullCreateThreshold: CGFloat, + flickThreshold: CGFloat, pullClearThreshold: CGFloat, onCreateTaskAtTop: @escaping () -> UUID, onClearCompleted: @escaping () -> Void @@ -190,6 +203,7 @@ extension View { activeTaskIDs: activeTaskIDs, hasCompletedTasks: hasCompletedTasks, pullCreateThreshold: pullCreateThreshold, + flickThreshold: flickThreshold, pullClearThreshold: pullClearThreshold, onCreateTaskAtTop: onCreateTaskAtTop, onClearCompleted: onClearCompleted diff --git a/ListlessiOS/Views/TaskListView.swift b/ListlessiOS/Views/TaskListView.swift @@ -174,6 +174,7 @@ struct TaskListView: View, TaskListViewProtocol { var vStackSpacing: CGFloat { 12 } var pullCreateThreshold: CGFloat { 70 } + var flickThreshold: CGFloat { 800 } var isCompletelyEmpty: Bool { activeTasks.isEmpty && completedTasks.isEmpty } init(store: TaskStore, syncMonitor: CloudKitSyncMonitor) { @@ -399,6 +400,7 @@ struct TaskListView: View, TaskListViewProtocol { activeTaskIDs: activeTasks.map(\.id), hasCompletedTasks: !completedTasks.isEmpty, pullCreateThreshold: pullCreateThreshold, + flickThreshold: flickThreshold, pullClearThreshold: pullClearThreshold, onCreateTaskAtTop: { createNewTaskAtTop() }, onClearCompleted: {