Animation, gestures and drawing · core

Animation and transitions

SwiftUI animates state changes, not views — grasp that inversion and the whole animation system falls into place.

12 min read13 min practice0/2 exercises5 recall cards

By the end you will be able to

  • Explain what SwiftUI actually animates when you write withAnimation
  • Choose between withAnimation and the .animation(_:value:) modifier
  • Use transitions for views that appear and disappear
Guess firstAnswering before you read makes the explanation stick — even when you get it wrong.

In UIKit you animate a view: "move this label 50 points over 0.3s". What do you animate in SwiftUI?

Animate the change, not the view

You already know that body is a function of state. Animation slots into that model in the only place it can: between two states.

struct Expander: View {
    @State private var isExpanded = false

    var body: some View {
        VStack {
            Rectangle()
                .frame(height: isExpanded ? 200 : 60)

            Button("Toggle") {
                withAnimation(.spring) {
                    isExpanded.toggle()
                }
            }
        }
    }
}

withAnimation does not mention the rectangle. It marks the state mutation as animated; SwiftUI computes the old tree, the new tree, and animates every property that differs. Add three more views that depend on isExpanded and they all animate in sync — with no extra code, which is the real-world payoff: an expanding card that pushes list rows down animates the card and every row, because they all changed.

Two ways in, one rule

withAnimation — animate this event. Wraps a state change at the cause side. Everything the change touches animates.

Button("Add") {
    withAnimation(.bouncy) {
        items.append(newItem)
    }
}

.animation(_:value:) — animate whenever this value changes. Declared at the effect side, on the view:

Card()
    .scaleEffect(isSelected ? 1.05 : 1.0)
    .animation(.spring, value: isSelected)

Whenever isSelected changes — from any code path — the card's changes animate.

The rule for choosing: withAnimation when the trigger is one event you control; .animation(value:) when the same value changes from many places (network callbacks, timers, several buttons) and repeating withAnimation at every site would be noise.

The animation curves

.linear(duration: 0.3)        // constant speed — progress bars, marquees
.easeInOut(duration: 0.3)     // slow-fast-slow — the classic
.spring                       // physically modelled — the iOS default feel
.bouncy                       // spring preset with visible overshoot
.smooth                       // spring preset with none
.snappy                       // spring preset, quick with a hint of bounce

Modern iOS motion is spring-based almost throughout — springs respond naturally when interrupted mid-flight, because they model velocity rather than a fixed timeline. When a user taps "expand" and immediately taps "collapse", a spring reverses gracefully from wherever it is; an ease curve restarts. Default to .spring or its presets; reach for easeInOut when motion should feel mechanical rather than physical.

Springs tune with plain-language parameters:

.spring(duration: 0.4, bounce: 0.2)   // bounce 0 = no overshoot, 1 = very bouncy

Transitions: appearing and disappearing

Interpolation needs a before and an after. A view that is inserted or removed has only one of those — so it gets a transition instead: a description of how it enters and leaves.

VStack {
    if showsBanner {
        Banner()
            .transition(.move(edge: .top).combined(with: .opacity))
    }
}

When showsBanner flips inside withAnimation, the banner slides in from the top edge while fading. The vocabulary:

.opacity                              // fade
.move(edge: .top)                     // slide from an edge
.scale                                // grow/shrink
.slide                                // leading in, trailing out
.asymmetric(insertion: .scale, removal: .opacity)   // different in and out

Two facts that save real debugging time:

  • A transition without an animation does nothing. The insertion must happen inside withAnimation (or under .animation(value:)) — the transition says what, the animation says how fast.
  • This is the buildEither machinery from the result-builders lesson. An if compiles to an either-value, so flipping it genuinely inserts one subtree and removes another — that is why transitions apply, and why the view's @State does not survive the flip.
End states of a transition pair. The .transition modifiers are recorded on the tree.
Run to see the preview.

What animates a list

For rows inserted into and removed from a List/ForEach, the same rules compose with identity from the lists lesson: SwiftUI matches rows by id between the old and new trees, then animates inserted rows in, removed rows out, and moved rows to their new position. This is where bad identity becomes visible — an index-based id turns a deletion into "every row after it changed contents", which animates as an ugly full-list crossfade instead of one row sliding out.

withAnimation(.spring) {
    items.removeAll { $0.isDone }     // done rows animate out; others slide up
}

Interruptibility, and honesty about the runtime

On a device, animations are interruptible by default: state changes mid-animation retarget smoothly from the current position, springs carrying their velocity. You get this for free — it is a headline reason the system feels good — but it also means your state must always be the target, never a mid-flight reading. Here is what "fighting the system" actually looks like: a card is springing toward x = 300, code reads its mid-flight position (x = 172, via a GeometryReader) and stores that back as state — now the animation retargets toward a stale halfway point, and rapid taps make the card creep and stutter instead of landing. State is where things are going; the framework owns where they momentarily are.

And accessibility is not optional here: respect Reduce Motion.

@Environment(\.accessibilityReduceMotion) private var reduceMotion

withAnimation(reduceMotion ? nil : .spring) { isExpanded.toggle() }

Passing nil applies the change instantly. Roughly one in five users has some motion sensitivity setting enabled; large moving surfaces can cause genuine vestibular discomfort. The accessibility lesson goes deeper.

Predict, then runA wrong prediction you have committed to is worth more than a right answer you read.

The animation modifier only fires for its own value:. This model mirrors that rule in plain Swift — which changes get the "animated" tag?

struct ChangeLog {
    var entries: [String] = []

    mutating func record(_ property: String, animatesOn tracked: String) {
        if property == tracked {
            entries.append("\(property): animated")
        } else {
            entries.append("\(property): instant")
        }
    }
}

var log = ChangeLog()
// The view: Card().scaleEffect(...).animation(.spring, value: "isSelected")
log.record("isSelected", animatesOn: "isSelected")
log.record("badgeCount", animatesOn: "isSelected")
log.record("isSelected", animatesOn: "isSelected")

for entry in log.entries {
    print(entry)
}
Check yourself

A banner should slide in from the top when a save succeeds. You write .transition(.move(edge: .top)) and flip showsBanner = true — and it just pops in, no slide. What is missing?

Wire the animation to its value

Build the view

The card should animate its scaling whenever isSelected changes, whatever code changes it. Add the correctly-bound animation modifier — not the deprecated value-less form.

Solution unlocks after 3 attempts

Enter differently than you leave

Build the view

Give the toast an asymmetric transition: it should scale in when inserted but fade out when removed.

Solution unlocks after 3 attempts
PredictSaved on this device. Never graded.

Open an app with a well-known animation — Mail's swipe-to-archive, Photos' grid-to-detail zoom. Predict how it is built in the terms of this lesson: what state changes, whether the motion is withAnimation or value-bound, what the transition is, and what happens if you interrupt it mid-flight. Then interrupt it on your phone and check the last part.

Checkpoint

You can now:

  • Explain "animate the change, not the view" and what the diff actually interpolates
  • Bind animation to events with withAnimation and to values with .animation(_:value:)
  • Attach transitions to appearing/disappearing views and make them actually run
  • Default to springs, and respect Reduce Motion

Next up: gestures — direct manipulation, and state that follows a finger.

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.