Gestures
Direct manipulation — state that follows a finger, and why @GestureState exists when @State already does.
By the end you will be able to
- Wire up tap, long-press and drag gestures with their callbacks
- Explain what @GestureState adds over @State and when each is right
- Combine gestures and predict which one wins
A draggable card should snap back to centre when the user lets go — including when the system cancels the drag midway (a phone call arrives, a parent scroll view claims the touch). Which tool guarantees the snap-back with no code path forgotten?
The gesture vocabulary
Convenience modifiers cover the common cases:
Card()
.onTapGesture { select() }
.onTapGesture(count: 2) { zoom() }
.onLongPressGesture { showContextActions() }
The full form attaches a gesture value — a recogniser you configure and compose:
Card()
.gesture(
DragGesture()
.onChanged { value in offset = value.translation }
.onEnded { value in finishDrag(velocity: value.velocity) }
)
DragGesture's callbacks receive a value carrying translation (offset since start), location, startLocation, velocity, and predictedEndTranslation — where the drag would land if the finger's momentum continued. That last one is how card-dismissal UIs decide "flick away or snap back" from a short, fast swipe.
@State vs @GestureState
The dividing line: does the value outlive the finger?
struct DraggableCard: View {
@GestureState private var dragOffset = CGSize.zero // only while dragging
@State private var position = CGSize.zero // survives between drags
var body: some View {
Card()
.offset(
x: position.width + dragOffset.width,
y: position.height + dragOffset.height
)
.gesture(
DragGesture()
.updating($dragOffset) { value, state, _ in
state = value.translation // write via the in-out param
}
.onEnded { value in
position.width += value.translation.width
position.height += value.translation.height
}
)
.animation(.spring, value: dragOffset)
}
}
Read the division of labour:
.updating($dragOffset)writes the live offset through aninoutparameter while the finger moves. The moment the gesture ends or is cancelled, the framework resetsdragOffsetto.zero— you cannot forget to, because there is nothing to write..onEndedcommits the result into persistent state. It runs on a successful end only, which is exactly right for a commit.- The spring bound to
dragOffsetanimates the automatic snap-back for free.
This split — live value resets itself; committed value is explicit — is the pattern behind every polished drag interaction you have used: sheet pull-downs, photo pinch-zoom that springs back past the limit, swipe-to-reply in messaging apps.
Composing gestures
Real interfaces layer gestures, and SwiftUI makes the arbitration explicit:
// Both at once — pinch and rotate a photo simultaneously:
MagnifyGesture().simultaneously(with: RotateGesture())
// One then the other — long-press to lift, then drag:
LongPressGesture(minimumDuration: 0.3).sequenced(before: DragGesture())
// First to succeed wins:
TapGesture(count: 2).exclusively(before: TapGesture(count: 1))
sequenced(before:) is the one that unlocks a familiar interaction: the "hold to pick up, then move" of home-screen icon rearranging. The drag half never starts unless the long-press half completed — and until it does, the scroll view keeps its claim on vertical movement, which is why the pattern feels trustworthy inside scrolling content.
When a gesture competes with a system one (a drag inside a ScrollView), the system gesture wins by default — usually what you want. .highPriorityGesture(_:) reverses that; treat it as a last resort, because stealing touches from scrolling is how apps end up feeling broken.
This model replays the @GestureState contract in plain Swift: live state resets on ANY end, committed state only changes on success. What does it print?
struct DragSession {
var liveOffset = 0 // the @GestureState
var committed = 0 // the @State
mutating func move(to offset: Int) {
liveOffset = offset
}
mutating func end() {
committed += liveOffset // .onEnded commits
liveOffset = 0 // framework resets
}
mutating func cancel() {
liveOffset = 0 // framework resets — but NO commit
}
var rendered: Int { committed + liveOffset }
}
var session = DragSession()
session.move(to: 40)
print(session.rendered)
session.end()
print(session.rendered)
session.move(to: 25)
session.cancel()
print(session.rendered)A horizontal swipe-to-dismiss lives inside a vertically scrolling feed. Sometimes a diagonal swipe scrolls the feed and nudges the card. What is the principled fix?
Separate live from committed
Model a pinch-to-zoom: liveScale exists only during the pinch, zoom persists. Implement pinch(to:), endPinch() and cancelPinch() so the printed trace matches.
Hold to arm, then drag
Attach the icon-rearranging composition: a long-press that must succeed before a drag begins.
Design the gesture system for a bottom sheet like Maps': it rests at three heights, follows the finger between them, and settles to the nearest on release — flicks counting extra. Name each piece in this lesson's terms: what lives in @GestureState, what in @State, what .onEnded computes (which value from the drag?), and how the sheet's drag coexists with the map's pan underneath.
You can now:
- Attach tap, long-press and drag gestures, and read the drag's value payload
- Split interaction state into live (
@GestureState) and committed (@State) - Compose gestures and reason about who wins a touch
- Say why buttons beat tap gestures for actions
Next up: architecture — keeping all of this testable as the app grows.