listless

A simple list app for Apple platforms
Log | Files | Refs | README | LICENSE

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 }