← All repositories

listless

A simple list app for Apple platforms

git clone https://code.inqk.net/listless.git


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            // Background stays in place
65            swipeBackground
66                .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
67                .allowsHitTesting(false)
68
69            // Only the content moves
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            // Complete action — accent color background
99            completeColor.opacity(backgroundOpacity(offset: swipeOffset))
100        } else if swipeDirection == .left {
101            // Delete action (red background, trash icon)
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        // Update offset with damping
123        swipeOffset = horizontalTranslation * offsetDamping
124
125        // Track whether threshold is currently crossed — reversible until release
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            // A drag-reorder started during or after this swipe — spring back, no action.
141            withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) {
142                resetSwipeState()
143            }
144            return
145        }
146        if isTriggered {
147            if swipeDirection == .right {
148                // Complete: keep the row at its current offset and let the
149                // ForEach re-evaluation animate it out of the active section.
150                triggerAction(action: onComplete)
151            } else {
152                // Delete: slide off screen, then reset so undo doesn't
153                // restore the row with a frozen swipe state.
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            // Released below threshold — spring back with no action
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// MARK: - UIGestureRecognizerRepresentable swipe gesture
201
202/// On iOS 26, `.simultaneousGesture(DragGesture())` on a child view blocks the
203/// ancestor ScrollView's scrolling. This uses a `UILongPressGestureRecognizer`
204/// (with zero press duration and infinite allowable movement) as a pan substitute,
205/// applied via `UIGestureRecognizerRepresentable`. The gesture delegate returns
206/// `shouldRecognizeSimultaneouslyWith: true` so scrolling is preserved.
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}