Animation and transitions
SwiftUI animates state changes, not views — grasp that inversion and the whole animation system falls into place.
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
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
buildEithermachinery from the result-builders lesson. Anifcompiles to an either-value, so flipping it genuinely inserts one subtree and removes another — that is why transitions apply, and why the view's@Statedoes not survive the flip.
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.
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)
}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
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.
Enter differently than you leave
Give the toast an asymmetric transition: it should scale in when inserted but fade out when removed.
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.
You can now:
- Explain "animate the change, not the view" and what the diff actually interpolates
- Bind animation to events with
withAnimationand 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.