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.

ConceptUse for
Implicit animation (.animation(_:value:))Animate a specific value’s changes
Explicit animation (withAnimation { })Animate a state mutation block
Transition (.transition(_:))Animate insertion/removal
matchedGeometryEffectAnimate elements moving between layouts
PhaseAnimatorMulti-phase scripted animations
KeyframeAnimatorComplex keyframe-based animations
Custom AnimatableDataAnimate 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 “when value changes, 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 elements
  • matchedGeometryEffect(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, Int
  • CGPoint, CGSize, CGRect
  • Color, 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 phases automatically
  • 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 KeyframeTrack animates 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

  1. “Use withAnimation everywhere.” Overusing it animates state that shouldn’t visually transition (e.g., loading state replacing content). Be intentional.
  2. .animation(_:) (1-arg) is deprecated for no reason.” It’s deprecated because it animated everything changing, often unintentionally. Use the value-bound .animation(_:value:).
  3. “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.
  4. matchedGeometryEffect only works for moving views.” It also works for morphing (different sizes/shapes). The two views can be completely different — only id and namespace match.
  5. “Custom Animatable is 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)
  • .smooth or .easeOut(duration: 0.25) for content fades
  • A single “success” keyframe animator for confirmation states
  • Reduce Motion overrides

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 Motion namespace 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 Transitions namespace with named transitions: .appCard (asymmetric scale+opacity), .appSheet, .appBadge.
  • A MotionTokens struct in the design system package.
  • Custom ViewModifiers for “successFlash”, “errorShake”, “loadingPulse” — reusable visual feedback.
  • A MatchedGeometry helper that pairs source/destination with consistent namespacing.

Enforcement:

  • Lint rule: ban literal .animation(.spring(...)) outside the Motion namespace.
  • Code review checklist: any animation requires named motion token or design review.
  • Audit screen for Reduce Motion compliance before ship.

Per-screen:

  • Onboarding: PhaseAnimator sequences, longer durations OK.
  • Trade execution success: KeyframeAnimator celebrating 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.


Next: Custom views & ViewModifiers