ListlessiOS/Extensions/ItemListView+PullGestures.swift (6656B)
1import SwiftUI
2
3extension ItemListView {
4 struct PullToCreateState {
5 enum Action {
6 case none
7 case createItem
8 case collapseIndicator
9 }
10
11 var pullOffset: CGFloat = 0
12 var indicatorOffset: CGFloat = 0
13 var isInsertionPending: Bool = false
14 var isScrollInteracting: Bool = false
15
16 private var pullStartTime: CFTimeInterval = 0
17
18 var shouldShowIndicator: Bool {
19 indicatorOffset > 0 || isInsertionPending
20 }
21
22 func indicatorDisplayOffset(threshold: CGFloat) -> CGFloat {
23 isInsertionPending ? threshold : indicatorOffset
24 }
25
26 mutating func updatePullDistance(_ distance: CGFloat) {
27
28 guard pullOffset != distance else { return }
29 pullOffset = distance
30 if isScrollInteracting {
31 indicatorOffset = distance
32 }
33 }
34
35 mutating func handlePhaseChange(
36 from oldPhase: ScrollPhase,
37 to newPhase: ScrollPhase,
38 pullThreshold: CGFloat,
39 flickThreshold: CGFloat
40 ) -> Action {
41 if newPhase == .interacting, oldPhase != .interacting {
42 pullStartTime = CACurrentMediaTime()
43
44
45 indicatorOffset = pullOffset
46 }
47 isScrollInteracting = (newPhase == .interacting)
48 guard oldPhase == .interacting, newPhase != .interacting else { return .none }
49
50 let elapsed = CACurrentMediaTime() - pullStartTime
51 let isFlick = pullOffset > 0 && elapsed > 0
52 && (pullOffset / elapsed) >= flickThreshold
53
54 if pullOffset >= pullThreshold || isFlick {
55 isInsertionPending = true
56 return .createItem
57 }
58
59 isInsertionPending = false
60 return .collapseIndicator
61 }
62 }
63}
64
65private struct PullGesturesModifier: ViewModifier {
66 @Binding var pullToCreate: ItemListView.PullToCreateState
67 @Binding var pullUpOffset: CGFloat
68
69 @AppStorage("hapticsEnabled") private var hapticsEnabled = true
70 @State private var isScrollInteracting = false
71
72 let isEditing: Bool
73 let hasCompletedItems: Bool
74 let pullCreateThreshold: CGFloat
75 let flickThreshold: CGFloat
76 let pullClearThreshold: CGFloat
77 let onCreateItemAtTop: () -> UUID
78 let onClearCompleted: () -> Void
79
80 func body(content: Content) -> some View {
81 content
82 .onScrollGeometryChange(for: CGFloat.self) { geo in
83 max(0, -(geo.contentOffset.y + geo.contentInsets.top))
84 } action: { _, pullDistance in
85 if isEditing {
86 pullToCreate.updatePullDistance(0)
87 } else {
88 pullToCreate.updatePullDistance(pullDistance)
89 }
90 }
91 .onScrollGeometryChange(for: CGFloat.self) { geo in
92 let adjustedBottomInset = geo.contentInsets.bottom - 20
93 let maxOffset = max(
94 -geo.contentInsets.top,
95 geo.contentSize.height - geo.bounds.size.height + adjustedBottomInset
96 )
97 return max(0, geo.contentOffset.y - maxOffset)
98 } action: { _, bottomOverscroll in
99 guard hasCompletedItems, isScrollInteracting else { return }
100 pullUpOffset = bottomOverscroll
101 }
102 .onScrollPhaseChange { oldPhase, newPhase in
103 if newPhase == .interacting {
104 isScrollInteracting = true
105 }
106
107 handlePullToCreateScrollPhaseChange(from: oldPhase, to: newPhase)
108
109 if oldPhase == .interacting, newPhase != .interacting {
110 handlePullToClearRelease()
111 isScrollInteracting = false
112 }
113 }
114 .sensoryFeedback(
115 .impact(weight: .light),
116 trigger: hapticsEnabled && !isEditing && pullToCreate.pullOffset >= pullCreateThreshold
117 ) { old, new in
118 !old && new
119 }
120 .sensoryFeedback(.impact(weight: .light), trigger: hapticsEnabled && pullUpOffset >= pullClearThreshold) { old, new in
121 !old && new
122 }
123 }
124
125 private func handlePullToCreateScrollPhaseChange(from oldPhase: ScrollPhase, to newPhase: ScrollPhase) {
126 guard !isEditing else { return }
127 let action = pullToCreate.handlePhaseChange(
128 from: oldPhase,
129 to: newPhase,
130 pullThreshold: pullCreateThreshold,
131 flickThreshold: flickThreshold
132 )
133
134 guard oldPhase == .interacting, newPhase != .interacting else { return }
135
136 switch action {
137 case .createItem:
138 var transaction = Transaction(animation: nil)
139 transaction.disablesAnimations = true
140 withTransaction(transaction) {
141 _ = onCreateItemAtTop()
142 }
143 case .collapseIndicator:
144 withAnimation(.spring(response: 0.22, dampingFraction: 1.0)) {
145 pullToCreate.indicatorOffset = 0
146 }
147 case .none:
148 break
149 }
150 }
151
152 private func handlePullToClearRelease() {
153 guard hasCompletedItems, pullUpOffset >= pullClearThreshold else {
154 pullUpOffset = 0
155 return
156 }
157 pullUpOffset = 0
158 onClearCompleted()
159 }
160}
161
162extension View {
163 func pullGestures(
164 pullToCreate: Binding<ItemListView.PullToCreateState>,
165 pullUpOffset: Binding<CGFloat>,
166 isEditing: Bool,
167 hasCompletedItems: Bool,
168 pullCreateThreshold: CGFloat,
169 flickThreshold: CGFloat,
170 pullClearThreshold: CGFloat,
171 onCreateItemAtTop: @escaping () -> UUID,
172 onClearCompleted: @escaping () -> Void
173 ) -> some View {
174 modifier(
175 PullGesturesModifier(
176 pullToCreate: pullToCreate,
177 pullUpOffset: pullUpOffset,
178 isEditing: isEditing,
179 hasCompletedItems: hasCompletedItems,
180 pullCreateThreshold: pullCreateThreshold,
181 flickThreshold: flickThreshold,
182 pullClearThreshold: pullClearThreshold,
183 onCreateItemAtTop: onCreateItemAtTop,
184 onClearCompleted: onClearCompleted
185 )
186 )
187 }
188}