Back to Provenance

SwiftUI Advanced Animations

.agents/skills/swiftui-expert-skill/references/animation-advanced.md

3.3.110.1 KB
Original Source

SwiftUI Advanced Animations

Transactions, phase animations (iOS 17+), keyframe animations (iOS 17+), completion handlers (iOS 17+), and @Animatable macro (iOS 26+).

Table of Contents


Transactions

The underlying mechanism for all animations in SwiftUI.

Basic Usage

swift
// withAnimation is shorthand for withTransaction
withAnimation(.default) { flag.toggle() }

// Equivalent explicit transaction
var transaction = Transaction(animation: .default)
withTransaction(transaction) { flag.toggle() }

The .transaction Modifier

swift
Rectangle()
    .frame(width: flag ? 100 : 50, height: 50)
    .transaction { t in
        t.animation = .default
    }

Note: This behaves like the deprecated .animation(_:) without value parameter - it animates on every state change.

Animation Precedence

Implicit animations override explicit animations (later in view tree wins).

swift
Button("Tap") {
    withAnimation(.linear) { flag.toggle() }
}
.animation(.bouncy, value: flag)  // .bouncy wins!

Disabling Animations

swift
// Prevent implicit animations from overriding
.transaction { t in
    t.disablesAnimations = true
}

// Remove animation entirely
.transaction { $0.animation = nil }

Custom Transaction Keys (iOS 17+)

Pass metadata through transactions.

swift
struct ChangeSourceKey: TransactionKey {
    static let defaultValue: String = "unknown"
}

extension Transaction {
    var changeSource: String {
        get { self[ChangeSourceKey.self] }
        set { self[ChangeSourceKey.self] = newValue }
    }
}

// Set source
var transaction = Transaction(animation: .default)
transaction.changeSource = "server"
withTransaction(transaction) { flag.toggle() }

// Read in view tree
.transaction { t in
    if t.changeSource == "server" {
        t.animation = .smooth
    } else {
        t.animation = .bouncy
    }
}

Phase Animations (iOS 17+)

Cycle through discrete phases automatically. Each phase change is a separate animation.

Basic Usage

swift
// GOOD - triggered phase animation
Button("Shake") { trigger += 1 }
    .phaseAnimator(
        [0.0, -10.0, 10.0, -5.0, 5.0, 0.0],
        trigger: trigger
    ) { content, offset in
        content.offset(x: offset)
    }

// Infinite loop (no trigger)
Circle()
    .phaseAnimator([1.0, 1.2, 1.0]) { content, scale in
        content.scaleEffect(scale)
    }
swift
// GOOD - enum phases are self-documenting
enum BouncePhase: CaseIterable {
    case initial, up, down, settle

    var scale: CGFloat {
        switch self {
        case .initial: 1.0
        case .up: 1.2
        case .down: 0.9
        case .settle: 1.0
        }
    }
}

Circle()
    .phaseAnimator(BouncePhase.allCases, trigger: trigger) { content, phase in
        content.scaleEffect(phase.scale)
    }

Custom Timing Per Phase

swift
.phaseAnimator([0, -20, 20], trigger: trigger) { content, offset in
    content.offset(x: offset)
} animation: { phase in
    switch phase {
    case -20: .bouncy
    case 20: .linear
    default: .smooth
    }
}

Good vs Bad

swift
// GOOD - use phaseAnimator for multi-step sequences
.phaseAnimator([0, -10, 10, 0], trigger: trigger) { content, offset in
    content.offset(x: offset)
}

// BAD - manual DispatchQueue sequencing
Button("Animate") {
    withAnimation(.easeOut(duration: 0.1)) { offset = -10 }
    DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
        withAnimation { offset = 10 }
    }
    DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
        withAnimation { offset = 0 }
    }
}

Keyframe Animations (iOS 17+)

Precise timing control with exact values at specific times.

Basic Usage

swift
Button("Bounce") { trigger += 1 }
    .keyframeAnimator(
        initialValue: AnimationValues(),
        trigger: trigger
    ) { content, value in
        content
            .scaleEffect(value.scale)
            .offset(y: value.verticalOffset)
    } keyframes: { _ in
        KeyframeTrack(\.scale) {
            SpringKeyframe(1.2, duration: 0.15)
            SpringKeyframe(0.9, duration: 0.1)
            SpringKeyframe(1.0, duration: 0.15)
        }
        KeyframeTrack(\.verticalOffset) {
            LinearKeyframe(-20, duration: 0.15)
            LinearKeyframe(0, duration: 0.25)
        }
    }

struct AnimationValues {
    var scale: CGFloat = 1.0
    var verticalOffset: CGFloat = 0
}

Keyframe Types

TypeBehavior
CubicKeyframeSmooth interpolation
LinearKeyframeStraight-line interpolation
SpringKeyframeSpring physics
MoveKeyframeInstant jump (no interpolation)

Multiple Synchronized Tracks

Tracks run in parallel, each animating one property.

swift
// GOOD - bell shake with synchronized rotation and scale
struct BellAnimation {
    var rotation: Double = 0
    var scale: CGFloat = 1.0
}

Image(systemName: "bell.fill")
    .keyframeAnimator(
        initialValue: BellAnimation(),
        trigger: trigger
    ) { content, value in
        content
            .rotationEffect(.degrees(value.rotation))
            .scaleEffect(value.scale)
    } keyframes: { _ in
        KeyframeTrack(\.rotation) {
            CubicKeyframe(15, duration: 0.1)
            CubicKeyframe(-15, duration: 0.1)
            CubicKeyframe(10, duration: 0.1)
            CubicKeyframe(-10, duration: 0.1)
            CubicKeyframe(0, duration: 0.1)
        }
        KeyframeTrack(\.scale) {
            CubicKeyframe(1.1, duration: 0.25)
            CubicKeyframe(1.0, duration: 0.25)
        }
    }

// BAD - manual timer-based animation
Image(systemName: "bell.fill")
    .onTapGesture {
        withAnimation(.easeOut(duration: 0.1)) { rotation = 15 }
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
            withAnimation { rotation = -15 }
        }
        // ... more manual timing - error prone
    }

KeyframeTimeline (iOS 17+)

Query animation values directly for testing or non-SwiftUI use.

swift
let timeline = KeyframeTimeline(initialValue: AnimationValues()) {
    KeyframeTrack(\.scale) {
        CubicKeyframe(1.2, duration: 0.25)
        CubicKeyframe(1.0, duration: 0.25)
    }
}

let midpoint = timeline.value(time: 0.25)
print(midpoint.scale)  // Value at 0.25 seconds

Animation Completion Handlers (iOS 17+)

Execute code when animations finish.

With withAnimation

swift
// GOOD - completion with withAnimation
Button("Animate") {
    withAnimation(.spring) {
        isExpanded.toggle()
    } completion: {
        showNextStep = true
    }
}

With Transaction (For Reexecution)

swift
// GOOD - completion fires on every trigger change
Circle()
    .scaleEffect(bounceCount % 2 == 0 ? 1.0 : 1.2)
    .transaction(value: bounceCount) { transaction in
        transaction.animation = .spring
        transaction.addAnimationCompletion {
            message = "Bounce \(bounceCount) complete"
        }
    }

// BAD - completion only fires ONCE (no value parameter)
Circle()
    .scaleEffect(bounceCount % 2 == 0 ? 1.0 : 1.2)
    .animation(.spring, value: bounceCount)
    .transaction { transaction in  // No value!
        transaction.addAnimationCompletion {
            completionCount += 1  // Only fires once, ever
        }
    }

@Animatable Macro (iOS 26+)

The @Animatable macro auto-synthesizes animatableData from all animatable stored properties, eliminating verbose manual conformance. Use @AnimatableIgnored to exclude properties that should not animate.

Before (Manual)

swift
struct Wedge: Shape {
    var startAngle: Angle
    var endAngle: Angle
    var drawClockwise: Bool

    var animatableData: AnimatablePair<Double, Double> {
        get { AnimatablePair(startAngle.radians, endAngle.radians) }
        set {
            startAngle = .radians(newValue.first)
            endAngle = .radians(newValue.second)
        }
    }

    func path(in rect: CGRect) -> Path { /* ... */ }
}

After (@Animatable)

swift
@Animatable
struct Wedge: Shape {
    var startAngle: Angle
    var endAngle: Angle
    @AnimatableIgnored var drawClockwise: Bool

    func path(in rect: CGRect) -> Path { /* ... */ }
}

When to Use

  • Prefer @Animatable for any custom Shape, AnimatableModifier, or type conforming to Animatable with multiple properties
  • Use @AnimatableIgnored for properties that control behavior but should not interpolate (e.g., directions, flags, identifiers)
  • The macro works with any type conforming to Animatable, not just Shape

Source: "What's new in SwiftUI" (WWDC25, session 256)


Quick Reference

Transactions (All iOS versions)

  • withTransaction is the explicit form of withAnimation
  • Implicit animations override explicit (later in view tree wins)
  • Use disablesAnimations to prevent override
  • Use .transaction { $0.animation = nil } to remove animation

Custom Transaction Keys (iOS 17+)

  • Pass metadata through animation system via TransactionKey

Phase Animations (iOS 17+)

  • Use for multi-step sequences returning to start
  • Prefer enum phases for clarity
  • Each phase change is a separate animation
  • Use trigger parameter for one-shot animations

Keyframe Animations (iOS 17+)

  • Use for precise timing control
  • Tracks run in parallel
  • Use KeyframeTimeline for testing/advanced use
  • Prefer over manual DispatchQueue timing

Completion Handlers (iOS 17+)

  • Use withAnimation(.animation) { } completion: { } for one-shot completion handlers
  • Use .transaction(value:) for handlers that should refire on every value change
  • Without value: parameter, completion only fires once

@Animatable Macro (iOS 26+)

  • Use @Animatable to auto-synthesize animatableData from stored properties
  • Use @AnimatableIgnored to exclude non-animatable properties
  • Replaces verbose manual animatableData getters/setters