BS
BleepingSwift
Published on
8 min read
Intermediate

> SwiftUI Animations: From Basics to KeyframeAnimator

Share:

// What_You_Will_Learn

  • Choose between implicit and explicit animations for different use cases
  • Use spring animations with the right presets for natural-feeling motion
  • Build multi-step animations with PhaseAnimator
  • Create choreographed effects with KeyframeAnimator

SwiftUI makes animation surprisingly approachable. Add a single modifier and your view springs to life. But the framework goes much deeper than basic transitions, with APIs for multi-step sequences, independent property timelines, and physics-based springs that feel natural under your fingers.

Implicit vs. Explicit Animations

SwiftUI offers two ways to animate changes. Implicit animations attach directly to a view with the .animation modifier:

Swift
struct PulsingDot: View {
    @State private var isActive = false

    var body: some View {
        Circle()
            .fill(isActive ? Color.green : Color.gray)
            .frame(width: 20, height: 20)
            .scaleEffect(isActive ? 1.2 : 1.0)
            .animation(.easeInOut(duration: 0.3), value: isActive)
            .onTapGesture {
                isActive.toggle()
            }
    }
}

The value parameter is required and scopes the animation to changes in that specific value. The older form without value is deprecated because it caused unpredictable behavior when multiple state changes happened simultaneously.

Explicit animations wrap a state change with withAnimation, giving you control over exactly which mutation triggers the animation:

Swift
struct CardFlip: View {
    @State private var isFlipped = false

    var body: some View {
        RoundedRectangle(cornerRadius: 16)
            .fill(isFlipped ? Color.blue : Color.orange)
            .frame(width: 200, height: 300)
            .rotation3DEffect(
                .degrees(isFlipped ? 180 : 0),
                axis: (x: 0, y: 1, z: 0)
            )
            .onTapGesture {
                withAnimation(.spring(duration: 0.6, bounce: 0.3)) {
                    isFlipped.toggle()
                }
            }
    }
}

Use implicit animations when a view should always animate a particular property change. Use withAnimation when you want to coordinate multiple state changes or when the same property should sometimes animate and sometimes not.

Spring Animations

Apple recommends springs as the default animation type because they produce natural-feeling motion. Since iOS 17, the spring API is straightforward:

Swift
.animation(.spring(duration: 0.5, bounce: 0.3), value: isExpanded)

The duration controls how long the spring takes to settle, and bounce ranges from 0 (no overshoot) to 1 (maximum bounciness). SwiftUI also provides three convenient presets:

Swift
struct SpringComparison: View {
    @State private var offset: CGFloat = 0

    var body: some View {
        VStack(spacing: 40) {
            Circle()
                .frame(width: 40, height: 40)
                .offset(x: offset)
                .animation(.smooth, value: offset)

            Circle()
                .frame(width: 40, height: 40)
                .offset(x: offset)
                .animation(.snappy, value: offset)

            Circle()
                .frame(width: 40, height: 40)
                .offset(x: offset)
                .animation(.bouncy, value: offset)
        }
        .onTapGesture {
            offset = offset == 0 ? 100 : 0
        }
    }
}

.smooth has no bounce and settles quickly. .snappy has a tiny bit of overshoot for a responsive feel. .bouncy has noticeable overshoot for playful interactions. For most UI animations, .snappy or .smooth is the right choice.

Animation Completion Callbacks

iOS 17 added completion handlers to withAnimation, which is useful for chaining animations or triggering follow-up logic:

Swift
withAnimation(.spring(duration: 0.4)) {
    cardOffset = 300
} completion: {
    withAnimation(.spring(duration: 0.3)) {
        cardOffset = 0
        showConfirmation = true
    }
}

This replaces the old pattern of using DispatchQueue.main.asyncAfter to approximate animation timing, which was fragile and could fall out of sync.

Custom Transitions

SwiftUI provides built-in transitions like .opacity, .slide, .scale, and .move(edge:) for views that appear and disappear:

Swift
struct NotificationBanner: View {
    @State private var showBanner = false

    var body: some View {
        VStack {
            if showBanner {
                Text("Saved successfully")
                    .padding()
                    .background(Color.green.opacity(0.9))
                    .cornerRadius(10)
                    .transition(.move(edge: .top).combined(with: .opacity))
            }

            Spacer()

            Button("Save") {
                withAnimation(.spring(duration: 0.4, bounce: 0.2)) {
                    showBanner = true
                }
                DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
                    withAnimation {
                        showBanner = false
                    }
                }
            }
        }
    }
}

For more control, the Transition protocol (iOS 17+) lets you build custom transitions using view modifiers and TransitionPhase:

Swift
struct SlideAndFade: Transition {
    func body(content: Content, phase: TransitionPhase) -> some View {
        content
            .opacity(phase.isIdentity ? 1 : 0)
            .offset(y: phase == .willAppear ? -20 : phase == .didDisappear ? 20 : 0)
            .scaleEffect(phase.isIdentity ? 1 : 0.95)
    }
}

// Usage
if showCard {
    CardView()
        .transition(SlideAndFade())
}

matchedGeometryEffect

For hero-style transitions where one view transforms into another, matchedGeometryEffect synchronizes the geometry between two views:

Swift
struct HeroTransition: View {
    @Namespace private var animation
    @State private var isExpanded = false

    var body: some View {
        VStack {
            if isExpanded {
                RoundedRectangle(cornerRadius: 20)
                    .fill(Color.blue)
                    .matchedGeometryEffect(id: "card", in: animation)
                    .frame(height: 400)
                    .onTapGesture {
                        withAnimation(.spring(duration: 0.5, bounce: 0.2)) {
                            isExpanded = false
                        }
                    }
            } else {
                RoundedRectangle(cornerRadius: 12)
                    .fill(Color.blue)
                    .matchedGeometryEffect(id: "card", in: animation)
                    .frame(width: 150, height: 100)
                    .onTapGesture {
                        withAnimation(.spring(duration: 0.5, bounce: 0.2)) {
                            isExpanded = true
                        }
                    }
            }
        }
    }
}

The @Namespace provides a coordination space, and matching id values on both views tells SwiftUI to interpolate between their frames during the transition.

PhaseAnimator

PhaseAnimator (iOS 17+) drives multi-step animation sequences by cycling through a collection of phases. Each phase defines a different visual state, and SwiftUI animates between them automatically:

Swift
enum BouncePhase: CaseIterable {
    case rest, up, down

    var yOffset: Double {
        switch self {
        case .rest: 0
        case .up: -30
        case .down: 5
        }
    }

    var scale: Double {
        switch self {
        case .rest: 1.0
        case .up: 1.1
        case .down: 0.95
        }
    }
}

struct BouncingEmoji: View {
    @State private var trigger = 0

    var body: some View {
        Text("🏀")
            .font(.system(size: 60))
            .phaseAnimator(BouncePhase.allCases, trigger: trigger) { content, phase in
                content
                    .offset(y: phase.yOffset)
                    .scaleEffect(phase.scale)
            } animation: { phase in
                switch phase {
                case .rest: .spring(duration: 0.4, bounce: 0.5)
                case .up: .easeOut(duration: 0.2)
                case .down: .spring(duration: 0.3, bounce: 0.6)
                }
            }
            .onTapGesture { trigger += 1 }
    }
}

Without a trigger, the animator loops continuously, which is useful for loading indicators or attention-grabbing effects. With a trigger, it plays through all phases once each time the trigger value changes.

KeyframeAnimator

KeyframeAnimator (iOS 17+) provides timeline-based control where each property animates independently on its own track. This is the tool for complex, choreographed animations:

Swift
struct ShakeValues {
    var xOffset: Double = 0
    var rotation: Double = 0
    var scale: Double = 1.0
}

struct ShakeButton: View {
    @State private var trigger = 0

    var body: some View {
        Button("Submit") {
            trigger += 1
        }
        .padding(.horizontal, 32)
        .padding(.vertical, 12)
        .background(Color.red)
        .foregroundColor(.white)
        .cornerRadius(10)
        .keyframeAnimator(initialValue: ShakeValues(), trigger: trigger) { content, value in
            content
                .offset(x: value.xOffset)
                .rotationEffect(.degrees(value.rotation))
                .scaleEffect(value.scale)
        } keyframes: { _ in
            KeyframeTrack(\.xOffset) {
                SpringKeyframe(-10, duration: 0.1, spring: .snappy)
                SpringKeyframe(10, duration: 0.1, spring: .snappy)
                SpringKeyframe(-6, duration: 0.1, spring: .snappy)
                SpringKeyframe(6, duration: 0.1, spring: .snappy)
                SpringKeyframe(0, duration: 0.15, spring: .bouncy)
            }
            KeyframeTrack(\.rotation) {
                CubicKeyframe(-3, duration: 0.1)
                CubicKeyframe(3, duration: 0.1)
                CubicKeyframe(-2, duration: 0.1)
                CubicKeyframe(2, duration: 0.1)
                CubicKeyframe(0, duration: 0.15)
            }
            KeyframeTrack(\.scale) {
                CubicKeyframe(0.95, duration: 0.05)
                CubicKeyframe(1.05, duration: 0.2)
                SpringKeyframe(1.0, duration: 0.3, spring: .bouncy)
            }
        }
    }
}

Each KeyframeTrack targets a property on the values struct. The keyframe types control interpolation: CubicKeyframe for smooth Bezier curves, SpringKeyframe for physics-based motion, LinearKeyframe for constant-speed transitions, and MoveKeyframe for instant jumps without animation.

The key difference from PhaseAnimator is that properties animate independently. The x offset might be bouncing back and forth while the scale is still settling, creating layered, organic-feeling motion.

Animating Numeric Text

For counters, scores, or any changing numbers, contentTransition(.numericText()) produces smooth digit transitions:

Swift
struct ScoreView: View {
    @State private var score = 0

    var body: some View {
        VStack {
            Text("\(score)")
                .font(.system(size: 48, weight: .bold, design: .rounded))
                .contentTransition(.numericText(value: Double(score)))

            Button("Add Point") {
                withAnimation(.spring(duration: 0.3)) {
                    score += 1
                }
            }
        }
    }
}

Individual digits roll in and out as the number changes, giving a polished feel without any custom animation code.

Choosing the Right API

For a simple toggle or state change, use withAnimation or .animation(_:value:). These cover the vast majority of animation needs in typical apps.

For multi-step sequences where all properties change together between states, use PhaseAnimator. Think loading indicators, celebration effects, or attention pulses.

For choreographed animations where different properties need independent timing, use KeyframeAnimator. This is your tool for complex motion design like error shakes, onboarding sequences, or game-like effects.

For view insertions and removals, use transition() with the built-in options or the Transition protocol for custom effects.

For hero-style morphing between views, use matchedGeometryEffect with a shared @Namespace.

The animation system in SwiftUI is layered. Start with the simplest API that does what you need, and reach for the more powerful tools when you need independent control over timing and properties.

// Frequently_Asked

subscribe.sh

// Stay Updated

Get notified when I publish new tutorials on Swift, SwiftUI, and iOS development. No spam, unsubscribe anytime.

>

By subscribing, you agree to our Privacy Policy.