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
- Phase Animations (iOS 17+)
- Keyframe Animations (iOS 17+)
- Animation Completion Handlers (iOS 17+)
- @Animatable Macro (iOS 26+)
Transactions
The underlying mechanism for all animations in SwiftUI.
Basic Usage
// withAnimation is shorthand for withTransaction
withAnimation(.default) { flag.toggle() }
// Equivalent explicit transaction
var transaction = Transaction(animation: .default)
withTransaction(transaction) { flag.toggle() }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
Implicit animations override explicit animations (later in view tree wins).
Button("Tap") {
withAnimation(.linear) { flag.toggle() }
}
.animation(.bouncy, value: flag) // .bouncy wins!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+)
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 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
// 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)
}Enum Phases (Recommended for Clarity)
// 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
.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
// 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
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
| Type | Behavior |
|---|---|
CubicKeyframe | Smooth interpolation |
LinearKeyframe | Straight-line interpolation |
SpringKeyframe | Spring physics |
MoveKeyframe | Instant jump (no interpolation) |
Multiple Synchronized Tracks
Tracks run in parallel, each animating one property.
// 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.
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+)
Execute code when animations finish.
With withAnimation
// GOOD - completion with withAnimation
Button("Animate") {
withAnimation(.spring) {
isExpanded.toggle()
} completion: {
showNextStep = true
}
}With Transaction (For Reexecution)
// 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)
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)
@Animatable
struct Wedge: Shape {
var startAngle: Angle
var endAngle: Angle
@AnimatableIgnored var drawClockwise: Bool
func path(in rect: CGRect) -> Path { /* ... */ }
}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
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+)
- 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
triggerparameter for one-shot animations
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+)
- 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
@Animatableto auto-synthesizeanimatableDatafrom stored properties - Use
@AnimatableIgnoredto exclude non-animatable properties - Replaces verbose manual
animatableDatagetters/setters