← All repositories

listless

A simple list app for Apple platforms

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


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

MListless.xcodeproj/project.pbxproj | 8++++----
AListlessiOS/Extensions/TaskListView+PullGestures.swift | 163+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
DListlessiOS/Helpers/PullToCreateState.swift | 56--------------------------------------------------------
MListlessiOS/Views/TaskListView.swift | 64++++++++++------------------------------------------------------

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() }
+        )
     }
 }