Gestures are the backbone of every great iOS app. Every swipe, pinch, drag, and long press your users perform flows through SwiftUI's gesture system — and honestly, getting gestures right can make or break how your app feels.
But here's the thing: building polished gesture-driven interactions, especially when multiple gestures need to play nicely together, is one of the trickier parts of SwiftUI development. I've spent more time debugging gesture conflicts than I'd like to admit.
This guide walks you through every built-in gesture type, shows you how to compose them using simultaneous, sequenced, and exclusive patterns, and demonstrates how to build fully custom gestures. All examples use Swift 6 and target iOS 26 with the latest API conventions.
How SwiftUI's Gesture System Works
SwiftUI treats gestures as first-class value types that conform to the Gesture protocol. You attach them to views using modifiers, and SwiftUI's hit-testing engine routes touch events to the correct recognizer based on the view hierarchy.
Two things to internalize before we go further:
- Child views win by default. When a parent and child both have gestures, SwiftUI gives priority to the child's recognizer. This trips people up all the time.
- Only one gesture fires at a time unless you explicitly opt into simultaneous recognition.
Every gesture provides callbacks through onChanged (fired continuously as the gesture progresses) and onEnded (fired once when it completes). Some gestures also support updating for binding to @GestureState properties that automatically reset when the gesture ends — we'll dig into that shortly.
TapGesture: Single, Double, and Multi-Tap
The simplest gesture in SwiftUI. You can use the convenient .onTapGesture modifier or create a TapGesture struct when you need more control.
Basic Single Tap
struct TapExample: View {
@State private var tapped = false
var body: some View {
Circle()
.fill(tapped ? .green : .blue)
.frame(width: 100, height: 100)
.onTapGesture {
tapped.toggle()
}
}
}
Double Tap and Multi-Tap
Pass a count parameter to require multiple taps before the gesture fires. This works great for zoom-to-fit or reset actions:
Image("photo")
.resizable()
.scaledToFit()
.onTapGesture(count: 2) {
// Double-tap: reset zoom to 1.0
scale = 1.0
}
One gotcha here — when you combine single and double taps on the same view, always attach the higher count gesture first. SwiftUI waits briefly to see if more taps are coming before committing to the single-tap action. Get the order wrong and your double-tap will never fire.
LongPressGesture: Press and Hold
LongPressGesture triggers after the user holds their finger on a view for a specified duration. It's perfect for contextual menus, edit modes, and destructive action confirmations.
struct LongPressExample: View {
@State private var isActive = false
var body: some View {
RoundedRectangle(cornerRadius: 16)
.fill(isActive ? .red : .gray)
.frame(width: 200, height: 100)
.onLongPressGesture(minimumDuration: 0.8) {
isActive.toggle()
}
}
}
Tracking Press Progress
You can provide an onPressingChanged closure to animate the view while the user is still pressing. This gives really nice visual feedback before the gesture actually completes:
RoundedRectangle(cornerRadius: 16)
.fill(isPressing ? .orange : .gray)
.frame(width: 200, height: 100)
.onLongPressGesture(minimumDuration: 1.0) {
// Gesture completed
didComplete = true
} onPressingChanged: { pressing in
isPressing = pressing
}
DragGesture: Moving Views Around
DragGesture is arguably the most versatile gesture in SwiftUI. It tracks finger movement and gives you translation values, start locations, predicted end locations, and velocity — basically everything you need for drag-to-dismiss, swipe actions, and custom sliders.
Basic Draggable View
struct DraggableCard: View {
@State private var offset = CGSize.zero
var body: some View {
RoundedRectangle(cornerRadius: 20)
.fill(.blue.gradient)
.frame(width: 200, height: 300)
.offset(offset)
.gesture(
DragGesture()
.onChanged { value in
offset = value.translation
}
.onEnded { _ in
withAnimation(.spring()) {
offset = .zero
}
}
)
}
}
Using @GestureState for Automatic Reset
@GestureState is a property wrapper built specifically for gestures. Unlike @State, it automatically resets to its initial value when the gesture ends — no manual cleanup needed. This makes it ideal for temporary visual feedback during a drag:
struct SmartDraggable: View {
@GestureState private var dragOffset = CGSize.zero
@State private var position = CGSize.zero
var body: some View {
Circle()
.fill(.purple)
.frame(width: 80, height: 80)
.offset(
x: position.width + dragOffset.width,
y: position.height + dragOffset.height
)
.gesture(
DragGesture()
.updating($dragOffset) { value, state, _ in
state = value.translation
}
.onEnded { value in
position.width += value.translation.width
position.height += value.translation.height
}
)
}
}
The key advantage: dragOffset snaps back to .zero automatically when the user lifts their finger, while position accumulates the final resting point after each drag. Once you start using this pattern, you won't go back.
Building a Swipe-to-Dismiss Card
Now let's build something fun. Combine drag translation with opacity and rotation for a Tinder-style swipe card:
struct SwipeCard: View {
@State private var offset = CGSize.zero
@State private var isDismissed = false
let dismissThreshold: CGFloat = 150
var body: some View {
if !isDismissed {
RoundedRectangle(cornerRadius: 20)
.fill(.white)
.shadow(radius: 5)
.frame(width: 300, height: 400)
.offset(x: offset.width)
.rotationEffect(.degrees(Double(offset.width / 20)))
.opacity(2 - Double(abs(offset.width / dismissThreshold)))
.gesture(
DragGesture()
.onChanged { value in
offset = value.translation
}
.onEnded { value in
if abs(value.translation.width) > dismissThreshold {
withAnimation(.easeOut(duration: 0.3)) {
offset.width = value.translation.width > 0 ? 500 : -500
isDismissed = true
}
} else {
withAnimation(.spring()) {
offset = .zero
}
}
}
)
}
}
}
The subtle rotation tied to horizontal offset is what sells the effect. Play with the / 20 divisor to control how dramatic the tilt feels.
MagnifyGesture: Pinch to Zoom
MagnifyGesture (renamed from MagnificationGesture back in iOS 17) tracks pinch-to-zoom interactions. Its value is a scale multiplier — 1.0 means no change, 2.0 means doubled in size.
struct ZoomableImage: View {
@State private var currentScale: CGFloat = 1.0
@GestureState private var gestureScale: CGFloat = 1.0
var body: some View {
Image("landscape")
.resizable()
.scaledToFit()
.scaleEffect(currentScale * gestureScale)
.gesture(
MagnifyGesture()
.updating($gestureScale) { value, state, _ in
state = value.magnification
}
.onEnded { value in
currentScale *= value.magnification
currentScale = min(max(currentScale, 0.5), 5.0)
}
)
}
}
Notice the clamping in onEnded — always set sensible min/max bounds. Trust me, you don't want users zooming to 50x and wondering why everything looks like pixel soup.
RotateGesture: Two-Finger Rotation
RotateGesture (also renamed in iOS 17, from RotationGesture) tracks two-finger rotation and reports the angle as an Angle value:
struct RotatableView: View {
@State private var currentAngle: Angle = .zero
@GestureState private var gestureAngle: Angle = .zero
var body: some View {
Rectangle()
.fill(.orange.gradient)
.frame(width: 200, height: 200)
.rotationEffect(currentAngle + gestureAngle)
.gesture(
RotateGesture()
.updating($gestureAngle) { value, state, _ in
state = value.rotation
}
.onEnded { value in
currentAngle += value.rotation
}
)
}
}
On its own, rotation is straightforward. Where things get interesting is when you combine it with other gestures.
Composing Gestures: Simultaneous, Sequenced, and Exclusive
Real-world apps rarely use gestures in isolation. A photo editor needs simultaneous pinch and rotate. A kanban board needs long-press-then-drag. SwiftUI gives you four composition strategies to handle these scenarios — and picking the right one matters more than you might think.
Simultaneous Gestures
Use .simultaneously(with:) or the simultaneousGesture() modifier when two gestures should both be active at the same time. The classic example is a photo viewer with pinch-to-zoom and rotation:
struct PhotoViewer: View {
@GestureState private var magnification: CGFloat = 1.0
@GestureState private var rotation: Angle = .zero
var body: some View {
Image("photo")
.resizable()
.scaledToFit()
.scaleEffect(magnification)
.rotationEffect(rotation)
.gesture(
MagnifyGesture()
.updating($magnification) { value, state, _ in
state = value.magnification
}
.simultaneously(with:
RotateGesture()
.updating($rotation) { value, state, _ in
state = value.rotation
}
)
)
}
}
This feels incredibly natural to users — they expect to pinch and rotate a photo in one fluid motion.
Sequenced Gestures
Use .sequenced(before:) when the second gesture should only kick in after the first one completes. The most common pattern here is long-press-then-drag, where you want to prevent accidental drags:
struct LongPressDraggable: View {
@State private var position = CGPoint(x: 200, y: 200)
@GestureState private var dragState = DragState.inactive
enum DragState {
case inactive
case pressing
case dragging(translation: CGSize)
var translation: CGSize {
switch self {
case .dragging(let t): return t
default: return .zero
}
}
var isActive: Bool {
switch self {
case .inactive: return false
default: return true
}
}
}
var body: some View {
Circle()
.fill(dragState.isActive ? .green : .blue)
.frame(width: 80, height: 80)
.position(
x: position.x + dragState.translation.width,
y: position.y + dragState.translation.height
)
.gesture(
LongPressGesture(minimumDuration: 0.5)
.sequenced(before: DragGesture())
.updating($dragState) { value, state, _ in
switch value {
case .first(true):
state = .pressing
case .second(true, let drag):
state = .dragging(
translation: drag?.translation ?? .zero
)
default:
state = .inactive
}
}
.onEnded { value in
guard case .second(true, let drag?) = value else {
return
}
position.x += drag.translation.width
position.y += drag.translation.height
}
)
}
}
This is the gold standard for movable items in kanban boards, widget editors, and drawing tools. The long press acts as a deliberate activation gate — it prevents accidental drags while scrolling, which is something your users will thank you for (even if they never realize why it feels so good).
Exclusive Gestures
Use .exclusively(before:) when only one of two gestures should succeed. SwiftUI prioritizes the first gesture — if it matches, the second gets ignored:
let tapOrDrag = TapGesture()
.onEnded { /* handle tap */ }
.exclusively(before:
DragGesture()
.onEnded { _ in /* handle drag */ }
)
Simple, but effective when you need clean either/or behavior.
High Priority Gestures
By default, child views consume gestures before their parents. Use .highPriorityGesture() on a parent view to flip this priority:
VStack {
Button("Child Action") { print("child tapped") }
}
.highPriorityGesture(
TapGesture()
.onEnded { print("parent wins") }
)
A word of caution: use this sparingly. Overriding the default child-first behavior can confuse users when buttons stop responding as expected. I've seen this cause some really frustrating UX bugs.
Building a Custom Gesture
When the built-in gestures aren't enough, you can create your own by conforming to the Gesture protocol. The protocol requires a body property that returns another gesture — you basically compose custom behavior from existing primitives.
Here's a directional swipe gesture that only triggers for horizontal swipes beyond a minimum distance:
enum SwipeDirection {
case left, right
}
struct HorizontalSwipeGesture: Gesture {
let minimumDistance: CGFloat
let onSwipe: (SwipeDirection) -> Void
init(
minimumDistance: CGFloat = 50,
onSwipe: @escaping (SwipeDirection) -> Void
) {
self.minimumDistance = minimumDistance
self.onSwipe = onSwipe
}
var body: some Gesture {
DragGesture(minimumDistance: minimumDistance)
.onEnded { value in
let horizontal = value.translation.width
let vertical = value.translation.height
// Only trigger if horizontal movement exceeds vertical
guard abs(horizontal) > abs(vertical) else { return }
if horizontal > 0 {
onSwipe(.right)
} else {
onSwipe(.left)
}
}
}
}
// Usage
Text("Swipe Me")
.gesture(
HorizontalSwipeGesture { direction in
print("Swiped \(direction)")
}
)
The guard check comparing horizontal vs. vertical magnitude is the secret sauce — it prevents diagonal swipes from triggering, which makes the gesture feel intentional rather than twitchy.
Handling Gesture Conflicts in iOS 26
Heads up on this one. iOS 26 introduced a subtle breaking change to gesture handling within NavigationLink. The implicit button behavior now more aggressively claims gesture exclusivity, which means .simultaneousGesture attached to navigation links may just... stop working.
The recommended workaround combines a few techniques:
NavigationLink(destination: DetailView()) {
CardView()
}
.buttonStyle(.plain) // Unlock child gesture recognition
.contentShape(Rectangle()) // Ensure proper hit testing
.simultaneousGesture(
LongPressGesture(minimumDuration: 0.5)
.onEnded { _ in
showContextMenu = true
}
)
If you need the primary action to always fire regardless of child views, swap .simultaneousGesture() for .highPriorityGesture() instead.
Performance Best Practices
Gesture handlers fire at up to 120 Hz on ProMotion displays. That's a lot of callbacks. Poorly optimized onChanged closures will cause visible jank, and your users will notice.
Here's what to keep in mind:
- Use
@GestureStateover@Statefor temporary gesture data. It resets automatically and avoids unnecessary view updates after the gesture ends. - Keep
onChangedclosures lightweight. Move expensive calculations (hit testing, layout recalculation) toonEndedwhenever possible. - Set a
coordinateSpacewhen usingDragGestureinside nested scroll views. Without it, you'll get coordinate confusion that's maddening to debug. - Avoid creating new gesture instances inside
bodyon every view update. Extract gestures into computed properties to help SwiftUI's diffing engine. - Use the
@Animatablemacro (new in iOS 26) for smooth gesture-driven animations — it replaces the old verboseanimatableDataboilerplate.
// Extract gesture into a computed property
struct OptimizedView: View {
@GestureState private var drag = CGSize.zero
private var dragGesture: some Gesture {
DragGesture()
.updating($drag) { value, state, _ in
state = value.translation
}
}
var body: some View {
Circle()
.offset(drag)
.gesture(dragGesture)
}
}
That small refactor — pulling the gesture out of body — can make a meaningful difference in complex views.
Quick Reference: Choosing the Right Gesture Composition
When your view needs to handle multiple gestures, use this as a quick cheat sheet:
- Both gestures at the same time? →
.simultaneously(with:) - Second gesture only after the first completes? →
.sequenced(before:) - Only one gesture should win? →
.exclusively(before:) - Parent gesture should override child? →
.highPriorityGesture()
Frequently Asked Questions
How do I combine a pinch and rotation gesture in SwiftUI?
Use .simultaneously(with:) to attach a MagnifyGesture and RotateGesture to the same view. Both gestures will recognize at the same time, letting the user pinch to zoom and rotate with two fingers in one fluid motion. Store the cumulative scale and angle in @GestureState properties for automatic reset.
Why does my SwiftUI gesture stop working inside a ScrollView?
SwiftUI's ScrollView claims drag gestures for scrolling, so your DragGesture gets swallowed. To fix this, use .simultaneousGesture() or increase the minimumDistance parameter on your DragGesture so it doesn't compete with scrolling. Another solid option: gate your drag behind a LongPressGesture using .sequenced(before:).
What is the difference between @GestureState and @State for gestures?
@GestureState automatically resets to its initial value when the gesture ends — you don't need to manually reset it in onEnded. Use @GestureState for temporary values during the gesture (like drag offset) and @State for values that should stick around after the gesture completes (like final position).
How do I create a long press then drag gesture in SwiftUI?
Chain a LongPressGesture with a DragGesture using .sequenced(before:). The drag only activates after the long press succeeds. Use an enum-based @GestureState to track whether you're in the pressing or dragging phase, and update the view's appearance accordingly for clear visual feedback.
Can I use SwiftUI gestures with UIKit views?
Yes, but with some caveats. When wrapping UIKit views with UIViewRepresentable, you can attach SwiftUI gestures to the wrapper view. However, if the UIKit view has its own UIGestureRecognizer instances, they'll likely conflict with SwiftUI's gesture system. In those cases, it's usually cleaner to manage gestures entirely on the UIKit side and communicate state changes back to SwiftUI through bindings or the coordinator pattern.