ItemRowSwipeGesture.swift (9873B)
1 import SwiftUI 2 3 extension 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 30 struct 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. 207 private 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 228 private 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 }