5.7 — Animations & transitions
Opening scenario
A designer drops a Lottie file in Slack and asks “can we just match this?” The animation: a cart icon scales up, the count badge slides in from the top-right with a bouncy spring, the underlying button shifts color, and the previous count crossfades out. In UIKit, you’d spend a day with UIView.animate(withDuration:delay:options:animations:) and CABasicAnimation, and the result wouldn’t quite match.
In SwiftUI, this is ~30 lines. The framework’s animation system is declarative — you describe what state means visually; SwiftUI interpolates between states when state changes. You don’t manage animation curves manually for each property; you change a value, wrap it in withAnimation, and SwiftUI handles the rest.
| Concept | Use for |
|---|---|
Implicit animation (.animation(_:value:)) | Animate a specific value’s changes |
Explicit animation (withAnimation { }) | Animate a state mutation block |
Transition (.transition(_:)) | Animate insertion/removal |
matchedGeometryEffect | Animate elements moving between layouts |
PhaseAnimator | Multi-phase scripted animations |
KeyframeAnimator | Complex keyframe-based animations |
Custom AnimatableData | Animate non-standard properties |
Concept → Why → How → Code
Implicit animations
struct LikeButton: View {
@State private var isLiked = false
var body: some View {
Image(systemName: isLiked ? "heart.fill" : "heart")
.foregroundStyle(isLiked ? .red : .gray)
.scaleEffect(isLiked ? 1.2 : 1.0)
.animation(.spring(response: 0.3, dampingFraction: 0.6), value: isLiked)
.onTapGesture { isLiked.toggle() }
}
}
.animation(_:value:)says “whenvaluechanges, animate dependent properties”- The
value:parameter is critical (the deprecated 1-arg.animation(_:)animates everything indiscriminately) - Animation applies to the modifiers above it in the chain
Explicit animations
Button("Toggle") {
withAnimation(.spring) {
isExpanded.toggle()
}
}
withAnimation { } wraps the state mutation. Every observable change in the closure is animated with the given curve. This is the most common pattern in real codebases — you control when animations happen at the source, not by sprinkling .animation modifiers across views.
Animation curves
.animation(.linear, value: x)
.animation(.easeIn, value: x)
.animation(.easeOut, value: x)
.animation(.easeInOut(duration: 0.5), value: x)
.animation(.spring, value: x) // default spring
.animation(.spring(duration: 0.4, bounce: 0.3), value: x)
.animation(.bouncy, value: x) // playful spring
.animation(.smooth, value: x) // snappy spring
.animation(.snappy, value: x) // fast spring
.animation(.interpolatingSpring(stiffness: 100, damping: 10), value: x)
iOS 17+ animation presets (.spring, .bouncy, .smooth, .snappy) cover 95% of cases and are physically tuned.
Modifiers:
.animation(.spring.delay(0.2), value: x)
.animation(.spring.speed(2.0), value: x)
.animation(.spring.repeatCount(3, autoreverses: true), value: x)
.animation(.spring.repeatForever(), value: x)
Transitions
Transitions animate insertion and removal:
struct Card: View {
@State private var isShowing = false
var body: some View {
VStack {
Button("Toggle") { withAnimation { isShowing.toggle() } }
if isShowing {
Text("Hello!")
.padding()
.background(.regularMaterial)
.transition(.scale.combined(with: .opacity))
}
}
}
}
Built-in transitions:
.identity(no animation).opacity(fade in/out).scale(grow/shrink, optional anchor).move(edge: .leading)(slide in/out).slide(slide from leading).push(from: .trailing)(system push)
Combine: .scale.combined(with: .opacity)
Asymmetric (different in vs out):
.transition(.asymmetric(
insertion: .move(edge: .leading).combined(with: .opacity),
removal: .scale(scale: 0.8).combined(with: .opacity)
))
matchedGeometryEffect — element morphing
The “magic move” effect: a thumbnail in a grid expands into a full-screen view, smoothly animating its position and size.
struct Gallery: View {
@Namespace private var ns
@State private var selectedID: Photo.ID?
var body: some View {
ZStack {
if let id = selectedID, let photo = photos.first(where: { $0.id == id }) {
AsyncImage(url: photo.fullURL)
.matchedGeometryEffect(id: photo.id, in: ns)
.onTapGesture {
withAnimation(.spring) { selectedID = nil }
}
} else {
LazyVGrid(columns: gridColumns) {
ForEach(photos) { photo in
AsyncImage(url: photo.thumbnailURL)
.frame(height: 100)
.clipped()
.matchedGeometryEffect(id: photo.id, in: ns)
.onTapGesture {
withAnimation(.spring) { selectedID = photo.id }
}
}
}
}
}
}
}
@Namespace— a shared identifier scope for matched elementsmatchedGeometryEffect(id:in:)on source AND destination view- When state changes, SwiftUI interpolates position/size between the two views with the same
id - The “two” views need not exist simultaneously — one disappears, the other appears, SwiftUI animates the morph
This is the same primitive Apple uses for Photos app’s tap-to-expand, App Library card opens, etc.
Animatable properties — what can be interpolated
SwiftUI animates between values of types conforming to VectorArithmetic:
Double,CGFloat,IntCGPoint,CGSize,CGRectColor,Angle- Composites (
AnimatablePair,AnimatableVector)
For custom properties, conform to Animatable:
struct Wave: Shape {
var phase: Double
var animatableData: Double {
get { phase }
set { phase = newValue }
}
func path(in rect: CGRect) -> Path {
// ...
}
}
// Then:
Wave(phase: animating ? 2 * .pi : 0)
.animation(.linear(duration: 2).repeatForever(autoreverses: false), value: animating)
For two animatable properties:
struct ProgressArc: Shape {
var start: Double
var end: Double
var animatableData: AnimatablePair<Double, Double> {
get { AnimatablePair(start, end) }
set { start = newValue.first; end = newValue.second }
}
// ...
}
PhaseAnimator (iOS 17+) — multi-phase scripted animations
Cycle through phases, each with its own visual state:
enum WelcomePhase: CaseIterable {
case start, expand, settle
}
struct WelcomeBanner: View {
var body: some View {
PhaseAnimator(WelcomePhase.allCases, trigger: shouldAnimate) { phase in
Text("Welcome")
.font(.largeTitle)
.scaleEffect(phase == .start ? 0.5 : (phase == .expand ? 1.3 : 1.0))
.opacity(phase == .start ? 0 : 1)
} animation: { phase in
switch phase {
case .start: .easeOut(duration: 0)
case .expand: .spring(duration: 0.4)
case .settle: .spring(duration: 0.3)
}
}
}
}
- SwiftUI cycles through
phasesautomatically - For each phase, you define the visual state and the transition curve
- Re-triggered when
trigger:value changes
Useful for: launch screens, success animations, loading indicators, attention pulses.
KeyframeAnimator (iOS 17+) — complex keyframe sequences
When you need different properties animating on different schedules:
struct BouncyMessage: View {
var body: some View {
Image(systemName: "heart.fill")
.keyframeAnimator(initialValue: AnimationValues(), trigger: tapCount) { content, value in
content
.scaleEffect(value.scale)
.rotationEffect(value.rotation)
.offset(y: value.verticalOffset)
} keyframes: { _ in
KeyframeTrack(\.scale) {
CubicKeyframe(1.3, duration: 0.2)
SpringKeyframe(1.0, duration: 0.5)
}
KeyframeTrack(\.rotation) {
CubicKeyframe(.degrees(-10), duration: 0.15)
CubicKeyframe(.degrees(10), duration: 0.15)
SpringKeyframe(.degrees(0), duration: 0.4)
}
KeyframeTrack(\.verticalOffset) {
LinearKeyframe(-20, duration: 0.2)
SpringKeyframe(0, duration: 0.5)
}
}
}
}
struct AnimationValues {
var scale = 1.0
var rotation = Angle.zero
var verticalOffset = 0.0
}
- One animator value (
AnimationValues) holds all animated properties - Each
KeyframeTrackanimates one property along a sequence of keyframes CubicKeyframe,SpringKeyframe,LinearKeyframe,MoveKeyframe- Powerful for complex micro-interactions: notification arrivals, achievement unlocks, success states
Gesture-driven animations
struct Drag: View {
@State private var offset: CGSize = .zero
var body: some View {
Circle()
.fill(.blue)
.frame(width: 80, height: 80)
.offset(offset)
.gesture(
DragGesture()
.onChanged { offset = $0.translation }
.onEnded { _ in
withAnimation(.spring) { offset = .zero }
}
)
}
}
Direct gesture tracking (no animation) for the drag itself, then spring back on release. Common pattern for cards, sheets, swipe interactions.
Animation in lists (insertion/deletion)
List {
ForEach(items) { item in
Row(item: item)
.transition(.slide.combined(with: .opacity))
}
.onDelete { offsets in
withAnimation { items.remove(atOffsets: offsets) }
}
}
List animates inserts/removes automatically when wrapped in withAnimation. Custom transitions via .transition on ForEach children.
Reduce Motion accessibility
@Environment(\.accessibilityReduceMotion) var reduceMotion
var body: some View {
Image(systemName: "star.fill")
.scaleEffect(isPulsing ? 1.2 : 1.0)
.animation(reduceMotion ? nil : .spring.repeatForever(), value: isPulsing)
}
Always check accessibilityReduceMotion for long, repeating, or parallax animations. Respect it.
In the wild
- Apple Photos uses
matchedGeometryEffect(or its UIKit equivalent) for the tap-to-zoom transition. - Robinhood uses keyframe animations for the success state when an order fills — number scales, color flashes, haptic fires.
- Instagram Stories uses gesture-driven progressive spring animations for the swipe-down-to-dismiss gesture.
- Lyft uses
PhaseAnimator(or similar pre-iOS 17 hacks) for the driver-arriving sequence — pulse, scale, slide. - Airbnb uses subtle spring animations on every primary interaction; their internal design system enforces a small set of spring presets.
Common misconceptions
- “Use
withAnimationeverywhere.” Overusing it animates state that shouldn’t visually transition (e.g., loading state replacing content). Be intentional. - “
.animation(_:)(1-arg) is deprecated for no reason.” It’s deprecated because it animated everything changing, often unintentionally. Use the value-bound.animation(_:value:). - “Springs are slower than ease curves.” Modern springs (iOS 17 presets) feel faster than ease curves because they decelerate naturally. Designers prefer them for direct-manipulation UI.
- “
matchedGeometryEffectonly works for moving views.” It also works for morphing (different sizes/shapes). The two views can be completely different — only id and namespace match. - “Custom
Animatableis rare.” It’s surprisingly common for custom shapes, charts, and progress indicators. Worth knowing the protocol.
Seasoned engineer’s take
Define your animation vocabulary once and reuse it. A typical app has:
.spring(duration: 0.35, bounce: 0.2)for primary interactions (taps, navigation).smoothor.easeOut(duration: 0.25)for content fades- A single “success” keyframe animator for confirmation states
Reduce Motionoverrides
Then every screen looks consistent. Without this, animations drift — one engineer uses .spring, another .easeInOut(duration: 0.3), a third hand-tunes for “feel” — and the app feels disjointed.
For complex sequences (multi-step success animations, onboarding), reach for PhaseAnimator or KeyframeAnimator. They’re more readable than chained DispatchQueue.main.asyncAfter(deadline:) with withAnimation.
Avoid implicit .animation(_:value:) for animations triggered by user gestures — explicit withAnimation at the gesture’s end is cleaner. Implicit animations are for data-driven changes (state updated from network, model mutation).
TIP: When debugging animations, slow time globally: enable “Slow Animations” in iOS Simulator (Debug menu) or “Slow Animations” in the Simulator app’s Window menu. You’ll see what’s actually happening.
WARNING:
animation(.repeatForever())does not stop when the view leaves the screen — it continues consuming CPU. Pair with a state that disables the animation when not needed, or use.task { try? await Task.sleep(...) }for time-bounded effects.
Interview corner
Junior-level: “What’s the difference between implicit and explicit animations?”
Implicit: .animation(_:value:) modifier — SwiftUI animates property changes triggered by changes to the bound value. Explicit: withAnimation { state.x = newValue } — SwiftUI animates any observable changes inside the closure. Explicit is more controlled (you choose when); implicit is more declarative (the view describes when it animates).
Mid-level: “Implement a smooth thumbnail-to-fullscreen transition for a photo gallery.”
@Namespace + matchedGeometryEffect. Both the thumbnail in the grid and the fullscreen view declare the same matchedGeometryEffect(id: photo.id, in: namespace). Wrap the state change that toggles between them in withAnimation(.spring). SwiftUI interpolates position and size between the two declared geometries. The two views can use entirely different child content; only the matched geometry animates.
Senior-level: “Design the animation system for a fintech app — what’s reusable, what’s per-screen, and how do you enforce consistency?”
Reusable layer:
- A
Motionnamespace with named animations:.appPrimary(spring, 0.35s, bounce 0.2),.appFade(easeOut, 0.2s),.appBouncy(bouncy preset),.appAttention(custom keyframe sequence for success). Engineers reference these by name, never construct ad-hoc. - A
Transitionsnamespace with named transitions:.appCard(asymmetric scale+opacity),.appSheet,.appBadge. - A
MotionTokensstruct in the design system package. - Custom
ViewModifiers for “successFlash”, “errorShake”, “loadingPulse” — reusable visual feedback. - A
MatchedGeometryhelper that pairs source/destination with consistent namespacing.
Enforcement:
- Lint rule: ban literal
.animation(.spring(...))outside theMotionnamespace. - Code review checklist: any animation requires named motion token or design review.
- Audit screen for
Reduce Motioncompliance before ship.
Per-screen:
- Onboarding:
PhaseAnimatorsequences, longer durations OK. - Trade execution success:
KeyframeAnimatorcelebrating fill with scale/color/haptic. - List item enter/exit: standard transitions, fast (200ms max — long list animations are jarring).
Red flag in candidates: Hand-tuned .animation(.easeInOut(duration: 0.347)) everywhere. Indicates no system thinking.
Lab preview
Lab 5.2 combines Canvas, PhaseAnimator, matchedGeometryEffect, and KeyframeAnimator to build a chart dashboard with bar entry animation, value-change keyframes, and tap-to-expand detail cards.