swiftui-animation
Scannednpx machina-cli add skill dpearson2699/swift-ios-skills/swiftui-animation --openclawSwiftUI Animation (iOS 26+)
Review, write, and fix SwiftUI animations. Apply modern animation APIs with correct timing, transitions, and accessibility handling using Swift 6.2 patterns.
Triage Workflow
Step 1: Identify the animation category
| Category | API | When to use |
|---|---|---|
| State-driven | withAnimation, .animation(_:value:) | Simple property changes |
| Multi-phase | PhaseAnimator | Sequenced multi-step animations |
| Keyframe | KeyframeAnimator | Complex multi-property choreography |
| Shared element | matchedGeometryEffect | Layout-driven hero transitions |
| Navigation | matchedTransitionSource + .navigationTransition(.zoom) | NavigationStack push/pop zoom |
| View lifecycle | .transition() | Insertion and removal |
| Text content | .contentTransition() | In-place text/number changes |
| Symbol | .symbolEffect() | SF Symbol animations |
| Custom | CustomAnimation protocol | Novel timing curves |
Step 2: Choose the animation curve
// Timing curves
.linear // constant speed
.easeIn(duration: 0.3) // slow start
.easeOut(duration: 0.3) // slow end
.easeInOut(duration: 0.3) // slow start and end
// Spring presets (preferred for natural motion)
.smooth // no bounce, fluid
.smooth(duration: 0.5, extraBounce: 0.0)
.snappy // small bounce, responsive
.snappy(duration: 0.4, extraBounce: 0.1)
.bouncy // visible bounce, playful
.bouncy(duration: 0.5, extraBounce: 0.2)
// Custom spring
.spring(duration: 0.5, bounce: 0.3, blendDuration: 0.0)
.spring(Spring(duration: 0.6, bounce: 0.2), blendDuration: 0.0)
.interactiveSpring(response: 0.15, dampingFraction: 0.86)
Step 3: Apply and verify
- Confirm animation triggers on the correct state change.
- Test with Accessibility > Reduce Motion enabled.
- Verify no expensive work runs inside animation content closures.
withAnimation (Explicit Animation)
withAnimation(.spring) { isExpanded.toggle() }
// With completion (iOS 17+)
withAnimation(.smooth(duration: 0.35), completionCriteria: .logicallyComplete) {
isExpanded = true
} completion: { loadContent() }
.animation(_:value:) (Implicit Animation)
Circle()
.scaleEffect(isActive ? 1.2 : 1.0)
.opacity(isActive ? 1.0 : 0.6)
.animation(.bouncy, value: isActive)
Spring Type (iOS 17+)
Four initializer forms for different mental models.
// Perceptual (preferred)
Spring(duration: 0.5, bounce: 0.3)
// Physical
Spring(mass: 1.0, stiffness: 100.0, damping: 10.0)
// Response-based
Spring(response: 0.5, dampingRatio: 0.7)
// Settling-based
Spring(settlingDuration: 1.0, dampingRatio: 0.8)
Three presets mirror Animation presets: .smooth, .snappy, .bouncy.
PhaseAnimator (iOS 17+)
Cycle through discrete phases with per-phase animation curves.
enum PulsePhase: CaseIterable {
case idle, grow, shrink
}
struct PulsingDot: View {
var body: some View {
PhaseAnimator(PulsePhase.allCases) { phase in
Circle()
.frame(width: 40, height: 40)
.scaleEffect(phase == .grow ? 1.4 : 1.0)
.opacity(phase == .shrink ? 0.5 : 1.0)
} animation: { phase in
switch phase {
case .idle: .easeIn(duration: 0.2)
case .grow: .spring(duration: 0.4, bounce: 0.3)
case .shrink: .easeOut(duration: 0.3)
}
}
}
}
Trigger-based variant runs one cycle per trigger change:
PhaseAnimator(PulsePhase.allCases, trigger: tapCount) { phase in
// ...
} animation: { _ in .spring(duration: 0.4) }
KeyframeAnimator (iOS 17+)
Animate multiple properties along independent timelines.
struct AnimValues {
var scale: Double = 1.0
var yOffset: Double = 0.0
var opacity: Double = 1.0
}
struct BounceView: View {
@State private var trigger = false
var body: some View {
Image(systemName: "star.fill")
.font(.largeTitle)
.keyframeAnimator(
initialValue: AnimValues(),
trigger: trigger
) { content, value in
content
.scaleEffect(value.scale)
.offset(y: value.yOffset)
.opacity(value.opacity)
} keyframes: { _ in
KeyframeTrack(\.scale) {
SpringKeyframe(1.5, duration: 0.3)
CubicKeyframe(1.0, duration: 0.4)
}
KeyframeTrack(\.yOffset) {
CubicKeyframe(-30, duration: 0.2)
CubicKeyframe(0, duration: 0.4)
}
KeyframeTrack(\.opacity) {
LinearKeyframe(0.6, duration: 0.15)
LinearKeyframe(1.0, duration: 0.25)
}
}
.onTapGesture { trigger.toggle() }
}
}
Keyframe types: LinearKeyframe (linear), CubicKeyframe (smooth curve),
SpringKeyframe (spring physics), MoveKeyframe (instant jump).
Use repeating: true for looping keyframe animations.
@Animatable Macro
Replaces manual AnimatableData boilerplate. Attach to any type with
animatable stored properties.
// WRONG: Manual AnimatableData (verbose, error-prone)
struct WaveShape: Shape, Animatable {
var frequency: Double
var amplitude: Double
var phase: Double
var animatableData: AnimatablePair<Double, AnimatablePair<Double, Double>> {
get { AnimatablePair(frequency, AnimatablePair(amplitude, phase)) }
set {
frequency = newValue.first
amplitude = newValue.second.first
phase = newValue.second.second
}
}
// ...
}
// CORRECT: @Animatable macro synthesizes animatableData
@Animatable
struct WaveShape: Shape {
var frequency: Double
var amplitude: Double
var phase: Double
@AnimatableIgnored var lineWidth: CGFloat
func path(in rect: CGRect) -> Path {
// draw wave using frequency, amplitude, phase
}
}
Rules:
- Stored properties must conform to
VectorArithmetic. - Use
@AnimatableIgnoredto exclude non-animatable properties. - Computed properties are never included.
matchedGeometryEffect (iOS 14+)
Synchronize geometry between views for shared-element animations.
struct HeroView: View {
@Namespace private var heroSpace
@State private var isExpanded = false
var body: some View {
if isExpanded {
DetailCard()
.matchedGeometryEffect(id: "card", in: heroSpace)
.onTapGesture {
withAnimation(.spring(duration: 0.4, bounce: 0.2)) {
isExpanded = false
}
}
} else {
ThumbnailCard()
.matchedGeometryEffect(id: "card", in: heroSpace)
.onTapGesture {
withAnimation(.spring(duration: 0.4, bounce: 0.2)) {
isExpanded = true
}
}
}
}
}
Exactly one view per ID must be visible at a time for the interpolation to work.
Navigation Zoom Transition (iOS 18+)
Pair matchedTransitionSource on the source view with
.navigationTransition(.zoom(...)) on the destination.
struct GalleryView: View {
@Namespace private var zoomSpace
let items: [GalleryItem]
var body: some View {
NavigationStack {
ScrollView {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 100))]) {
ForEach(items) { item in
NavigationLink {
GalleryDetail(item: item)
.navigationTransition(
.zoom(sourceID: item.id, in: zoomSpace)
)
} label: {
ItemThumbnail(item: item)
.matchedTransitionSource(
id: item.id, in: zoomSpace
)
}
}
}
}
}
}
}
Apply .navigationTransition on the destination view, not on inner containers.
Transitions (iOS 17+)
Control how views animate on insertion and removal.
if showBanner {
BannerView()
.transition(.move(edge: .top).combined(with: .opacity))
}
Built-in types: .opacity, .slide, .scale, .scale(_:anchor:),
.move(edge:), .push(from:), .offset(x:y:), .identity,
.blurReplace, .blurReplace(_:), .symbolEffect,
.symbolEffect(_:options:).
Asymmetric transitions:
.transition(.asymmetric(
insertion: .push(from: .bottom),
removal: .opacity
))
ContentTransition (iOS 16+)
Animate in-place content changes without insertion/removal.
Text("\(score)")
.contentTransition(.numericText(countsDown: false))
.animation(.snappy, value: score)
// For SF Symbols
Image(systemName: isMuted ? "speaker.slash" : "speaker.wave.3")
.contentTransition(.symbolEffect(.replace.downUp))
Types: .identity, .interpolate, .opacity,
.numericText(countsDown:), .numericText(value:), .symbolEffect.
Symbol Effects (iOS 17+)
Animate SF Symbols with semantic effects.
// Discrete (triggers on value change)
Image(systemName: "bell.fill")
.symbolEffect(.bounce, value: notificationCount)
Image(systemName: "arrow.clockwise")
.symbolEffect(.wiggle.clockwise, value: refreshCount)
// Indefinite (active while condition holds)
Image(systemName: "wifi")
.symbolEffect(.pulse, isActive: isSearching)
Image(systemName: "mic.fill")
.symbolEffect(.breathe, isActive: isRecording)
// Variable color with chaining
Image(systemName: "speaker.wave.3.fill")
.symbolEffect(
.variableColor.iterative.reversing.dimInactiveLayers,
options: .repeating,
isActive: isPlaying
)
All effects: .bounce, .pulse, .variableColor, .scale, .appear,
.disappear, .replace, .breathe, .rotate, .wiggle.
Scope: .byLayer, .wholeSymbol. Direction varies per effect.
Common Mistakes
1. Animating without a value binding
// WRONG: .animation without value triggers on any state change
Text("Hello")
.opacity(isVisible ? 1 : 0)
.animation(.easeIn)
// CORRECT: Bind to the specific value
Text("Hello")
.opacity(isVisible ? 1 : 0)
.animation(.easeIn, value: isVisible)
2. Expensive work inside animation closures
// WRONG: Heavy computation every frame
.keyframeAnimator(initialValue: vals, trigger: t) { content, value in
let filtered = applyExpensiveFilter(content) // runs every frame
return filtered.opacity(value.opacity)
} keyframes: { _ in /* ... */ }
// CORRECT: Precompute outside, animate only visual properties
.keyframeAnimator(initialValue: vals, trigger: t) { content, value in
content.opacity(value.opacity)
} keyframes: { _ in /* ... */ }
3. Missing reduce motion support
// WRONG: Ignores accessibility setting
withAnimation(.bouncy) { showDetail = true }
// CORRECT: Respect reduce motion
@Environment(\.accessibilityReduceMotion) private var reduceMotion
withAnimation(reduceMotion ? .none : .bouncy) { showDetail = true }
4. Multiple matchedGeometryEffect sources
// WRONG: Both visible with same ID -- undefined behavior
HStack {
Circle().matchedGeometryEffect(id: "dot", in: ns)
Circle().matchedGeometryEffect(id: "dot", in: ns)
}
// CORRECT: Only one source visible at a time via conditional
if onLeft {
Circle().matchedGeometryEffect(id: "dot", in: ns)
} else {
Circle().matchedGeometryEffect(id: "dot", in: ns)
}
5. Using DispatchQueue or UIView.animate
// WRONG: UIKit patterns in SwiftUI
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
withAnimation { isVisible = true }
}
UIView.animate(withDuration: 0.3) { /* ... */ }
// CORRECT: SwiftUI animation with delay
withAnimation(.spring.delay(0.5)) { isVisible = true }
withAnimation(.easeInOut(duration: 0.3)) { /* state change */ }
6. Forgetting animation on ContentTransition
// WRONG: No animation -- content transition has no effect
Text("\(count)")
.contentTransition(.numericText(countsDown: true))
// CORRECT: Pair with animation modifier
Text("\(count)")
.contentTransition(.numericText(countsDown: true))
.animation(.snappy, value: count)
7. navigationTransition on wrong view
// WRONG: Applied inside a container
NavigationLink {
VStack {
DetailView(item: item)
.navigationTransition(.zoom(sourceID: item.id, in: ns))
}
} label: { /* ... */ }
// CORRECT: Applied on the outermost destination view
NavigationLink {
DetailView(item: item)
.navigationTransition(.zoom(sourceID: item.id, in: ns))
} label: { /* ... */ }
Review Checklist
- Animation curve matches intent (spring for natural, ease for mechanical)
-
withAnimationwraps the state change, not the view -
.animation(_:value:)has an explicitvalueparameter -
matchedGeometryEffecthas exactly one source per ID at a time - Navigation zoom uses matching
idandnamespaceon source and destination -
@Animatablemacro used instead of manualanimatableData -
accessibilityReduceMotionis checked and respected - No heavy computation inside keyframe/phase content closures
- No
DispatchQueueorUIView.animatefor animation timing - Transitions use
.transition()on conditionally inserted views -
contentTransitionis paired with.animation(_:value:) - Symbol effects use correct category (discrete vs indefinite)
- Ensure animated state changes happen on @MainActor; types driving animations should be Sendable if passed across concurrency boundaries
Reference Material
- See
references/animation-advanced.mdfor CustomAnimation protocol, full Spring variants, all Transition types, symbol effect details, Transaction system, UnitCurve types, and performance guidance.
API Verification
The apple-docs MCP server is available for verifying SwiftUI animation API
names, signatures, and availability. Use searchAppleDocumentation with queries
like "PhaseAnimator" or fetchAppleDocumentation with paths like
/documentation/swiftui/animation to cross-check details when needed.
Source
git clone https://github.com/dpearson2699/swift-ios-skills/blob/main/skills/swiftui-animation/SKILL.mdView on GitHub Overview
Develop, review, and fix SwiftUI animations and transitions using modern APIs. This skill covers implicit and explicit animations, phase and keyframe choreography, hero transitions, SF Symbol effects, custom animation types, and accessibilityReduceMotion compliance.
How This Skill Works
Identify the animation category via the triage workflow, then choose an appropriate API (withAnimation, .animation, PhaseAnimator, KeyframeAnimator, matchedGeometryEffect, matchedTransitionSource, or CustomAnimation). Apply a suitable timing curve (smooth, snappy, bouncy, or a custom Spring) and verify the animation triggers on the correct state changes while respecting accessibilityReduceMotion.
When to Use It
- State-driven UI updates that should animate on change using withAnimation or .animation(_:value:).
- Sequenced or multi-phase animations using PhaseAnimator or KeyframeAnimator.
- Hero or shared-element transitions with matchedGeometryEffect or matchedTransitionSource.
- Navigation transitions via matchedTransitionSource with navigationTransition(.zoom) or related view lifecycle transitions.
- SF Symbol effects (bounce, pulse, variableColor, breathe, rotate, wiggle) or custom transitions, ensuring accessibilityReduceMotion is respected.
Quick Start
- Step 1: Identify the animation category from the triage workflow (state-driven, phase, keyframe, shared element, navigation, or custom).
- Step 2: Pick a timing curve (e.g., .smooth/.snappy/.bouncy) or a custom Spring, and decide implicit vs explicit animation.
- Step 3: Implement with withAnimation or .animation, add PhaseAnimator/KeyframeAnimator or matchedGeometryEffect as needed, then test accessibilityReduceMotion.
Best Practices
- Match the animation API to the identified category (state-driven, phase, keyframe, shared element, or custom).
- Choose an appropriate timing curve from presets (.smooth, .snappy, .bouncy) or a custom Spring for natural motion.
- Test animations with Accessibility > Reduce Motion and provide graceful fallbacks.
- Keep expensive work out of animation closures and avoid heavy layout recalculations during animation.
- Document the intent and consider reusable components (PhaseAnimator/KeyframeAnimator blocks) for consistency.
Example Use Cases
- Using withAnimation(.spring) { isExpanded.toggle() } to animate a panel expansion.
- Applying .animation(.bouncy, value: isActive) to repeatedly animate a view as a state changes.
- Coordinating multi-step UI with PhaseAnimator for idle, grow, and shrink phases.
- Creating a hero transition with matchedGeometryEffect between a list item and detail view.
- Animating an SF Symbol with .symbolEffect() to add bounce or wiggle while the symbol changes.