.agents/skills/swiftui-expert-skill/references/animation-advanced.md
Transactions, phase animations (iOS 17+), keyframe animations (iOS 17+), completion handlers (iOS 17+), and @Animatable macro (iOS 26+).
The underlying mechanism for all animations in SwiftUI.
// withAnimation is shorthand for withTransaction
withAnimation(.default) { flag.toggle() }
// Equivalent explicit transaction
var transaction = Transaction(animation: .default)
withTransaction(transaction) { flag.toggle() }
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.
Implicit animations override explicit animations (later in view tree wins).
Button("Tap") {
withAnimation(.linear) { flag.toggle() }
}
.animation(.bouncy, value: flag) // .bouncy wins!
// Prevent implicit animations from overriding
.transaction { t in
t.disablesAnimations = true
}
// Remove animation entirely
.transaction { $0.animation = nil }
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
}
}
Cycle through discrete phases automatically. Each phase change is a separate animation.
// 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)
}
// 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)
}
.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 - 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 }
}
}
Precise timing control with exact values at specific times.
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
}
| Type | Behavior |
|---|---|
CubicKeyframe | Smooth interpolation |
LinearKeyframe | Straight-line interpolation |
SpringKeyframe | Spring physics |
MoveKeyframe | Instant jump (no interpolation) |
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
}
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 seconds
Execute code when animations finish.
// GOOD - completion with withAnimation
Button("Animate") {
withAnimation(.spring) {
isExpanded.toggle()
} completion: {
showNextStep = true
}
}
// 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
}
}
The @Animatable macro auto-synthesizes animatableData from all animatable stored properties, eliminating verbose manual conformance. Use @AnimatableIgnored to exclude properties that should not animate.
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 { /* ... */ }
}
@Animatable
struct Wedge: Shape {
var startAngle: Angle
var endAngle: Angle
@AnimatableIgnored var drawClockwise: Bool
func path(in rect: CGRect) -> Path { /* ... */ }
}
@Animatable for any custom Shape, AnimatableModifier, or type conforming to Animatable with multiple properties@AnimatableIgnored for properties that control behavior but should not interpolate (e.g., directions, flags, identifiers)Animatable, not just ShapeSource: "What's new in SwiftUI" (WWDC25, session 256)
withTransaction is the explicit form of withAnimationdisablesAnimations to prevent override.transaction { $0.animation = nil } to remove animationTransactionKeytrigger parameter for one-shot animationsKeyframeTimeline for testing/advanced usewithAnimation(.animation) { } completion: { } for one-shot completion handlers.transaction(value:) for handlers that should refire on every value changevalue: parameter, completion only fires once@Animatable to auto-synthesize animatableData from stored properties@AnimatableIgnored to exclude non-animatable propertiesanimatableData getters/setters