SwiftUI Advanced Animations
SwiftUI Advanced Animations
Section titled “SwiftUI Advanced Animations”Transactions, phase animations (iOS 17+), keyframe animations (iOS 17+), completion handlers (iOS 17+), and @Animatable macro (iOS 26+).
Table of Contents
Section titled “Table of Contents”- Transactions
- Phase Animations (iOS 17+)
- Keyframe Animations (iOS 17+)
- Animation Completion Handlers (iOS 17+)
- @Animatable Macro (iOS 26+)
Transactions
Section titled “Transactions”The underlying mechanism for all animations in SwiftUI.
Basic Usage
Section titled “Basic Usage”// withAnimation is shorthand for withTransactionwithAnimation(.default) { flag.toggle() }
// Equivalent explicit transactionvar transaction = Transaction(animation: .default)withTransaction(transaction) { flag.toggle() }The .transaction Modifier
Section titled “The .transaction Modifier”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
Section titled “Animation Precedence”Implicit animations override explicit animations (later in view tree wins).
Button("Tap") { withAnimation(.linear) { flag.toggle() }}.animation(.bouncy, value: flag) // .bouncy wins!Disabling Animations
Section titled “Disabling Animations”// Prevent implicit animations from overriding.transaction { t in t.disablesAnimations = true}
// Remove animation entirely.transaction { $0.animation = nil }Custom Transaction Keys (iOS 17+)
Section titled “Custom Transaction Keys (iOS 17+)”Pass metadata through transactions.
struct ChangeSourceKey: TransactionKey { static let defaultValue: String = "unknown"}
extension Transaction { var changeSource: String { get { self[ChangeSourceKey.self] } set { self[ChangeSourceKey.self] = newValue } }}
// Set sourcevar 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+)
Section titled “Phase Animations (iOS 17+)”Cycle through discrete phases automatically. Each phase change is a separate animation.
Basic Usage
Section titled “Basic Usage”// GOOD - triggered phase animationButton("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) }Enum Phases (Recommended for Clarity)
Section titled “Enum Phases (Recommended for Clarity)”// GOOD - enum phases are self-documentingenum 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
Section titled “Custom Timing Per Phase”.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
Section titled “Good vs Bad”// GOOD - use phaseAnimator for multi-step sequences.phaseAnimator([0, -10, 10, 0], trigger: trigger) { content, offset in content.offset(x: offset)}
// BAD - manual DispatchQueue sequencingButton("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+)
Section titled “Keyframe Animations (iOS 17+)”Precise timing control with exact values at specific times.
Basic Usage
Section titled “Basic Usage”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
Section titled “Keyframe Types”| Type | Behavior |
|---|---|
CubicKeyframe |
Smooth interpolation |
LinearKeyframe |
Straight-line interpolation |
SpringKeyframe |
Spring physics |
MoveKeyframe |
Instant jump (no interpolation) |
Multiple Synchronized Tracks
Section titled “Multiple Synchronized Tracks”Tracks run in parallel, each animating one property.
// GOOD - bell shake with synchronized rotation and scalestruct 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 animationImage(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+)
Section titled “KeyframeTimeline (iOS 17+)”Query animation values directly for testing or non-SwiftUI use.
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 secondsAnimation Completion Handlers (iOS 17+)
Section titled “Animation Completion Handlers (iOS 17+)”Execute code when animations finish.
With withAnimation
Section titled “With withAnimation”// GOOD - completion with withAnimationButton("Animate") { withAnimation(.spring) { isExpanded.toggle() } completion: { showNextStep = true }}With Transaction (For Reexecution)
Section titled “With Transaction (For Reexecution)”// GOOD - completion fires on every trigger changeCircle() .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+)
Section titled “@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)
Section titled “Before (Manual)”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)
Section titled “After (@Animatable)”@Animatablestruct Wedge: Shape { var startAngle: Angle var endAngle: Angle @AnimatableIgnored var drawClockwise: Bool
func path(in rect: CGRect) -> Path { /* ... */ }}When to Use
Section titled “When to Use”- Prefer
@Animatablefor any customShape,AnimatableModifier, or type conforming toAnimatablewith multiple properties - Use
@AnimatableIgnoredfor properties that control behavior but should not interpolate (e.g., directions, flags, identifiers) - The macro works with any type conforming to
Animatable, not justShape
Source: “What’s new in SwiftUI” (WWDC25, session 256)
Quick Reference
Section titled “Quick Reference”Transactions (All iOS versions)
Section titled “Transactions (All iOS versions)”withTransactionis the explicit form ofwithAnimation- Implicit animations override explicit (later in view tree wins)
- Use
disablesAnimationsto prevent override - Use
.transaction { $0.animation = nil }to remove animation
Custom Transaction Keys (iOS 17+)
Section titled “Custom Transaction Keys (iOS 17+)”- Pass metadata through animation system via
TransactionKey
Phase Animations (iOS 17+)
Section titled “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
triggerparameter for one-shot animations
Keyframe Animations (iOS 17+)
Section titled “Keyframe Animations (iOS 17+)”- Use for precise timing control
- Tracks run in parallel
- Use
KeyframeTimelinefor testing/advanced use - Prefer over manual DispatchQueue timing
Completion Handlers (iOS 17+)
Section titled “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+)
Section titled “@Animatable Macro (iOS 26+)”- Use
@Animatableto auto-synthesizeanimatableDatafrom stored properties - Use
@AnimatableIgnoredto exclude non-animatable properties - Replaces verbose manual
animatableDatagetters/setters