SwiftUI Gestures: Tap, Drag, Rotate, and Custom Compositions

Learn every SwiftUI gesture — from basic taps and drags to pinch-rotate compositions and custom gesture types. Covers all five built-in gestures, four composition strategies, practical examples like swipe-to-dismiss cards and long-press-then-drag patterns, plus performance tips for iOS 26.

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 @GestureState over @State for temporary gesture data. It resets automatically and avoids unnecessary view updates after the gesture ends.
  • Keep onChanged closures lightweight. Move expensive calculations (hit testing, layout recalculation) to onEnded whenever possible.
  • Set a coordinateSpace when using DragGesture inside nested scroll views. Without it, you'll get coordinate confusion that's maddening to debug.
  • Avoid creating new gesture instances inside body on every view update. Extract gestures into computed properties to help SwiftUI's diffing engine.
  • Use the @Animatable macro (new in iOS 26) for smooth gesture-driven animations — it replaces the old verbose animatableData boilerplate.
// 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.

About the Author Editorial Team

Our team of expert writers and editors.