Skip to content

SwiftUI Notes 4: Gesture Shortcuts

03/06/2026

SwiftUI gives you two ways to handle gestures: convenience modifiers and the full .gesture() API. This guide covers every shortcut modifier with practical code examples, explains what each parameter does, and shows when you need to reach for the lower-level .gesture() approach instead.

I. onTapGesture

1. Basic single tap

The simplest gesture in SwiftUI. Fires once when the user taps the view.

Text("Tap me")
    .onTapGesture {
        print("Tapped!")
    }

This is equivalent to writing:

.gesture(
    TapGesture()
        .onEnded { print("Tapped!") }
)

2. Double & triple tap

Use the count parameter to require multiple taps. count defaults to 1 if you don't specify it.

Image("photo")
    .onTapGesture(count: 2) {
        // Double tap — like Instagram's heart
        isLiked.toggle()
    }

.onTapGesture(count: 3) {
    // Triple tap — select a paragraph
    selectAll()
}

3. Combining single + double tap

Put the higher count first so SwiftUI can distinguish them correctly. Order matters: the double-tap modifier must come before the single-tap modifier. Otherwise the single tap always fires first.

SwiftUI waits briefly after the first tap to see if more taps are coming. This adds a small delay to single-tap handlers when both single and double tap are on the same view.

Image("photo")
    .onTapGesture(count: 2) {
        isLiked.toggle()
    }
    .onTapGesture(count: 1) {
        showDetails = true
    }

4. Tap location (iOS 17+)

Starting in iOS 17, you can get the exact tap position. The location is relative to the view's coordinate space. Works with count too: .onTapGesture(count: 2) { location in ... }.

Canvas { context, size in
    // draw something
}
.onTapGesture { location in
    // location is a CGPoint
    print("Tapped at \(location)")
    dropPin(at: location)
}

II. onLongPressGesture

1. Basic long press

Fires after the user holds down for a minimum duration. Default minimumDuration is 0.5 seconds.

Text("Hold me")
    .onLongPressGesture {
        showMenu = true
    }

2. Custom duration

Set how long the user must hold before the gesture triggers.

Button("Delete") { }
    .onLongPressGesture(minimumDuration: 2.0) {
        // Only fires after 2 full seconds
        deleteItem()
    }

3. onPressingChanged

This is the real power of the shortcut. The onPressingChanged callback tells you when the press starts and ends, so you can animate during the press.

onPressingChanged sends true immediately when the finger touches down. It sends false either when the finger lifts early (gesture cancelled) or when the full duration completes. This replaces the @GestureState + .updating() pattern.

@State private var isPressing = false
@State private var didComplete = false

Circle()
    .fill(isPressing ? .red : .blue)
    .frame(width: 80, height: 80)
    .scaleEffect(isPressing ? 1.4 : 1.0)
    .animation(.spring, value: isPressing)
    .onLongPressGesture(minimumDuration: 1.0) {
        // Fires when the full duration completes
        didComplete = true
    } onPressingChanged: { pressing in
        // pressing = true when finger goes down
        // pressing = false when finger lifts
        // OR when minimumDuration completes
        isPressing = pressing
    }

4. Practical example

A "hold to confirm" delete button that fills up as you press.

@State private var pressing = false

ZStack(alignment: .leading) {
    RoundedRectangle(cornerRadius: 12)
        .fill(.gray.opacity(0.2))
    RoundedRectangle(cornerRadius: 12)
        .fill(.red)
        .scaleEffect(
            x: pressing ? 1 : 0,
            y: 1,
            anchor: .leading
        )
        .animation(
            pressing ? .linear(duration: 2.0) : .spring,
            value: pressing
        )
    Text("Hold to Delete")
        .padding()
}
.frame(height: 50)
.onLongPressGesture(minimumDuration: 2.0) {
    deleteItem()
} onPressingChanged: { value in
    pressing = value
}

III. Gestures Without Shortcuts

These gestures require the full .gesture() modifier. There is no .onDrag, .onMagnify, or .onRotate convenience shortcut.

GestureUsageTracks
DragGesture.gesture(DragGesture() ...)Translation, velocity
MagnifyGesture.gesture(MagnifyGesture() ...)Scale factor
RotateGesture.gesture(RotateGesture() ...)Rotation angle
SpatialTapGesture.gesture(SpatialTapGesture() ...)Tap location
@State private var offset = CGSize.zero

RoundedRectangle(cornerRadius: 16)
    .frame(width: 100, height: 100)
    .offset(offset)
    .gesture(
        DragGesture()
            .onChanged { value in
                offset = value.translation
            }
            .onEnded { value in
                withAnimation(.spring) {
                    offset = .zero
                }
            }
    )

IV. When to Use Which

ScenarioUse
React to a tap.onTapGesture { }
React to a long press.onLongPressGesture { }
Animate during a long press.onLongPressGesture { } onPressingChanged: { }
Drag, pinch, or rotate.gesture(DragGesture() ...)
Two gestures at once (e.g. drag + rotate).gesture(drag.simultaneously(with: rotate))
One gesture then another (e.g. long press then drag).gesture(longPress.sequenced(before: drag))
Auto-resetting state during gesture@GestureState + .updating()

Rule of thumb: start with the shortcut. Move to .gesture() only when you need to combine gestures, use @GestureState, or handle a gesture type that has no shortcut.

V. Quick Reference

That's it. Two modifiers, three signatures. Everything else goes through .gesture().

// Tap
.onTapGesture(count: Int = 1, perform: () -> Void)

// Tap with location (iOS 17+)
.onTapGesture(count: Int = 1, perform: (CGPoint) -> Void)

// Long press
.onLongPressGesture(
    minimumDuration: Double = 0.5,
    perform: () -> Void,
    onPressingChanged: ((Bool) -> Void)? = nil
)