commit 39ccaaaaf50ed04e49886e7f48f080fc58cb7efc
parent 18193a4d9d55ad01ce3370420051a5efe5ef7c22
Author: Michael Camilleri <[email protected]>
Date: Wed, 18 Feb 2026 17:19:14 +0900
Refactor pull-to-create gesture
Co-Authored-By: Codex GPT 5.3 <[email protected]>
Diffstat:
4 files changed, 177 insertions(+), 114 deletions(-)
diff --git a/Listless.xcodeproj/project.pbxproj b/Listless.xcodeproj/project.pbxproj
@@ -22,6 +22,7 @@
5761B201BF46FCA9C5C98CEF /* PlatformScrollIndicatorsModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 466F9B0E407DF1F5B4789531 /* PlatformScrollIndicatorsModifier.swift */; };
5B60B409CE4BA668DB30A65D /* Listless.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = C093494053E6C348F245D4EC /* Listless.xcdatamodeld */; };
5D3EE9526DA269EE9EE3AB52 /* AppColors.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1E998119283F784B9ADEE28 /* AppColors.swift */; };
+ 5DB1231FEF24A9E4BC1E56B0 /* TaskListView+PullGestures.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2D9CDDA8913CD116FB4DA74 /* TaskListView+PullGestures.swift */; };
614FCCA450EC0BFFD8B40640 /* ListlessMacApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DA467DF2E59BDBE6EEF6A7D /* ListlessMacApp.swift */; };
763363F6F3C7D2D3C9A63977 /* PlatformScrollIndicatorsModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA5D58EC1FBAA96E83A79445 /* PlatformScrollIndicatorsModifier.swift */; };
77970A2323A598E830D95301 /* TaskListView+Toolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB43816B8E7F083A2AD07F28 /* TaskListView+Toolbar.swift */; };
@@ -40,7 +41,6 @@
BA6953E0EFE6F8255F05A3FD /* AccentColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17DD7EDA74DAAFA27C84CA08 /* AccentColor.swift */; };
C169823665158AA347A63990 /* TaskListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E51B8129962E5CC78ECDDC2B /* TaskListView.swift */; };
C1FE091454864C4BBBBEB077 /* TaskItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBB8A3BEB346267B30B4675F /* TaskItem.swift */; };
- CAC36F0ED35740AB74A2BB15 /* PullToCreateState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D48D898398DB472D4321B91 /* PullToCreateState.swift */; };
CAD142ED738A83371DFF8F5B /* TaskListView+Logic.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72B4668483D05A6ECA142B89 /* TaskListView+Logic.swift */; };
DB5FF6C1AA57D4C9BDDD50FD /* ClickableTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = D640E7D21735C62A30A26DA4 /* ClickableTextField.swift */; };
DC73A39A269AB495BCE1AC48 /* PersistenceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C14858BDFD1FD5119F1F24A6 /* PersistenceController.swift */; };
@@ -90,7 +90,6 @@
74255E6B6C40899E9B17D927 /* .gitkeep */ = {isa = PBXFileReference; path = .gitkeep; sourceTree = "<group>"; };
75B048B19C5219862BBED2E7 /* TestHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestHelpers.swift; sourceTree = "<group>"; };
7C73E9D4C42CCABBF0F33543 /* Listless.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Listless.entitlements; sourceTree = "<group>"; };
- 8D48D898398DB472D4321B91 /* PullToCreateState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PullToCreateState.swift; sourceTree = "<group>"; };
9262207DAC21619BD9EDEE15 /* Listless.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Listless.entitlements; sourceTree = "<group>"; };
944BAE054AAC1B9C4FC954F9 /* .gitkeep */ = {isa = PBXFileReference; path = .gitkeep; sourceTree = "<group>"; };
967F7ECEB3915CEDCE584872 /* TaskStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskStoreTests.swift; sourceTree = "<group>"; };
@@ -106,6 +105,7 @@
C9B14DC786A336008AAB78EE /* .gitkeep */ = {isa = PBXFileReference; path = .gitkeep; sourceTree = "<group>"; };
CB43816B8E7F083A2AD07F28 /* TaskListView+Toolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TaskListView+Toolbar.swift"; sourceTree = "<group>"; };
D10B5491A53E77C80F8F75CD /* .gitkeep */ = {isa = PBXFileReference; path = .gitkeep; sourceTree = "<group>"; };
+ D2D9CDDA8913CD116FB4DA74 /* TaskListView+PullGestures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TaskListView+PullGestures.swift"; sourceTree = "<group>"; };
D57C3CC81C4380EFAE4DB910 /* TaskRowDragGesture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskRowDragGesture.swift; sourceTree = "<group>"; };
D640E7D21735C62A30A26DA4 /* ClickableTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClickableTextField.swift; sourceTree = "<group>"; };
DC3DEE364304587D280C5672 /* TaskStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskStore.swift; sourceTree = "<group>"; };
@@ -127,7 +127,6 @@
B7588879D0FA1C2A8BCEF14F /* HoverCursorModifier.swift */,
FA5D58EC1FBAA96E83A79445 /* PlatformScrollIndicatorsModifier.swift */,
E2174C43654733E9D4023157 /* PlatformTextFieldWidthModifier.swift */,
- 8D48D898398DB472D4321B91 /* PullToCreateState.swift */,
6ECE0E961F87BA32FA87BF90 /* TappableTextField.swift */,
D57C3CC81C4380EFAE4DB910 /* TaskRowDragGesture.swift */,
067D666DF0AF1C53404ECF7C /* TaskRowSwipeGesture.swift */,
@@ -194,6 +193,7 @@
children = (
3FA75CA18C6270D68A755AEB /* TaskListView+Drag.swift */,
712ADAD0FE66175DAA9A6D50 /* TaskListView+NavigationHeader.swift */,
+ D2D9CDDA8913CD116FB4DA74 /* TaskListView+PullGestures.swift */,
5A6D74EDDD7E3FC150064FB5 /* TaskListView+PullToClear.swift */,
9AD8D4C29E09FEE78AE5AB79 /* TaskListView+PullToCreate.swift */,
CB43816B8E7F083A2AD07F28 /* TaskListView+Toolbar.swift */,
@@ -453,12 +453,12 @@
77FE96F070B1F7FE31A9CE51 /* PlatformTextFieldWidthModifier.swift in Sources */,
785721EB774EAC6BBA26C038 /* PullToClear.swift in Sources */,
E47136CA7428927395D8C7C7 /* PullToCreate.swift in Sources */,
- CAC36F0ED35740AB74A2BB15 /* PullToCreateState.swift in Sources */,
0F12D56A528FCBF8A67864CB /* TappableTextField.swift in Sources */,
0ACA67F6578EFF181EE5C9A7 /* TaskItem.swift in Sources */,
82E75475E13FD847DDDC69D8 /* TaskListView+Drag.swift in Sources */,
CAD142ED738A83371DFF8F5B /* TaskListView+Logic.swift in Sources */,
26F609518DE1055DF09B0159 /* TaskListView+NavigationHeader.swift in Sources */,
+ 5DB1231FEF24A9E4BC1E56B0 /* TaskListView+PullGestures.swift in Sources */,
8A8D164CAA7B95B4C7435A7C /* TaskListView+PullToClear.swift in Sources */,
80BCA654FE694D8A77D9BF93 /* TaskListView+PullToCreate.swift in Sources */,
77970A2323A598E830D95301 /* TaskListView+Toolbar.swift in Sources */,
diff --git a/ListlessiOS/Extensions/TaskListView+PullGestures.swift b/ListlessiOS/Extensions/TaskListView+PullGestures.swift
@@ -0,0 +1,163 @@
+import SwiftUI
+
+extension TaskListView {
+ struct PullToCreateState {
+ enum Action {
+ case none
+ case createTask
+ case collapseIndicator
+ }
+
+ var pullOffset: CGFloat = 0
+ var indicatorOffset: CGFloat = 0
+ var isInsertionPending: Bool = false
+ var activeTaskCountBeforeCreate: Int = 0
+ var isScrollInteracting: Bool = false
+
+ var shouldShowIndicator: Bool {
+ indicatorOffset > 0 || isInsertionPending
+ }
+
+ func indicatorDisplayOffset(threshold: CGFloat) -> CGFloat {
+ isInsertionPending ? threshold : indicatorOffset
+ }
+
+ mutating func updatePullDistance(_ distance: CGFloat) {
+ pullOffset = distance
+ if isScrollInteracting {
+ indicatorOffset = distance
+ }
+ }
+
+ mutating func handlePhaseChange(
+ from oldPhase: ScrollPhase,
+ to newPhase: ScrollPhase,
+ activeTaskCount: Int,
+ threshold: CGFloat
+ ) -> Action {
+ isScrollInteracting = (newPhase == .interacting)
+ guard oldPhase == .interacting, newPhase != .interacting else { return .none }
+
+ if pullOffset >= threshold {
+ activeTaskCountBeforeCreate = activeTaskCount
+ isInsertionPending = true
+ return .createTask
+ }
+
+ isInsertionPending = false
+ return .collapseIndicator
+ }
+
+ mutating func resolvePendingInsertion(activeTaskCount: Int) {
+ guard isInsertionPending, activeTaskCount > activeTaskCountBeforeCreate else { return }
+ isInsertionPending = false
+ indicatorOffset = 0
+ }
+ }
+}
+
+private struct PullCreationGestureModifier: ViewModifier {
+ @Binding var pullToCreate: TaskListView.PullToCreateState
+ @Binding var pullUpOffset: CGFloat
+
+ let activeTaskCount: Int
+ let hasCompletedTasks: Bool
+ let pullCreateThreshold: CGFloat
+ let pullClearThreshold: CGFloat
+ let onCreateTaskAtTop: () -> Void
+ let onClearCompleted: () -> Void
+
+ func body(content: Content) -> some View {
+ content
+ .onScrollGeometryChange(for: CGFloat.self) { geo in
+ max(0, -(geo.contentOffset.y + geo.contentInsets.top))
+ } action: { _, pullDistance in
+ pullToCreate.updatePullDistance(pullDistance)
+ }
+ .onScrollGeometryChange(for: CGFloat.self) { geo in
+ let maxOffset = max(
+ -geo.contentInsets.top,
+ geo.contentSize.height - geo.bounds.size.height + geo.contentInsets.bottom
+ )
+ return max(0, geo.contentOffset.y - maxOffset)
+ } action: { _, pullDistance in
+ pullUpOffset = pullDistance
+ }
+ .onScrollPhaseChange { oldPhase, newPhase in
+ handlePullToCreateScrollPhaseChange(from: oldPhase, to: newPhase)
+ }
+ .onChange(of: activeTaskCount) { _, newCount in
+ var transaction = Transaction(animation: nil)
+ transaction.disablesAnimations = true
+ withTransaction(transaction) {
+ pullToCreate.resolvePendingInsertion(activeTaskCount: newCount)
+ }
+ }
+ .sensoryFeedback(
+ .impact(weight: .medium),
+ trigger: pullToCreate.pullOffset >= pullCreateThreshold
+ ) { old, new in
+ !old && new
+ }
+ .sensoryFeedback(.impact(weight: .medium), trigger: pullUpOffset >= pullClearThreshold) { old, new in
+ !old && new
+ }
+ }
+
+ private func handlePullToCreateScrollPhaseChange(from oldPhase: ScrollPhase, to newPhase: ScrollPhase) {
+ let action = pullToCreate.handlePhaseChange(
+ from: oldPhase,
+ to: newPhase,
+ activeTaskCount: activeTaskCount,
+ threshold: pullCreateThreshold
+ )
+
+ guard oldPhase == .interacting, newPhase != .interacting else { return }
+
+ switch action {
+ case .createTask:
+ var transaction = Transaction(animation: .spring(response: 0.28, dampingFraction: 0.9))
+ transaction.disablesAnimations = false
+ withTransaction(transaction) {
+ onCreateTaskAtTop()
+ }
+ case .collapseIndicator:
+ withAnimation(.spring(response: 0.22, dampingFraction: 0.95)) {
+ pullToCreate.indicatorOffset = 0
+ }
+ case .none:
+ break
+ }
+
+ if pullUpOffset >= pullClearThreshold && hasCompletedTasks {
+ onClearCompleted()
+ }
+ pullUpOffset = 0
+ }
+}
+
+extension View {
+ func pullCreationGesture(
+ pullToCreate: Binding<TaskListView.PullToCreateState>,
+ pullUpOffset: Binding<CGFloat>,
+ activeTaskCount: Int,
+ hasCompletedTasks: Bool,
+ pullCreateThreshold: CGFloat,
+ pullClearThreshold: CGFloat,
+ onCreateTaskAtTop: @escaping () -> Void,
+ onClearCompleted: @escaping () -> Void
+ ) -> some View {
+ modifier(
+ PullCreationGestureModifier(
+ pullToCreate: pullToCreate,
+ pullUpOffset: pullUpOffset,
+ activeTaskCount: activeTaskCount,
+ hasCompletedTasks: hasCompletedTasks,
+ pullCreateThreshold: pullCreateThreshold,
+ pullClearThreshold: pullClearThreshold,
+ onCreateTaskAtTop: onCreateTaskAtTop,
+ onClearCompleted: onClearCompleted
+ )
+ )
+ }
+}
diff --git a/ListlessiOS/Helpers/PullToCreateState.swift b/ListlessiOS/Helpers/PullToCreateState.swift
@@ -1,56 +0,0 @@
-import SwiftUI
-
-struct PullToCreateState {
- enum Action {
- case none
- case createTask
- case collapseIndicator
- }
-
- var pullOffset: CGFloat = 0
- var indicatorOffset: CGFloat = 0
- var isInsertionPending: Bool = false
- var activeTaskCountBeforeCreate: Int = 0
- var isScrollInteracting: Bool = false
-
- var shouldShowIndicator: Bool {
- indicatorOffset > 0 || isInsertionPending
- }
-
- func indicatorDisplayOffset(threshold: CGFloat) -> CGFloat {
- isInsertionPending ? threshold : indicatorOffset
- }
-
- mutating func updatePullDistance(_ distance: CGFloat) {
- pullOffset = distance
- if isScrollInteracting {
- indicatorOffset = distance
- }
- }
-
- mutating func handlePhaseChange(
- from oldPhase: ScrollPhase,
- to newPhase: ScrollPhase,
- activeTaskCount: Int,
- threshold: CGFloat
- ) -> Action {
- isScrollInteracting = (newPhase == .interacting)
- guard oldPhase == .interacting, newPhase != .interacting else { return .none }
-
- if pullOffset >= threshold {
- activeTaskCountBeforeCreate = activeTaskCount
- isInsertionPending = true
- return .createTask
- }
-
- isInsertionPending = false
- return .collapseIndicator
- }
-
- mutating func resolvePendingInsertion(activeTaskCount: Int) -> Bool {
- guard isInsertionPending, activeTaskCount > activeTaskCountBeforeCreate else { return false }
- isInsertionPending = false
- indicatorOffset = 0
- return true
- }
-}
diff --git a/ListlessiOS/Views/TaskListView.swift b/ListlessiOS/Views/TaskListView.swift
@@ -154,59 +154,15 @@ struct TaskListView: View {
.overlay(alignment: .bottom) {
pullToClearIndicatorRow
}
- .onScrollGeometryChange(for: CGFloat.self) { geo in
- max(0, -(geo.contentOffset.y + geo.contentInsets.top))
- } action: { _, pullDistance in
- pullToCreate.updatePullDistance(pullDistance)
- }
- .onScrollGeometryChange(for: CGFloat.self) { geo in
- let maxOffset = max(
- -geo.contentInsets.top,
- geo.contentSize.height - geo.bounds.size.height + geo.contentInsets.bottom
- )
- return max(0, geo.contentOffset.y - maxOffset)
- } action: { _, pullDistance in
- pullUpOffset = pullDistance
- }
- .onScrollPhaseChange { oldPhase, newPhase in
- let action = pullToCreate.handlePhaseChange(
- from: oldPhase,
- to: newPhase,
- activeTaskCount: activeTasks.count,
- threshold: pullCreateThreshold
- )
-
- if oldPhase == .interacting && newPhase != .interacting {
- switch action {
- case .createTask:
- var transaction = Transaction(animation: .spring(response: 0.28, dampingFraction: 0.9))
- transaction.disablesAnimations = false
- withTransaction(transaction) {
- createNewTaskAtTop()
- }
- case .collapseIndicator:
- withAnimation(.spring(response: 0.22, dampingFraction: 0.95)) {
- pullToCreate.indicatorOffset = 0
- }
- case .none:
- break
- }
- if pullUpOffset >= pullClearThreshold && !completedTasks.isEmpty { clearCompletedTasks() }
- pullUpOffset = 0
- }
- }
- .onChange(of: activeTasks.count) { _, newCount in
- var transaction = Transaction(animation: nil)
- transaction.disablesAnimations = true
- withTransaction(transaction) {
- _ = pullToCreate.resolvePendingInsertion(activeTaskCount: newCount)
- }
- }
- .sensoryFeedback(.impact(weight: .medium), trigger: pullToCreate.pullOffset >= pullCreateThreshold) { old, new in
- !old && new
- }
- .sensoryFeedback(.impact(weight: .medium), trigger: pullUpOffset >= pullClearThreshold) { old, new in
- !old && new
- }
+ .pullCreationGesture(
+ pullToCreate: $pullToCreate,
+ pullUpOffset: $pullUpOffset,
+ activeTaskCount: activeTasks.count,
+ hasCompletedTasks: !completedTasks.isEmpty,
+ pullCreateThreshold: pullCreateThreshold,
+ pullClearThreshold: pullClearThreshold,
+ onCreateTaskAtTop: { createNewTaskAtTop() },
+ onClearCompleted: { clearCompletedTasks() }
+ )
}
}