struct ContentView: View { @Environment(\.layoutDirection) private var layoutDirection private static let height = 64.0 private static let feedbackGenerator = UIImpactFeedbackGenerator(style: .medium) // > iOS 18 @State private var newSecondaryActionDragAmount: CGFloat = 0 // < iOS 18 @GestureState private var oldSecondaryActionDragAmount = CGSize.zero private var translation: CGFloat { if #available(iOS 18, *) { newSecondaryActionDragAmount } else { oldSecondaryActionDragAmount.width } } @available(iOS 18, *) class HorizontalSwipeGestureRecognizer: UIPanGestureRecognizer { var direction: LayoutDirection = .leftToRight private var startedAt: ContinuousClock.Instant? = .none override func touchesMoved(_ touches: Set, with event: UIEvent) { super.touchesMoved(touches, with: event) let now = ContinuousClock.now if case .none = startedAt { startedAt = now } let velocity = self.velocity(in: self.view) if now - startedAt! < .milliseconds(30) { let distance = abs(velocity.x) - abs(velocity.y) let cap = abs(velocity.x) / (5 / 2) if distance < cap { print("too slow") state = .failed return } /// ensure swipe is horizontal if abs(velocity.x) < abs(velocity.y) { print("not horizontal") state = .failed return } /// ensure right swipe direction switch direction { case .leftToRight where velocity.x < 0: print("wrong direction") state = .failed return case .rightToLeft where velocity.x > 0: print("wrong direction") state = .failed return default: break } return } else if state == .possible { state = .began } else { state = .changed } } override func reset() { startedAt = .none } } @available(iOS 18, *) struct HorizontalSwipeGesture: UIGestureRecognizerRepresentable { typealias UIGestureRecognizerType = HorizontalSwipeGestureRecognizer @Binding var dragAmount: CGFloat @State private var surpassedThreshold = false let action: () -> Void let direction: LayoutDirection let threshold: CGFloat func makeUIGestureRecognizer(context: Context) -> UIGestureRecognizerType { let recognizer = HorizontalSwipeGestureRecognizer() recognizer.direction = direction recognizer.maximumNumberOfTouches = 1 recognizer.minimumNumberOfTouches = 1 recognizer.delegate = context.coordinator return recognizer } func updateUIGestureRecognizer(_ recognizer: UIGestureRecognizerType, context: Context) { recognizer.direction = direction } func makeCoordinator(converter _: CoordinateSpaceConverter) -> Coordinator { Coordinator() } func handleUIGestureRecognizerAction(_ recognizer: UIGestureRecognizerType, context: Context) { let sign: CGFloat = direction == .rightToLeft ? -1 : 1 switch recognizer.state { case .changed: let translation = recognizer.translation(in: recognizer.view) dragAmount = translation.x if translation.x * sign > threshold { if !surpassedThreshold { ContentView.feedbackGenerator.impactOccurred() } surpassedThreshold = true } else { surpassedThreshold = false } case .cancelled, .failed: dragAmount = 0 case .ended: dragAmount = 0 let translation = recognizer.translation(in: recognizer.view) if translation.x * sign > threshold { action() } default: break } } final class Coordinator: NSObject, UIGestureRecognizerDelegate { @objc func gestureRecognizer( _ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer ) -> Bool { if case .some = otherGestureRecognizer as? UIPanGestureRecognizer { return false } if case .some = otherGestureRecognizer as? UITapGestureRecognizer { return false } return true } } } @available(iOS 18, *) private var dragAmount: Binding { var transaction = Transaction() transaction.isContinuous = true return $newSecondaryActionDragAmount.transaction(transaction) } @ViewBuilder var inner: some View { Rectangle() .frame(width: Self.height, height: Self.height) .offset(x: translation, y: 0) } func action() { print("acted") } var body: some View { let threshold = Self.height let sign: CGFloat = layoutDirection == .rightToLeft ? -1 : 1 ScrollView { VStack { if #available(iOS 18, *) { inner .gesture( HorizontalSwipeGesture( dragAmount: dragAmount, action: action, direction: layoutDirection, threshold: threshold ) ) } else { inner .gesture(DragGesture(minimumDistance: 16) .updating($oldSecondaryActionDragAmount) { value, state, _ in if value.translation.width * sign > threshold, state.width * sign < threshold { Self.feedbackGenerator.impactOccurred() } state = value.translation } .onEnded { state in if state.translation.width * sign > threshold { action() } } ) } } .frame(maxWidth: .infinity, alignment: .leading) } .contentMargins(20) } }