Skip to content

Instantly share code, notes, and snippets.

@PhilipTrauner
Created May 7, 2025 10:06
Show Gist options
  • Save PhilipTrauner/09c03cd4418aa7660ee88a07816cd352 to your computer and use it in GitHub Desktop.
Save PhilipTrauner/09c03cd4418aa7660ee88a07816cd352 to your computer and use it in GitHub Desktop.
Gesture representable horizontal swipe in `ScrollView` with fallback
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<UITouch>, 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<CGFloat> {
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)
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment