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