ListlessiOS/Helpers/ItemRowSwipeGesture.swift (9873B)
1import SwiftUI
2
3extension View {
4 func itemSwipeGesture(
5 isDragging: Binding<Bool>,
6 isEditing: Bool,
7 isSwiping: Binding<Bool>,
8 swipeOffset: Binding<CGFloat>,
9 swipeDirection: Binding<ItemRowSwipeGesture.SwipeDirection>,
10 isTriggered: Binding<Bool>,
11 completeColor: Color = .green,
12 onComplete: @escaping () -> Void,
13 onDelete: @escaping () -> Void
14 ) -> some View {
15 self.modifier(
16 ItemRowSwipeGesture(
17 isDragging: isDragging,
18 isEditing: isEditing,
19 isSwiping: isSwiping,
20 swipeOffset: swipeOffset,
21 swipeDirection: swipeDirection,
22 isTriggered: isTriggered,
23 completeColor: completeColor,
24 onComplete: onComplete,
25 onDelete: onDelete
26 ))
27 }
28}
29
30struct ItemRowSwipeGesture: ViewModifier {
31 @Binding var isDragging: Bool
32 let isEditing: Bool
33 @Binding var isSwiping: Bool
34 @Binding var swipeOffset: CGFloat
35 @Binding var swipeDirection: SwipeDirection
36 @Binding var isTriggered: Bool
37 let completeColor: Color
38 let onComplete: () -> Void
39 let onDelete: () -> Void
40
41 @AppStorage("hapticsEnabled") private var hapticsEnabled = true
42 @State private var hapticTrigger = false
43 @State private var activeGestureAxis: ActiveGestureAxis = .undecided
44
45 enum SwipeDirection: Equatable {
46 case left
47 case right
48 case none
49 }
50
51 private enum ActiveGestureAxis {
52 case undecided
53 case horizontal
54 case vertical
55 }
56
57 private let completeThreshold: CGFloat = 40
58 private let deleteThreshold: CGFloat = 80
59 private let horizontalBufferPt: CGFloat = 10
60 private let offsetDamping: CGFloat = 0.9
61
62 func body(content: Content) -> some View {
63 ZStack(alignment: .leading) {
64
65 swipeBackground
66 .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
67 .allowsHitTesting(false)
68
69
70 content
71 .offset(x: swipeOffset)
72 .contentShape(Rectangle())
73 }
74 .applySwipeGesture(
75 isDisabled: isDragging || isEditing,
76 onChanged: { translation in
77 guard !isDragging, !isEditing else { return }
78 updateActiveGestureAxis(
79 horizontalTranslation: translation.width,
80 verticalTranslation: abs(translation.height)
81 )
82 guard activeGestureAxis == .horizontal else { return }
83 handleDragChanged(horizontalTranslation: translation.width)
84 },
85 onEnded: {
86 handleDragEnded()
87 }
88 )
89 .sensoryFeedback(.impact(weight: .light), trigger: hapticsEnabled ? hapticTrigger : false)
90 .onDisappear {
91 resetSwipeState()
92 }
93 }
94
95 @ViewBuilder
96 private var swipeBackground: some View {
97 if swipeDirection == .right {
98
99 completeColor.opacity(backgroundOpacity(offset: swipeOffset))
100 } else if swipeDirection == .left {
101
102 Color.red.opacity(backgroundOpacity(offset: swipeOffset))
103 .overlay {
104 HStack {
105 Spacer()
106 Image(systemName: "trash.fill")
107 .font(.system(size: 24))
108 .foregroundStyle(isTriggered ? .black : .white)
109 .padding(.trailing, 20)
110 }
111 }
112 }
113 }
114
115 private func handleDragChanged(horizontalTranslation: CGFloat) {
116 if horizontalTranslation > 0 {
117 swipeDirection = .right
118 } else if horizontalTranslation < 0 {
119 swipeDirection = .left
120 }
121
122
123 swipeOffset = horizontalTranslation * offsetDamping
124
125
126 if swipeDirection == .right {
127 isTriggered = swipeOffset >= completeThreshold
128 } else if swipeDirection == .left {
129 isTriggered = abs(swipeOffset) >= deleteThreshold
130 }
131 }
132
133 private func handleDragEnded() {
134 defer {
135 activeGestureAxis = .undecided
136 isSwiping = false
137 }
138
139 guard !isDragging else {
140
141 withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) {
142 resetSwipeState()
143 }
144 return
145 }
146 if isTriggered {
147 if swipeDirection == .right {
148
149
150 triggerAction(action: onComplete)
151 } else {
152
153
154 triggerAction(action: onDelete)
155 withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) {
156 swipeOffset = -400
157 } completion: {
158 resetSwipeState()
159 }
160 }
161 } else {
162
163 withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) {
164 resetSwipeState()
165 }
166 }
167 }
168
169 private func triggerAction(action: () -> Void) {
170 isTriggered = true
171 hapticTrigger.toggle()
172 action()
173 }
174
175 private func resetSwipeState() {
176 swipeOffset = 0
177 swipeDirection = .none
178 isTriggered = false
179 isSwiping = false
180 activeGestureAxis = .undecided
181 }
182
183 private func backgroundOpacity(offset: CGFloat) -> CGFloat {
184 let threshold = offset >= 0 ? completeThreshold : deleteThreshold
185 return min(abs(offset) / threshold, 1.0)
186 }
187
188 private func updateActiveGestureAxis(horizontalTranslation: CGFloat, verticalTranslation: CGFloat) {
189 guard activeGestureAxis == .undecided else { return }
190
191 if abs(horizontalTranslation) > verticalTranslation + horizontalBufferPt {
192 activeGestureAxis = .horizontal
193 isSwiping = true
194 } else if verticalTranslation > abs(horizontalTranslation) + horizontalBufferPt {
195 activeGestureAxis = .vertical
196 }
197 }
198}
199
200
201
202
203
204
205
206
207private extension View {
208 func applySwipeGesture(
209 isDisabled: Bool,
210 onChanged: @escaping (CGSize) -> Void,
211 onEnded: @escaping () -> Void
212 ) -> some View {
213 self.gesture(
214 SimultaneousSwipeGesture(
215 onChanged: { _, translation in
216 guard !isDisabled else { return }
217 onChanged(translation)
218 },
219 onEnded: { _, _ in
220 guard !isDisabled else { return }
221 onEnded()
222 }
223 )
224 )
225 }
226}
227
228private struct SimultaneousSwipeGesture: UIGestureRecognizerRepresentable {
229 let onChanged: (UILongPressGestureRecognizer, CGSize) -> Void
230 let onEnded: (UILongPressGestureRecognizer, CGSize) -> Void
231
232 func makeUIGestureRecognizer(context: Context) -> UILongPressGestureRecognizer {
233 let recognizer = UILongPressGestureRecognizer()
234 recognizer.minimumPressDuration = 0.0
235 recognizer.allowableMovement = CGFloat.greatestFiniteMagnitude
236 recognizer.delegate = context.coordinator
237 return recognizer
238 }
239
240 func handleUIGestureRecognizerAction(
241 _ recognizer: UILongPressGestureRecognizer, context: Context
242 ) {
243 switch recognizer.state {
244 case .began:
245 context.coordinator.startLocation = recognizer.location(in: recognizer.view)
246
247 case .changed:
248 let location = recognizer.location(in: recognizer.view)
249 let translation = CGSize(
250 width: location.x - context.coordinator.startLocation.x,
251 height: location.y - context.coordinator.startLocation.y
252 )
253 onChanged(recognizer, translation)
254
255 case .ended, .cancelled:
256 let location = recognizer.location(in: recognizer.view)
257 let translation = CGSize(
258 width: location.x - context.coordinator.startLocation.x,
259 height: location.y - context.coordinator.startLocation.y
260 )
261 context.coordinator.startLocation = .zero
262 onEnded(recognizer, translation)
263
264 default:
265 break
266 }
267 }
268
269 func makeCoordinator(converter: CoordinateSpaceConverter) -> Coordinator {
270 Coordinator()
271 }
272
273 class Coordinator: NSObject, UIGestureRecognizerDelegate {
274 var startLocation: CGPoint = .zero
275
276 func gestureRecognizer(
277 _ gestureRecognizer: UIGestureRecognizer,
278 shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer
279 ) -> Bool {
280 true
281 }
282 }
283}