Animation, gestures and drawing · core

Gestures

Direct manipulation — state that follows a finger, and why @GestureState exists when @State already does.

11 min read12 min practice0/2 exercises4 recall cards

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
Guess firstAnswering before you read makes the explanation stick — even when you get it wrong.

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 an inout parameter while the finger moves. The moment the gesture ends or is cancelled, the framework resets dragOffset to .zero — you cannot forget to, because there is nothing to write.
  • .onEnded commits the result into persistent state. It runs on a successful end only, which is exactly right for a commit.
  • The spring bound to dragOffset animates 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.

A draggable card's end state. The gesture chain is recorded on the tree.
Run to see the preview.
Predict, then runA wrong prediction you have committed to is worth more than a right answer you read.

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)
Check yourself

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

Write the code

Model a pinch-to-zoom: liveScale exists only during the pinch, zoom persists. Implement pinch(to:), endPinch() and cancelPinch() so the printed trace matches.

Solution unlocks after 3 attempts

Hold to arm, then drag

Build the view

Attach the icon-rearranging composition: a long-press that must succeed before a drag begins.

Solution unlocks after 3 attempts
Design itSaved on this device. Never graded.

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.

Checkpoint

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.

How well do you know this now? Rating yourself honestly, then being tested on it, is how you find out where your intuition is wrong.