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.
| Gesture | Usage | Tracks |
|---|---|---|
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
| Scenario | Use |
|---|---|
| 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
)