listless

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

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