Created
May 7, 2025 10:06
-
-
Save PhilipTrauner/09c03cd4418aa7660ee88a07816cd352 to your computer and use it in GitHub Desktop.
Gesture representable horizontal swipe in `ScrollView` with fallback
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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