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:
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: {