listless

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

commit 18193a4d9d55ad01ce3370420051a5efe5ef7c22
parent f5b4c349770f2a18b708dc02f2f73bbf2344fdef
Author: Michael Camilleri <[email protected]>
Date:   Wed, 18 Feb 2026 17:06:24 +0900

Extract pull-to-create state logic to struct

Co-Authored-By: Codex GPT 5.3 <[email protected]>

Diffstat:
MListless.xcodeproj/project.pbxproj | 4++++
MListlessiOS/Extensions/TaskListView+PullToCreate.swift | 4++--
AListlessiOS/Helpers/PullToCreateState.swift | 56++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MListlessiOS/Views/TaskListView.swift | 39+++++++++++++++++----------------------
4 files changed, 79 insertions(+), 24 deletions(-)

diff --git a/Listless.xcodeproj/project.pbxproj b/Listless.xcodeproj/project.pbxproj @@ -40,6 +40,7 @@ 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 */; }; @@ -89,6 +90,7 @@ 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>"; }; @@ -125,6 +127,7 @@ B7588879D0FA1C2A8BCEF14F /* HoverCursorModifier.swift */, FA5D58EC1FBAA96E83A79445 /* PlatformScrollIndicatorsModifier.swift */, E2174C43654733E9D4023157 /* PlatformTextFieldWidthModifier.swift */, + 8D48D898398DB472D4321B91 /* PullToCreateState.swift */, 6ECE0E961F87BA32FA87BF90 /* TappableTextField.swift */, D57C3CC81C4380EFAE4DB910 /* TaskRowDragGesture.swift */, 067D666DF0AF1C53404ECF7C /* TaskRowSwipeGesture.swift */, @@ -450,6 +453,7 @@ 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 */, diff --git a/ListlessiOS/Extensions/TaskListView+PullToCreate.swift b/ListlessiOS/Extensions/TaskListView+PullToCreate.swift @@ -2,9 +2,9 @@ import SwiftUI extension TaskListView { @ViewBuilder var pullToCreateIndicatorRow: some View { - if createIndicatorOffset > 0 || isCreateInsertionPending { + if pullToCreate.shouldShowIndicator { PullToCreateIndicator( - pullOffset: isCreateInsertionPending ? pullCreateThreshold : createIndicatorOffset + pullOffset: pullToCreate.indicatorDisplayOffset(threshold: pullCreateThreshold) ) } } diff --git a/ListlessiOS/Helpers/PullToCreateState.swift b/ListlessiOS/Helpers/PullToCreateState.swift @@ -0,0 +1,56 @@ +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 @@ -24,13 +24,9 @@ struct TaskListView: View { @State var draggedTaskID: UUID? @State var visualOrder: [UUID]? @State var pendingFocus: FocusField? - @State var pullOffset: CGFloat = 0 - @State var createIndicatorOffset: CGFloat = 0 - @State var isCreateInsertionPending: Bool = false - @State var activeTaskCountBeforeCreate: Int = 0 + @State var pullToCreate = PullToCreateState() @State var pullUpOffset: CGFloat = 0 @State var isDragging: Bool = false - @State var isScrollInteracting: Bool = false @State var rowFrames: [UUID: CGRect] = [:] var vStackSpacing: CGFloat { 12 } @@ -148,7 +144,7 @@ struct TaskListView: View { .frame(maxWidth: .infinity, alignment: .topLeading) .padding(.trailing, 16) .padding(.vertical, 12) - .offset(y: -pullOffset) + .offset(y: -pullToCreate.pullOffset) } .scrollDisabled(draggedTaskID != nil) .scrollBounceBehavior(.always) @@ -161,10 +157,7 @@ struct TaskListView: View { .onScrollGeometryChange(for: CGFloat.self) { geo in max(0, -(geo.contentOffset.y + geo.contentInsets.top)) } action: { _, pullDistance in - pullOffset = pullDistance - if isScrollInteracting { - createIndicatorOffset = pullDistance - } + pullToCreate.updatePullDistance(pullDistance) } .onScrollGeometryChange(for: CGFloat.self) { geo in let maxOffset = max( @@ -176,38 +169,40 @@ struct TaskListView: View { pullUpOffset = pullDistance } .onScrollPhaseChange { oldPhase, newPhase in - isScrollInteracting = (newPhase == .interacting) + let action = pullToCreate.handlePhaseChange( + from: oldPhase, + to: newPhase, + activeTaskCount: activeTasks.count, + threshold: pullCreateThreshold + ) if oldPhase == .interacting && newPhase != .interacting { - if pullOffset >= pullCreateThreshold { - activeTaskCountBeforeCreate = activeTasks.count - isCreateInsertionPending = true + switch action { + case .createTask: var transaction = Transaction(animation: .spring(response: 0.28, dampingFraction: 0.9)) transaction.disablesAnimations = false withTransaction(transaction) { createNewTaskAtTop() } - } else { - isCreateInsertionPending = false + case .collapseIndicator: withAnimation(.spring(response: 0.22, dampingFraction: 0.95)) { - createIndicatorOffset = 0 + pullToCreate.indicatorOffset = 0 } + case .none: + break } if pullUpOffset >= pullClearThreshold && !completedTasks.isEmpty { clearCompletedTasks() } pullUpOffset = 0 } } .onChange(of: activeTasks.count) { _, newCount in - guard isCreateInsertionPending else { return } - guard newCount > activeTaskCountBeforeCreate else { return } var transaction = Transaction(animation: nil) transaction.disablesAnimations = true withTransaction(transaction) { - isCreateInsertionPending = false - createIndicatorOffset = 0 + _ = pullToCreate.resolvePendingInsertion(activeTaskCount: newCount) } } - .sensoryFeedback(.impact(weight: .medium), trigger: pullOffset >= pullCreateThreshold) { old, new in + .sensoryFeedback(.impact(weight: .medium), trigger: pullToCreate.pullOffset >= pullCreateThreshold) { old, new in !old && new } .sensoryFeedback(.impact(weight: .medium), trigger: pullUpOffset >= pullClearThreshold) { old, new in