SwiftUI Animations: Implicit, Explicit, Matched Geometry, and Custom Transitions


The difference between a good app and a great app is often animation. SwiftUI makes basic animations trivial — a single .animation modifier can bring a view to life — but mastering matchedGeometryEffect, PhaseAnimator, and custom transitions is what turns your UI into something Pixar would be proud of.

This guide covers every animation layer SwiftUI provides, from the simplest implicit spring to keyframe-driven sequences. We won’t cover Canvas drawing or TimelineView for game-loop animations — those warrant their own post.

Contents

The Problem

Consider a Pixar film browser where tapping a movie poster should expand it into a detail view. Without animation, the poster snaps into place — jarring and disconnected. Selections have no press feedback. New items appear instantly with no spatial context.

Compare that to what a polished implementation feels like: the poster smoothly scales into the detail hero image, new films slide in from the trailing edge, and selection highlights spring with satisfying physics. The data model hasn’t changed — only the motion communicates the spatial relationship between states.

Getting there requires understanding which animation tool SwiftUI provides for each scenario, because using the wrong one produces either no animation or animations that fight each other.

Implicit Animations

Apple Docs: animation(_:value:) — SwiftUI

animation(_:value:) is attached to a view and fires whenever value changes. SwiftUI interpolates all animatable properties on that view between the old and new value.

struct FilmPosterCard: View {
    let film: PixarFilm
    var isSelected: Bool

    var body: some View {
        FilmPoster(film: film)
            .scaleEffect(isSelected ? 1.05 : 1.0)
            .shadow(radius: isSelected ? 12 : 4)
            .animation(
                .spring(response: 0.4, dampingFraction: 0.7),
                value: isSelected
            )
    }
}

When isSelected toggles from false to true, the scale and shadow both animate using spring physics. The key detail is the value: parameter — without it, the deprecated zero-argument form animates every state change on the view, which causes cascading animation interference.

Spring Presets

SwiftUI 5.0 (iOS 17+) introduced ergonomic spring presets alongside the underlying spring(response:dampingFraction:):

// Bouncy spring — great for playful UI like Pixar character cards
.animation(.bouncy, value: isExpanded)

// Smooth spring — subtle, suitable for navigation transitions
.animation(.smooth, value: isLoading)

// Snappy spring — quick and responsive, good for selections
.animation(.snappy, value: isSelected)

// Full control when presets don't fit
.animation(.spring(response: 0.45, dampingFraction: 0.65), value: isSelected)

Note: On iOS 16 and earlier, use spring(response:dampingFraction:) directly. The .bouncy, .smooth, and .snappy presets require iOS 17+.

Explicit Animations with withAnimation

Apple Docs: withAnimation(_:_:) — SwiftUI

withAnimation is the right tool when you need to control exactly when an animation fires — typically inside a gesture handler or after an async operation completes. All state changes inside the closure are animated together.

struct FilmBrowserView: View {
    @State private var selectedFilm: PixarFilm?
    @State private var showDetail = false

    var body: some View {
        FilmGrid(films: catalog) { film in
            withAnimation(.spring(response: 0.4, dampingFraction: 0.75)) {
                selectedFilm = film
                showDetail = true
            }
        }
    }
}

Both selectedFilm and showDetail change atomically inside the animation transaction. SwiftUI computes a single diff and applies one unified animation — you won’t see the two state changes animate at different times.

Completion Handlers

iOS 17 added completion handlers to withAnimation, which lets you chain sequential animations or execute code after a transition finishes:

@available(iOS 17.0, *)
withAnimation(.easeOut(duration: 0.3)) {
    isExiting = true
} completion: {
    // Called on the MainActor after the animation completes
    films.remove(at: selectedIndex)
    isExiting = false
}

Warning: The completion closure fires on the main actor after the animation’s natural duration. If the animation is interrupted mid-flight, the completion may not be called. For critical sequencing, consider using Task with try await Task.sleep instead.

Transitions

Apple Docs: transition(_:) — SwiftUI

A transition defines how a view enters and exits the view hierarchy when it’s conditionally inserted or removed. Without a transition, views pop in and out instantly.

// Combine built-in transitions for richer motion
if showAwardBadge {
    AwardBadge()
        .transition(.scale.combined(with: .opacity))
}

Custom AnyTransition

For asymmetric transitions — different animations for insertion vs. removal — use AnyTransition.asymmetric:

extension AnyTransition {
    static var filmSlideIn: AnyTransition {
        .asymmetric(
            insertion: .move(edge: .trailing).combined(with: .opacity),
            removal: .move(edge: .leading).combined(with: .opacity)
        )
    }
}

// Usage: new films slide in from the right, old ones exit left
FilmDetailView(film: selectedFilm)
    .transition(.filmSlideIn)

Tip: Transitions only fire when a view is inserted into or removed from the SwiftUI view hierarchy — not when its content updates. If your view stays in the tree but changes content, use implicit animations instead.

matchedGeometryEffect

Apple Docs: matchedGeometryEffect(id:in:properties:anchor:isSource:) — SwiftUI

matchedGeometryEffect is SwiftUI’s hero animation primitive. Declare a shared Namespace, attach the same id to a view in two different locations in the hierarchy, and SwiftUI automatically animates the frame transition between them.

This is the mechanism that makes a film poster in a grid feel like it “flies” into the full-screen detail view:

struct FilmBrowserView: View {
    @Namespace private var filmNamespace
    @State private var selectedFilm: PixarFilm?

    var body: some View {
        ZStack {
            // Film grid — the "source" views
            LazyVGrid(columns: [GridItem(.adaptive(minimum: 140))]) {
                ForEach(catalog) { film in
                    FilmPoster(film: film)
                        .matchedGeometryEffect(id: film.id, in: filmNamespace)
                        .onTapGesture {
                            withAnimation(.spring(response: 0.45, dampingFraction: 0.8)) {
                                selectedFilm = film
                            }
                        }
                }
            }
            .opacity(selectedFilm == nil ? 1 : 0)

            // Detail view — the "destination"
            if let film = selectedFilm {
                FilmDetailView(film: film, namespace: filmNamespace) {
                    withAnimation(.spring(response: 0.45, dampingFraction: 0.8)) {
                        selectedFilm = nil
                    }
                }
            }
        }
    }
}

struct FilmDetailView: View {
    let film: PixarFilm
    let namespace: Namespace.ID
    let onDismiss: () -> Void

    var body: some View {
        VStack {
            FilmPoster(film: film)
                // same id, same namespace — SwiftUI interpolates the frame
                .matchedGeometryEffect(id: film.id, in: namespace)
                .frame(maxWidth: .infinity)

            FilmMetadata(film: film)
            Spacer()

            Button("Back to Catalog", action: onDismiss)
        }
        .ignoresSafeArea(edges: .top)
    }
}

SwiftUI animates the frame, position, and size of the poster between its grid position and its detail position in a single fluid motion.

Warning: matchedGeometryEffect requires exactly one source view (isSource: true, the default) per id in the namespace at any given time. If two views with the same id are both visible, SwiftUI’s behavior is undefined. Use .opacity(0) to hide the non-active source rather than removing it from the hierarchy.

PhaseAnimator (iOS 17+)

Apple Docs: PhaseAnimator — SwiftUI

@available(iOS 17.0, *)

PhaseAnimator drives multi-step animation sequences. You provide an array of phases (any Equatable type works — even a Bool), and SwiftUI cycles through them, applying a different animation curve for each transition.

This example creates a looping “hat tip” animation on Woody’s hat icon:

@available(iOS 17.0, *)
struct WoodyHatBadge: View {
    var body: some View {
        PhaseAnimator([false, true]) { isTipped in
            Image(systemName: "hat.widebrim")
                .font(.largeTitle)
                .rotationEffect(isTipped ? .degrees(15) : .zero)
                .scaleEffect(isTipped ? 1.15 : 1.0)
                .foregroundStyle(isTipped ? .yellow : .brown)
        } animation: { phase in
            // Different curve for each phase transition
            phase ? .spring(bounce: 0.4) : .easeOut(duration: 0.25)
        }
    }
}

PhaseAnimator loops automatically. For a trigger-based (non-looping) version, use the trigger: parameter:

@available(iOS 17.0, *)
PhaseAnimator([0, 1, 2, 0], trigger: didAward) { phase in
    AwardStar()
        .scaleEffect(phase == 1 ? 1.4 : 1.0)
        .opacity(phase == 2 ? 0.0 : 1.0)
} animation: { _ in .spring(bounce: 0.5) }

KeyframeAnimator (iOS 17+)

Apple Docs: KeyframeAnimator — SwiftUI

@available(iOS 17.0, *)

KeyframeAnimator gives you precise, multi-property keyframe control — closer to Core Animation’s CAKeyframeAnimation than to SwiftUI’s interpolation model. You define a value type and describe how each property should move over time.

This example animates Buzz Lightyear’s launch sequence when a film is favorited:

@available(iOS 17.0, *)
struct BuzzLaunchButton: View {
    @State private var isLaunching = false

    var body: some View {
        KeyframeAnimator(
            initialValue: BuzzLaunchValues(),
            trigger: isLaunching
        ) { values in
            Image(systemName: "airplane")
                .font(.title)
                .scaleEffect(values.scale)
                .rotationEffect(values.rotation)
                .offset(y: values.verticalOffset)
        } keyframes: { _ in
            KeyframeTrack(\.scale) {
                LinearKeyframe(1.0, duration: 0.1)
                SpringKeyframe(1.3, duration: 0.2, spring: .bouncy)
                LinearKeyframe(1.3, duration: 0.15)
                SpringKeyframe(1.0, duration: 0.3, spring: .smooth)
            }
            KeyframeTrack(\.rotation) {
                LinearKeyframe(.zero, duration: 0.1)
                LinearKeyframe(.degrees(-15), duration: 0.25)
                LinearKeyframe(.degrees(15), duration: 0.25)
                LinearKeyframe(.zero, duration: 0.2)
            }
            KeyframeTrack(\.verticalOffset) {
                LinearKeyframe(0, duration: 0.1)
                SpringKeyframe(-40, duration: 0.4, spring: .bouncy)
                SpringKeyframe(0, duration: 0.3, spring: .smooth)
            }
        }
        .onTapGesture { isLaunching.toggle() }
    }
}

@available(iOS 17.0, *)
struct BuzzLaunchValues {
    var scale: Double = 1.0
    var rotation: Angle = .zero
    var verticalOffset: Double = 0
}

Each KeyframeTrack drives one property independently. SpringKeyframe, LinearKeyframe, and CubicKeyframe give you control over the interpolation curve between each keyframe.

Tip: KeyframeAnimator is best for one-shot, trigger-based animations where you need precise choreography. For looping decorative animations, PhaseAnimator is simpler. For continuous idle animations tied to a clock, use TimelineView.

Advanced Usage

GeometryEffect for Custom Path-Based Animations

Apple Docs: GeometryEffect — SwiftUI

GeometryEffect lets you define a custom ProjectionTransform that SwiftUI interpolates. Use it for animations that can’t be expressed with the standard modifiers — such as a film card that rotates around an anchor point:

struct ArcRotationEffect: GeometryEffect {
    var angle: Double // animatable

    var animatableData: Double {
        get { angle }
        set { angle = newValue }
    }

    func effectValue(size: CGSize) -> ProjectionTransform {
        let rotation = CGAffineTransform(rotationAngle: angle)
        let anchor = CGPoint(x: size.width / 2, y: size.height / 2)
        let translate = CGAffineTransform(
            translationX: anchor.x,
            y: anchor.y
        )
        let combined = translate
            .concatenating(rotation)
            .concatenating(translate.inverted())
        return ProjectionTransform(combined)
    }
}

animatableData for Custom Animatable Types

Any type conforming to Animatable can be smoothly interpolated by SwiftUI. For a PixarFilm rating bar:

struct RatingBarShape: Shape {
    var fillFraction: Double

    var animatableData: Double {
        get { fillFraction }
        set { fillFraction = newValue }
    }

    func path(in rect: CGRect) -> Path {
        Path(
            CGRect(
                x: rect.minX,
                y: rect.minY,
                width: rect.width * fillFraction,
                height: rect.height
            )
        )
    }
}

// Animates the bar fill smoothly when rating changes
RatingBarShape(fillFraction: film.audienceScore)
    .animation(.spring(), value: film.audienceScore)

Performance Considerations

Animation performance problems are usually caused by one of three things: expensive view bodies running on the main thread per frame, complex overlapping transparent views forcing GPU compositing, or using the wrong animation primitive for the job.

drawingGroup() for complex overlapping views. When you have many overlapping transparent views — a particle effect for Pixar character confetti, for example — attaching drawingGroup() flattens the subtree into a single Metal-backed texture before compositing:

ConfettiView(characters: ["🤠", "🚀", "🐟", "🦕"])
    .drawingGroup() // renders to an offscreen Metal texture, then composites once

Warning: drawingGroup() rasterizes the content at the current size. Views inside a drawingGroup that scale or zoom will appear pixelated. Only apply it to views where the content size is stable.

Avoid animating layout-affecting properties. Animating frame, padding, or anything that triggers a layout pass is significantly more expensive than animating scaleEffect, opacity, or offset, which are resolved in the render layer without a layout pass.

Profile with Instruments. The SwiftUI Instruments template shows view body evaluation counts and helps identify views that re-evaluate every animation frame. See SwiftUI Performance: Identifying and Fixing Unnecessary View Redraws for the full profiling workflow.

When to Use (and When Not To)

ScenarioRecommendation
A view property changes in response to stateImplicit .animation(_:value:)
Multiple state changes must animate togetherwithAnimation { }
A view enters or leaves the hierarchy.transition()
A list item expands into a detail viewmatchedGeometryEffect
A looping decorative animation with 2–4 phasesPhaseAnimator
Choreographed multi-property animation sequenceKeyframeAnimator
Continuous animation tied to a clock or playheadTimelineView
Custom non-affine transform during animationGeometryEffect
Many overlapping transparent views with animationdrawingGroup()
Simple opacity toggle that preserves view identity.opacity() + implicit

Summary

  • Implicit animations (.animation(_:value:)) are the default tool — attach them close to the view and always provide a value.
  • withAnimation { } is for coordinating multiple simultaneous state changes into a single animation transaction.
  • Transitions describe insertion/removal motion; use .asymmetric for directional push/pop animations.
  • matchedGeometryEffect is SwiftUI’s hero animation primitive — share a Namespace between list and detail to get frame interpolation for free.
  • PhaseAnimator (iOS 17+) is ideal for looping multi-step decorative animations; KeyframeAnimator (iOS 17+) gives precise per-property keyframe control for one-shot sequences.
  • Use drawingGroup() to offload complex overlapping view compositing to Metal, and always animate transform properties over layout properties.

With your animations polished, the next concern is keeping them smooth as the view tree scales. Check out SwiftUI Performance: Identifying and Fixing Unnecessary View Redraws to learn how to eliminate the unnecessary redraws that cause animation stutter.