
Swiftui Animation
- 3.7k installs
- 944 repo stars
- Updated July 15, 2026
- dpearson2699/swift-ios-skills
How to implement, review, and fix SwiftUI animations and transitions using modern iOS 17+ APIs (withAnimation, PhaseAnimator, KeyframeAnimator, matchedGeometryEffect) with correct timing curves, accessibility handling, a
About
SwiftUI Animation skill covers implementing, reviewing, and fixing animations using explicit withAnimation, implicit .animation modifiers, spring presets, PhaseAnimator for multi-step sequences, KeyframeAnimator for multi-property choreography, matchedGeometryEffect for hero transitions, symbol effects, and custom transitions. Includes triage workflow, animation curve selection, accessibility (reduceMotion) handling, common mistakes, and a review checklist. Master timing curves (.smooth, .snappy, .bouncy), phase/keyframe timing, and modern iOS 17+ APIs while respecting user motion preferences.
- Explicit withAnimation vs implicit .animation(.body:) vs .animation(.value:) scoping patterns
- Spring presets (.smooth, .snappy, .bouncy) with custom Spring(duration:bounce:) for natural motion
- PhaseAnimator for discrete multi-phase sequences and KeyframeAnimator for choreographed multi-property timelines
- matchedGeometryEffect hero transitions and iOS 18 matchedTransitionSource + .navigationTransition(.zoom)
- accessibilityReduceMotion integration and common pitfalls (bare .animation, expensive closures, multiple matched sources
Swiftui Animation by the numbers
- 3,682 all-time installs (skills.sh)
- +177 installs in the week ending Jul 29, 2026 (Skillselion tracking)
- Ranked #134 of 2,244 Frontend Development skills by installs in the Skillselion catalog
- Security screen: LOW risk (skills.sh audit)
- Data as of Jul 31, 2026 (Skillselion catalog sync)
npx skills add https://github.com/dpearson2699/swift-ios-skills --skill swiftui-animationAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 3.7k |
|---|---|
| repo stars | ★ 944 |
| Security audit | 3 / 3 scanners passed |
| Last updated | July 15, 2026 |
| Repository | dpearson2699/swift-ios-skills ↗ |
What it does
Implement, review, and improve SwiftUI animations and transitions with modern APIs, spring curves, keyframe choreography, and accessibility compliance.
Who is it for?
iOS/SwiftUI engineers writing animations for state-driven UI, hero transitions, symbol effects, and complex choreography; code reviewers ensuring animation quality and accessibility compliance.
Skip if: Android developers, web animation frameworks, Core Animation bridging (covered separately), custom timing curve math, high-performance graphics optimization.
When should I use this skill?
Adding explicit animations to state changes, reviewing animation code, building multi-phase sequences, creating hero transitions, implementing symbol effects, ensuring reduce-motion support, or debugging animation perfor
What you get
Team reviews and ships smooth, accessible, performant animations aligned with platform idioms, using spring presets, phase/keyframe choreography, and proper scope binding to state.
- SwiftUI animation code snippets
- PhaseAnimator configurations
- Transition implementations
By the numbers
- Covers iOS 17+ CustomAnimation protocol and all transition types
- Documents every Spring initializer variant and UnitCurve types
Files
SwiftUI Animation (iOS 26+)
Review, write, and fix SwiftUI animations. Apply modern animation APIs with correct timing, transitions, and accessibility handling using Swift 6.3 patterns.
Contents
- Triage Workflow
- withAnimation (Explicit Animation)
- Implicit Animation
- Spring Type (iOS 17+)
- PhaseAnimator (iOS 17+)
- KeyframeAnimator (iOS 17+)
- `@Animatable Macro`
- matchedGeometryEffect (iOS 14+)
- Navigation Zoom Transition (iOS 18+)
- Transitions (iOS 17+)
- ContentTransition (iOS 16+)
- Symbol Effects (iOS 17+)
- Symbol Rendering Modes
- Common Mistakes
- Review Checklist
- References
Triage Workflow
Step 1: Identify the animation category
| Category | API | When to use |
|---|---|---|
| State-driven | withAnimation, .animation(_:body:), .animation(_:value:) | Explicit state changes, selective modifier animation, or simple value-bound 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() }Implicit Animation
Prefer .animation(_:body:) when only specific modifiers should animate. Use .animation(_:value:) for simple value-bound changes that can animate the view's animatable modifiers together.
Badge()
.foregroundStyle(isActive ? .green : .secondary)
.animation(.snappy) { content in
content
.scaleEffect(isActive ? 1.15 : 1.0)
.opacity(isActive ? 1.0 : 0.7)
}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 {
Button { trigger.toggle() } label: {
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)
}
}
}
.buttonStyle(.plain)
}
}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.
// Replaces manual AnimatableData boilerplate
@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 {
Group {
if isExpanded {
Button {
withAnimation(.spring(duration: 0.4, bounce: 0.2)) {
isExpanded = false
}
} label: {
DetailCard()
.matchedGeometryEffect(id: "card", in: heroSpace)
}
} else {
Button {
withAnimation(.spring(duration: 0.4, bounce: 0.2)) {
isExpanded = true
}
} label: {
ThumbnailCard()
.matchedGeometryEffect(id: "card", in: heroSpace)
}
}
}
.buttonStyle(.plain)
}
}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.
Symbol Rendering Modes
Control how SF Symbol layers are colored with .symbolRenderingMode(_:).
| Mode | Effect | When to use |
|---|---|---|
.monochrome | Single color applied uniformly (default) | Toolbars, simple icons matching text |
.hierarchical | Single color with opacity layers for depth | Subtle depth without multiple colors |
.multicolor | System-defined fixed colors per layer | Weather, file types — Apple's intended palette |
.palette | Custom colors per layer via .foregroundStyle | Brand colors, custom multi-color icons |
// Hierarchical — single tint, opacity layers for depth
Image(systemName: "speaker.wave.3.fill")
.symbolRenderingMode(.hierarchical)
.foregroundStyle(.blue)
// Palette — custom color per layer
Image(systemName: "person.crop.circle.badge.plus")
.symbolRenderingMode(.palette)
.foregroundStyle(.blue, .green)
// Multicolor — system-defined colors
Image(systemName: "cloud.sun.rain.fill")
.symbolRenderingMode(.multicolor)Variable color: .symbolVariableColor(value:) for percentage-based fill (signal strength, volume):
Image(systemName: "wifi")
.symbolVariableColor(value: signalStrength) // 0.0–1.0Docs: SymbolRenderingMode · symbolRenderingMode(_:))
Common Mistakes
1. Using bare .animation(_:) when you need precise scope
// TOO BROAD — applies when the view changes
.animation(.easeIn)
// CORRECT — bind animation to one value
.animation(.easeIn, value: isVisible)
// CORRECT — scope animation to selected modifiers
.animation(.easeIn) { content in
content.opacity(isVisible ? 1.0 : 0.0)
}2. Expensive work inside animation closures
Never run heavy computation in keyframeAnimator / PhaseAnimator content closures — they execute every frame. Precompute outside, animate only visual properties.
3. Missing reduce motion support
@Environment(\.accessibilityReduceMotion) private var reduceMotion
withAnimation(reduceMotion ? .none : .bouncy) { showDetail = true }4. Multiple matchedGeometryEffect sources
Only one view per ID should be visible at a time. Two visible views with the same ID causes undefined layout.
5. Using DispatchQueue or UIView.animate
// WRONG
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { withAnimation { isVisible = true } }
// CORRECT
withAnimation(.spring.delay(0.5)) { isVisible = true }6. Forgetting animation on ContentTransition
// WRONG — no animation, content transition has no effect
Text("\(count)").contentTransition(.numericText(countsDown: true))
// CORRECT — pair with animation
Text("\(count)")
.contentTransition(.numericText(countsDown: true))
.animation(.snappy, value: count)7. navigationTransition on wrong view
Apply .navigationTransition(.zoom(sourceID:in:)) on the outermost destination view, not inside a container.
Review Checklist
- [ ] Animation curve matches intent (spring for natural, ease for mechanical)
- [ ]
withAnimationwraps the state change; implicit animation uses.animation(_:body:)for selective modifier scope or.animation(_:value:)with an explicit value - [ ]
matchedGeometryEffecthas exactly one source per ID; zoom uses matchingid/namespace - [ ]
@Animatablemacro used instead of manualanimatableData - [ ]
accessibilityReduceMotionchecked; noDispatchQueue/UIView.animate - [ ] Transitions use
.transition();contentTransitionis paired with animation and uses the narrowest implicit animation scope that fits - [ ] Animated state changes on @MainActor; animation-driving types are Sendable
References
- See references/animation-advanced.md for CustomAnimation protocol, full Spring variants, all Transition types, symbol effect details, Transaction system, UnitCurve types, and performance guidance.
- Core Animation bridging patterns: references/core-animation-bridge.md
SwiftUI Animation Advanced Reference
Detailed API reference for SwiftUI animation types, protocols, and patterns. Covers material beyond the SKILL.md summary.
Contents
- CustomAnimation Protocol (iOS 17+)
- Spring Type -- All Initializer Variants
- UnitCurve Types (iOS 17+)
- PhaseAnimator Deep Patterns
- KeyframeAnimator Multi-Track Examples
- Transaction and TransactionKey
- Scoped Implicit Animation
- All Transition Types (iOS 17+)
- All Symbol Effect Types
- Reduce Motion Implementation Patterns
- Animation Performance Tips
CustomAnimation Protocol (iOS 17+)
Create entirely custom animation curves by conforming to CustomAnimation.
@preconcurrency protocol CustomAnimation: Hashable, SendableRequired Method
func animate<V: VectorArithmetic>(
value: V,
time: TimeInterval,
context: inout AnimationContext<V>
) -> V?Return the interpolated value at the given time. Return nil when the animation is complete.
Optional Methods
func velocity<V: VectorArithmetic>(
value: V,
time: TimeInterval,
context: AnimationContext<V>
) -> V?
func shouldMerge<V: VectorArithmetic>(
previous: Animation,
value: V,
time: TimeInterval,
context: inout AnimationContext<V>
) -> BoolFull Example: Elastic Ease-In-Out
struct ElasticAnimation: CustomAnimation {
let duration: TimeInterval
func animate<V: VectorArithmetic>(
value: V,
time: TimeInterval,
context: inout AnimationContext<V>
) -> V? {
guard time <= duration else { return nil }
let p = time / duration
let s = sin((20 * p - 11.125) * ((2 * .pi) / 4.5))
let progress: Double
if p < 0.5 {
progress = -(pow(2, 20 * p - 10) * s) / 2
} else {
progress = (pow(2, -20 * p + 10) * s) / 2 + 1
}
return value.scaled(by: progress)
}
}Ergonomic Extension Pattern
Expose custom animations as static members on Animation.
extension Animation {
static var elastic: Animation {
elastic(duration: 0.35)
}
static func elastic(duration: TimeInterval) -> Animation {
Animation(ElasticAnimation(duration: duration))
}
}
// Usage
withAnimation(.elastic(duration: 0.5)) { isActive.toggle() }Supporting Types
| Type | Role |
|---|---|
AnimationContext<V> | Carries environment and per-animation state |
AnimationState | Key-value storage for persisted state |
AnimationStateKey | Protocol for defining custom state keys |
Spring Type -- All Initializer Variants
Perceptual (Preferred)
Spring(duration: 0.5, bounce: 0.0)duration-- Perceptual duration controlling pace. Default0.5.bounce-- Bounciness.0.0= no bounce,1.0= undamped. Negative values
produce overdamped springs. Default 0.0.
Physical Parameters
Spring(mass: 1.0, stiffness: 100.0, damping: 10.0, allowOverDamping: false)mass-- Mass at end of spring. Default1.0.stiffness-- Spring stiffness coefficient.damping-- Friction-like drag force.allowOverDamping-- Permit damping ratio > 1. Defaultfalse.
Response-Based
Spring(response: 0.5, dampingRatio: 0.7)response-- Stiffness expressed as approximate duration in seconds.dampingRatio-- Fraction of critical damping.1.0= critically damped.
Settling-Based
Spring(settlingDuration: 1.0, dampingRatio: 0.8, epsilon: 0.001)settlingDuration-- Estimated time to come to rest.dampingRatio-- Fraction of critical damping.epsilon-- Threshold for considering the spring at rest. Default0.001.
Presets
Spring.smooth // no bounce
Spring.smooth(duration: 0.5, extraBounce: 0.0)
Spring.snappy // small bounce
Spring.snappy(duration: 0.4, extraBounce: 0.1)
Spring.bouncy // visible bounce
Spring.bouncy(duration: 0.5, extraBounce: 0.2)Querying State
let spring = Spring(duration: 0.5, bounce: 0.3)
let v = spring.value(target: 1.0, initialVelocity: 0.0, time: 0.25)
let vel = spring.velocity(target: 1.0, initialVelocity: 0.0, time: 0.25)
let settle = spring.settlingDuration(target: 1.0, initialVelocity: 0.0, epsilon: 0.001)Parameter Conversion
let spring = Spring(duration: 0.5, bounce: 0.3)
// Access physical equivalents:
spring.mass // 1.0
spring.stiffness // 157.9
spring.damping // 17.6
spring.response
spring.dampingRatio
spring.settlingDurationUnitCurve Types (iOS 17+)
Map input progress [0,1] to output progress [0,1]. Used with .timingCurve(_:duration:).
Built-in Curves
UnitCurve.linear
UnitCurve.easeIn
UnitCurve.easeOut
UnitCurve.easeInOut
UnitCurve.circularEaseIn
UnitCurve.circularEaseOut
UnitCurve.circularEaseInOutCustom Bezier Curve
UnitCurve.bezier(
startControlPoint: UnitPoint(x: 0.42, y: 0.0),
endControlPoint: UnitPoint(x: 0.58, y: 1.0)
)Instance Members
let curve = UnitCurve.easeInOut
curve.value(at: 0.5) // output progress at midpoint
curve.velocity(at: 0.5) // rate of change at midpoint
curve.inverse // swaps x and y componentsUsage with Animation
.animation(.timingCurve(UnitCurve.circularEaseIn, duration: 0.4), value: x)
// Cubic bezier control points
.animation(.timingCurve(0.68, -0.55, 0.27, 1.55, duration: 0.5), value: x)PhaseAnimator Deep Patterns
Multi-Phase with Complex State
enum LoadPhase: CaseIterable {
case ready, loading, spinning, complete
var scale: Double {
switch self {
case .ready: 1.0
case .loading: 0.9
case .spinning: 1.0
case .complete: 1.1
}
}
var rotation: Angle {
switch self {
case .spinning: .degrees(360)
default: .zero
}
}
var opacity: Double {
self == .loading ? 0.7 : 1.0
}
}
struct LoadingIndicator: View {
var body: some View {
PhaseAnimator(LoadPhase.allCases) { phase in
Image(systemName: "arrow.triangle.2.circlepath")
.font(.title)
.scaleEffect(phase.scale)
.rotationEffect(phase.rotation)
.opacity(phase.opacity)
} animation: { phase in
switch phase {
case .ready: .smooth(duration: 0.2)
case .loading: .easeIn(duration: 0.15)
case .spinning: .linear(duration: 0.6)
case .complete: .spring(duration: 0.3, bounce: 0.4)
}
}
}
}Trigger-Based One-Shot
Run through all phases once each time the trigger value changes.
struct FeedbackDot: View {
@State private var feedbackTrigger = 0
var body: some View {
Button { feedbackTrigger += 1 } label: {
Circle()
.frame(width: 20, height: 20)
.phaseAnimator(
[false, true, false],
trigger: feedbackTrigger
) { content, phase in
content.scaleEffect(phase ? 1.5 : 1.0)
} animation: { _ in
.spring(duration: 0.25, bounce: 0.5)
}
}
.buttonStyle(.plain)
}
}View Modifier Form
Text("Hello")
.phaseAnimator([0.0, 1.0, 0.0]) { content, phase in
content.opacity(phase)
} animation: { _ in .easeInOut(duration: 0.8) }KeyframeAnimator Multi-Track Examples
Bounce-and-Fade
struct BounceValues {
var yOffset: Double = 0
var scale: Double = 1.0
var opacity: Double = 1.0
var rotation: Angle = .zero
}
struct BouncingBadge: View {
@State private var trigger = false
var body: some View {
Button { trigger.toggle() } label: {
Text("NEW")
.font(.caption.bold())
.padding(.horizontal)
.background(.red, in: Capsule())
.keyframeAnimator(
initialValue: BounceValues(),
trigger: trigger
) { content, value in
content
.offset(y: value.yOffset)
.scaleEffect(value.scale)
.opacity(value.opacity)
.rotationEffect(value.rotation)
} keyframes: { _ in
KeyframeTrack(\.yOffset) {
SpringKeyframe(-20, duration: 0.2)
CubicKeyframe(5, duration: 0.15)
SpringKeyframe(0, duration: 0.3)
}
KeyframeTrack(\.scale) {
CubicKeyframe(1.3, duration: 0.2)
CubicKeyframe(0.95, duration: 0.15)
SpringKeyframe(1.0, duration: 0.3)
}
KeyframeTrack(\.rotation) {
LinearKeyframe(.degrees(-5), duration: 0.1)
LinearKeyframe(.degrees(5), duration: 0.1)
SpringKeyframe(.zero, duration: 0.2)
}
KeyframeTrack(\.opacity) {
MoveKeyframe(1.0)
}
}
}
.buttonStyle(.plain)
}
}Repeating Keyframe Animation
KeyframeAnimator(
initialValue: PulseValues(),
repeating: true
) { value in
Circle()
.fill(.blue)
.frame(width: 40, height: 40)
.scaleEffect(value.scale)
.opacity(value.opacity)
} keyframes: { _ in
KeyframeTrack(\.scale) {
CubicKeyframe(1.3, duration: 0.5)
CubicKeyframe(1.0, duration: 0.5)
}
KeyframeTrack(\.opacity) {
CubicKeyframe(0.6, duration: 0.5)
CubicKeyframe(1.0, duration: 0.5)
}
}Keyframe Type Reference
| Type | Interpolation | Use case |
|---|---|---|
LinearKeyframe(value, duration:) | Straight line between values | Steady movement |
CubicKeyframe(value, duration:) | Cubic bezier curve | Smooth easing |
SpringKeyframe(value, duration:, spring:) | Spring physics | Natural settle |
MoveKeyframe(value) | Instant jump | Reset to value immediately |
KeyframeTimeline for Manual Evaluation
let timeline = KeyframeTimeline(initialValue: AnimValues()) {
KeyframeTrack(\.scale) {
CubicKeyframe(1.5, duration: 0.3)
CubicKeyframe(1.0, duration: 0.4)
}
}
let totalDuration = timeline.duration
let valueAtHalf = timeline.value(time: totalDuration / 2)Transaction and TransactionKey
Transaction Basics
A Transaction carries the animation context for a state change. Every withAnimation call creates a transaction internally.
// Explicit transaction
var transaction = Transaction(animation: .spring)
withTransaction(transaction) {
isExpanded = true
}Overriding Animations with Transaction
// Remove the incoming transaction animation for this scoped content
SomeView()
.transaction { transaction in
transaction.animation = nil
}
// Override the scoped transaction animation when a value changes
SomeView()
.transaction(value: selectedTab) { transaction in
transaction.animation = .smooth(duration: 0.3)
}Custom TransactionKey
Store custom metadata in transactions.
struct IsInteractiveKey: TransactionKey {
static let defaultValue = false
}
extension Transaction {
var isInteractive: Bool {
get { self[IsInteractiveKey.self] }
set { self[IsInteractiveKey.self] = newValue }
}
}
// Usage
var transaction = Transaction(animation: .interactiveSpring)
transaction.isInteractive = true
withTransaction(transaction) { dragOffset = newOffset }Scoped Transaction Override
// Apply transaction only within a body closure
ParentView()
.transaction { $0.animation = .spring } body: { content in
content.scaleEffect(scale)
}Scoped Implicit Animation
Use .animation(_:body:) when only selected modifiers should animate. Use .animation(_:value:) when a single value change should drive the view's animatable modifiers together. Use .transaction(_:body:) when you need to scope transaction overrides rather than attach one animation.
CardView(isExpanded: isExpanded)
.animation(.smooth) { content in
content
.scaleEffect(isExpanded ? 1.05 : 1.0)
.shadow(radius: isExpanded ? 12 : 4)
}All Transition Types (iOS 17+)
Built-in Transitions
| Transition | Description | Example |
|---|---|---|
.opacity | Fade in/out | .transition(.opacity) |
.slide | Slide from leading, exit trailing | .transition(.slide) |
.scale | Scale from zero | .transition(.scale) |
.scale(_:anchor:) | Scale with amount and anchor | .transition(.scale(0.5, anchor: .bottom)) |
.move(edge:) | Move from specified edge | .transition(.move(edge: .top)) |
.push(from:) | Push from edge with fade | .transition(.push(from: .trailing)) |
.offset(_:) | Offset by CGSize | .transition(.offset(CGSize(width: 0, height: 50))) |
.offset(x:y:) | Offset by x and y | .transition(.offset(x: 0, y: -100)) |
.identity | No visual change | .transition(.identity) |
.blurReplace | Blur and scale combined | .transition(.blurReplace) |
.blurReplace(_:) | Configurable blur replace | .transition(.blurReplace(.downUp)) |
.symbolEffect | Default symbol effect | .transition(.symbolEffect) |
.symbolEffect(_:options:) | Custom symbol effect | .transition(.symbolEffect(.appear)) |
Combining Transitions
// Slide + fade
.transition(.slide.combined(with: .opacity))
// Move from top + scale
.transition(.move(edge: .top).combined(with: .scale))Asymmetric Transitions
Different animation for insertion vs removal.
.transition(.asymmetric(
insertion: .push(from: .bottom).combined(with: .opacity),
removal: .scale.combined(with: .opacity)
))Custom Transition
struct RotateTransition: Transition {
func body(content: Content, phase: TransitionPhase) -> some View {
content
.rotationEffect(phase.isIdentity ? .zero : .degrees(90))
.opacity(phase.isIdentity ? 1 : 0)
}
}
extension AnyTransition {
static var rotate: AnyTransition {
.init(RotateTransition())
}
}TransitionPhase
enum TransitionPhase {
case willAppear // View is about to be inserted
case identity // View is fully presented
case didDisappear // View is being removed
}
// Check current phase
phase.isIdentity // true when fully presentedAttaching Animation to Transition
.transition(
.move(edge: .bottom)
.combined(with: .opacity)
.animation(.spring(duration: 0.4, bounce: 0.2))
)All Symbol Effect Types
Discrete Effects (trigger with value:)
| Effect | Scope | Direction |
|---|---|---|
.bounce | .byLayer, .wholeSymbol | -- |
.wiggle | .byLayer, .wholeSymbol | .up, .down, .left, .right, .forward, .backward, .clockwise, .counterClockwise, .custom(angle:) |
Image(systemName: "bell.fill")
.symbolEffect(.bounce.byLayer, value: count)
Image(systemName: "arrow.left.arrow.right")
.symbolEffect(.wiggle.left, value: swapCount)Indefinite Effects (toggle with isActive:)
| Effect | Scope | Direction |
|---|---|---|
.pulse | .byLayer, .wholeSymbol | -- |
.variableColor | .byLayer, .wholeSymbol | Chaining: .cumulative/.iterative, .reversing/.nonReversing, .dimInactiveLayers/.hideInactiveLayers |
.scale | .byLayer, .wholeSymbol | .up, .down |
.breathe | .byLayer, .wholeSymbol | -- |
.rotate | .byLayer, .wholeSymbol | .clockwise, .counterClockwise |
Image(systemName: "wifi")
.symbolEffect(.pulse.byLayer, isActive: isConnecting)
Image(systemName: "gear")
.symbolEffect(.rotate.clockwise, isActive: isProcessing)
Image(systemName: "speaker.wave.3.fill")
.symbolEffect(
.variableColor.cumulative.nonReversing.dimInactiveLayers,
options: .repeating,
isActive: isPlaying
)
Image(systemName: "magnifyingglass")
.symbolEffect(.scale.up, isActive: isHighlighted)
Image(systemName: "heart.fill")
.symbolEffect(.breathe, isActive: isFavorite)Transition Effects (appear/disappear)
Image(systemName: "checkmark.circle.fill")
.symbolEffect(.appear, isActive: showCheck)
Image(systemName: "xmark.circle")
.symbolEffect(.disappear, isActive: shouldHide)Content Transition Effects (replace)
Image(systemName: isMuted ? "speaker.slash" : "speaker.wave.3")
.contentTransition(.symbolEffect(.replace.downUp))
// Magic replace (morphs between symbols)
Image(systemName: isPlaying ? "pause.fill" : "play.fill")
.contentTransition(.symbolEffect(.replace.magic(fallback: .downUp)))Replace directions: .downUp, .offUp, .upUp.
SymbolEffectOptions
.symbolEffect(.pulse, options: .default, isActive: true)
.symbolEffect(.bounce, options: .repeating, value: count)
.symbolEffect(.pulse, options: .nonRepeating, isActive: true)
.symbolEffect(.bounce, options: .repeat(3), value: count)
.symbolEffect(.pulse, options: .speed(2.0), isActive: true)
// RepeatBehavior
.symbolEffect(.bounce, options: .repeat(.periodic(3, delay: 0.5)), value: count)
.symbolEffect(.pulse, options: .repeat(.continuous), isActive: true)Removing Effects
Image(systemName: "star.fill")
.symbolEffect(.pulse, isActive: true)
.symbolEffectsRemoved(reduceMotion)Reduce Motion Implementation Patterns
Environment Variable
@Environment(\.accessibilityReduceMotion) private var reduceMotionPattern 1: Conditional Animation
withAnimation(reduceMotion ? .none : .bouncy) {
isExpanded.toggle()
}Pattern 2: Simplified Animation
Replace bouncy/spring with crossfade when reduce motion is on.
withAnimation(reduceMotion ? .easeInOut(duration: 0.2) : .spring(duration: 0.4, bounce: 0.3)) {
selectedTab = newTab
}Pattern 3: Disable Repeating Animations
// WRONG: Ignores reduce motion
PhaseAnimator(phases) { phase in /* ... */ }
// CORRECT: Use trigger-based or skip entirely
if !reduceMotion {
PhaseAnimator(phases) { phase in /* ... */ }
} else {
StaticView()
}Pattern 4: Symbol Effects
Image(systemName: "wifi")
.symbolEffect(.pulse, isActive: isSearching)
.symbolEffectsRemoved(reduceMotion)Pattern 5: Reusable Helper
extension Animation {
static func adaptive(
_ animation: Animation,
reduceMotion: Bool
) -> Animation? {
reduceMotion ? nil : animation
}
}
// Usage
withAnimation(.adaptive(.bouncy, reduceMotion: reduceMotion)) {
isVisible = true
}Animation Performance Tips
Keep Content Closures Light
The content closure in KeyframeAnimator and PhaseAnimator runs every frame while animating. Keep it to simple view modifiers.
// WRONG: Expensive computation per frame
.keyframeAnimator(initialValue: v, trigger: t) { content, value in
let result = heavyComputation(value.progress)
return content.opacity(result)
} keyframes: { _ in /* ... */ }
// CORRECT: Only apply view modifiers
.keyframeAnimator(initialValue: v, trigger: t) { content, value in
content.opacity(value.opacity)
} keyframes: { _ in /* ... */ }Prefer Modifier-Based Animations
Animating view modifiers (opacity, scaleEffect, offset, rotationEffect) is highly optimized. Avoid animating layout-triggering properties when possible.
Use drawingGroup for Complex Compositing
ComplexAnimatedView()
.drawingGroup()Flattens the view hierarchy into a single Metal-backed layer. Use when compositing many overlapping animated views.
Limit Concurrent Animations
Avoid animating dozens of views simultaneously. Use staggered delays.
ForEach(Array(items.enumerated()), id: \.element.id) { index, item in
ItemView(item: item)
.transition(.move(edge: .bottom).combined(with: .opacity))
.animation(.spring.delay(Double(index) * 0.05), value: isVisible)
}Avoid Re-creating Views During Animation
Ensure animated views maintain stable identity. Use explicit id() modifiers or stable ForEach identifiers.
// WRONG: View identity changes, breaks animation
ForEach(Array(items.enumerated()), id: \.offset) { index, item in
ItemView(item: item)
}
// CORRECT: Stable identity from model
ForEach(items) { item in
ItemView(item: item)
}Use geometryGroup() for Nested Geometry
Isolate child geometry from parent animations when they conflict.
ParentView()
.scaleEffect(parentScale)
.geometryGroup() // children see stable geometryTransaction for Selective Animation Override
Override animation for specific subtrees without affecting siblings.
// Disable animation on one child while parent animates
ChildView()
.transaction { $0.animation = nil }Profile with Instruments
Use the Core Animation instrument in Xcode Instruments to verify:
- Frame rate stays at 120 fps (ProMotion devices) or 60 fps.
- No offscreen rendering passes.
- GPU utilization stays reasonable during animations.
Core Animation Bridge
Patterns for bridging Core Animation (QuartzCore) with SwiftUI. Use when SwiftUI's built-in animation system is insufficient -- typically for performance-critical layer animations, unsupported animation curves, or direct CALayer manipulation. Overflow reference for the swiftui-animation skill.
Contents
- When to Drop Below SwiftUI Animations
- CABasicAnimation
- CAKeyframeAnimation
- CASpringAnimation
- CAAnimationGroup
- CADisplayLink
- UIViewRepresentable Wrapper for CA Layers
- Bridging CA Animations with SwiftUI State
- Performance Considerations
When to Drop Below SwiftUI Animations
SwiftUI's animation system covers most use cases. Drop to Core Animation only when:
| Scenario | Why CA Is Needed |
|---|---|
| Custom timing functions beyond spring/ease | CAMediaTimingFunction supports arbitrary cubic Bezier curves |
| Layer-specific properties (shadowPath, borderWidth, etc.) | SwiftUI does not expose all CALayer animatable properties |
| Additive animations | CA supports additive blending of multiple concurrent animations on the same property |
| Frame-synchronized drawing | CADisplayLink provides precise frame timing for custom rendering |
| Performance-critical particle/effects | Direct layer manipulation avoids SwiftUI's diffing overhead |
| Animation along a path | CAKeyframeAnimation supports CGPath-based animation paths |
If SwiftUI's withAnimation, PhaseAnimator, or KeyframeAnimator can achieve the effect, prefer them. Core Animation bridging adds complexity and requires explicit UIViewRepresentable wrappers.
CABasicAnimation
`CABasicAnimation` interpolates a single layer property between two values.
Basic Usage
import QuartzCore
let animation = CABasicAnimation(keyPath: "opacity")
animation.fromValue = 0.0
animation.toValue = 1.0
animation.duration = 0.3
animation.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
// Apply to a layer
layer.add(animation, forKey: "fadeIn")
layer.opacity = 1.0 // Set the final model valueCustom Bezier Timing
// Custom cubic Bezier curve -- not available in SwiftUI
let timingFunction = CAMediaTimingFunction(controlPoints: 0.2, 0.8, 0.2, 1.0)
let animation = CABasicAnimation(keyPath: "position.y")
animation.fromValue = layer.position.y
animation.toValue = layer.position.y - 100
animation.duration = 0.5
animation.timingFunction = timingFunction
animation.fillMode = .forwards
animation.isRemovedOnCompletion = false
layer.add(animation, forKey: "customBezier")Shadow Path Animation
// Animate shadowPath -- not possible in pure SwiftUI
let animation = CABasicAnimation(keyPath: "shadowPath")
animation.fromValue = layer.shadowPath
animation.toValue = UIBezierPath(roundedRect: newBounds, cornerRadius: 16).cgPath
animation.duration = 0.3
animation.timingFunction = CAMediaTimingFunction(name: .easeOut)
layer.shadowPath = UIBezierPath(roundedRect: newBounds, cornerRadius: 16).cgPath
layer.add(animation, forKey: "shadowPath")Important: Always set the model value (the property on the layer itself) to the final state. Core Animation operates on a separate presentation layer -- without setting the model value, the layer snaps back when the animation completes.
Docs: CABasicAnimation | CAMediaTimingFunction
CAKeyframeAnimation
`CAKeyframeAnimation` animates a property through a sequence of values or along a path.
Value-Based Keyframes
let animation = CAKeyframeAnimation(keyPath: "transform.scale")
animation.values = [1.0, 1.3, 0.9, 1.05, 1.0]
animation.keyTimes = [0, 0.25, 0.5, 0.75, 1.0] // Normalized [0..1]
animation.duration = 0.6
animation.timingFunctions = [
CAMediaTimingFunction(name: .easeOut),
CAMediaTimingFunction(name: .easeIn),
CAMediaTimingFunction(name: .easeOut),
CAMediaTimingFunction(name: .easeInEaseOut)
]
layer.add(animation, forKey: "bounceScale")Path-Based Animation
// Animate position along a CGPath -- unique to CAKeyframeAnimation
let path = CGMutablePath()
path.move(to: CGPoint(x: 50, y: 300))
path.addCurve(
to: CGPoint(x: 300, y: 50),
control1: CGPoint(x: 100, y: 50),
control2: CGPoint(x: 250, y: 300)
)
let animation = CAKeyframeAnimation(keyPath: "position")
animation.path = path
animation.duration = 1.5
animation.rotationMode = .rotateAuto // Rotate along the tangent
animation.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
layer.add(animation, forKey: "pathAnimation")
layer.position = CGPoint(x: 300, y: 50)Shake Animation (Discrete Keyframes)
func shakeAnimation() -> CAKeyframeAnimation {
let animation = CAKeyframeAnimation(keyPath: "transform.translation.x")
animation.values = [0, -10, 10, -8, 8, -5, 5, 0]
animation.keyTimes = [0, 0.125, 0.25, 0.375, 0.5, 0.625, 0.75, 1.0]
animation.duration = 0.5
animation.timingFunction = CAMediaTimingFunction(name: .easeOut)
return animation
}Docs: CAKeyframeAnimation
CASpringAnimation
`CASpringAnimation` applies spring physics to a layer property. It extends CABasicAnimation with physical spring attributes.
Physical Spring Parameters
let spring = CASpringAnimation(keyPath: "transform.scale")
spring.fromValue = 0.0
spring.toValue = 1.0
spring.mass = 1.0
spring.stiffness = 200.0
spring.damping = 10.0
spring.initialVelocity = 0.0
spring.duration = spring.settlingDuration // Use the physics-calculated duration
layer.add(spring, forKey: "springScale")
layer.transform = CATransform3DIdentityPerceptual Spring (iOS 17+)
let spring = CASpringAnimation(perceptualDuration: 0.5, bounce: 0.3)
spring.keyPath = "position.y"
spring.fromValue = layer.position.y
spring.toValue = layer.position.y - 100
layer.add(spring, forKey: "perceptualSpring")
layer.position.y -= 100The perceptualDuration and bounce initializer matches SwiftUI's Spring(duration:bounce:), making it easier to keep CA and SwiftUI spring behaviors consistent.
Matching SwiftUI Spring Presets
| SwiftUI Preset | CA Equivalent |
|---|---|
.smooth | CASpringAnimation(perceptualDuration: 0.5, bounce: 0.0) |
.snappy | CASpringAnimation(perceptualDuration: 0.4, bounce: 0.15) |
.bouncy | CASpringAnimation(perceptualDuration: 0.5, bounce: 0.3) |
Docs: CASpringAnimation
CAAnimationGroup
`CAAnimationGroup` runs multiple animations concurrently on the same layer.
let scaleAnim = CABasicAnimation(keyPath: "transform.scale")
scaleAnim.fromValue = 0.5
scaleAnim.toValue = 1.0
let opacityAnim = CABasicAnimation(keyPath: "opacity")
opacityAnim.fromValue = 0.0
opacityAnim.toValue = 1.0
let group = CAAnimationGroup()
group.animations = [scaleAnim, opacityAnim]
group.duration = 0.4
group.timingFunction = CAMediaTimingFunction(name: .easeOut)
layer.add(group, forKey: "appearGroup")
layer.transform = CATransform3DIdentity
layer.opacity = 1.0Docs: CAAnimationGroup
CADisplayLink
`CADisplayLink` is a timer synchronized to the display's refresh rate. Use it for frame-accurate custom drawing, particle systems, or manual animation loops.
Basic Display Link
import QuartzCore
final class FrameAnimator {
private var displayLink: CADisplayLink?
private var startTime: CFTimeInterval = 0
func start() {
displayLink = CADisplayLink(target: self, selector: #selector(onFrame))
displayLink?.add(to: .main, forMode: .common)
startTime = CACurrentMediaTime()
}
func stop() {
displayLink?.invalidate()
displayLink = nil
}
@objc private func onFrame(_ link: CADisplayLink) {
let elapsed = link.timestamp - startTime
let progress = min(elapsed / 2.0, 1.0) // 2-second animation
// Update rendering based on progress
updateAnimation(progress: progress)
if progress >= 1.0 {
stop()
}
}
private func updateAnimation(progress: Double) {
// Custom per-frame rendering logic
}
}ProMotion Frame Rate Control
On ProMotion displays (120 Hz), use preferredFrameRateRange to balance smoothness and power:
displayLink?.preferredFrameRateRange = CAFrameRateRange(
minimum: 30,
maximum: 120,
preferred: 60
)| Range | Use Case |
|---|---|
preferred: 120 | Smooth scrolling, gesture tracking |
preferred: 60 | Standard animations |
preferred: 30 | Ambient/slow animations, power saving |
Important: Always call invalidate() when done. A running CADisplayLink prevents the CPU from idling and drains battery.
Docs: CADisplayLink | Optimizing ProMotion refresh rates
UIViewRepresentable Wrapper for CA Layers
To use Core Animation layers inside SwiftUI, wrap them in a UIViewRepresentable.
Animated Layer View
import SwiftUI
import QuartzCore
struct AnimatedLayerView: UIViewRepresentable {
var isAnimating: Bool
var color: Color
func makeUIView(context: Context) -> AnimatedLayerUIView {
let view = AnimatedLayerUIView()
return view
}
func updateUIView(_ uiView: AnimatedLayerUIView, context: Context) {
uiView.updateColor(UIColor(color))
if isAnimating {
uiView.startAnimation()
} else {
uiView.stopAnimation()
}
}
static func dismantleUIView(_ uiView: AnimatedLayerUIView, coordinator: ()) {
uiView.stopAnimation()
}
}The Backing UIView
final class AnimatedLayerUIView: UIView {
private let animationLayer = CAShapeLayer()
private var displayLink: CADisplayLink?
private var phase: CGFloat = 0
override init(frame: CGRect) {
super.init(frame: frame)
setupLayer()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
setupLayer()
}
private func setupLayer() {
animationLayer.fillColor = UIColor.systemBlue.cgColor
animationLayer.strokeColor = nil
layer.addSublayer(animationLayer)
}
override func layoutSubviews() {
super.layoutSubviews()
animationLayer.frame = bounds
updatePath()
}
func updateColor(_ color: UIColor) {
// Animate color change at the CA layer level
let animation = CABasicAnimation(keyPath: "fillColor")
animation.fromValue = animationLayer.fillColor
animation.toValue = color.cgColor
animation.duration = 0.3
animationLayer.fillColor = color.cgColor
animationLayer.add(animation, forKey: "colorChange")
}
func startAnimation() {
guard displayLink == nil else { return }
displayLink = CADisplayLink(target: self, selector: #selector(tick))
displayLink?.preferredFrameRateRange = CAFrameRateRange(
minimum: 30, maximum: 60, preferred: 60
)
displayLink?.add(to: .main, forMode: .common)
}
func stopAnimation() {
displayLink?.invalidate()
displayLink = nil
}
@objc private func tick(_ link: CADisplayLink) {
phase += 0.05
updatePath()
}
private func updatePath() {
let path = CGMutablePath()
let width = bounds.width
let height = bounds.height
let midY = height / 2
path.move(to: CGPoint(x: 0, y: midY))
for x in stride(from: 0, to: width, by: 2) {
let relativeX = x / width
let y = midY + sin((relativeX * .pi * 4) + phase) * (height * 0.3)
path.addLine(to: CGPoint(x: x, y: y))
}
path.addLine(to: CGPoint(x: width, y: height))
path.addLine(to: CGPoint(x: 0, y: height))
path.closeSubpath()
animationLayer.path = path
}
}SwiftUI Usage
struct WaveView: View {
@State private var isAnimating = true
var body: some View {
Button { isAnimating.toggle() } label: {
AnimatedLayerView(isAnimating: isAnimating, color: .blue)
.frame(height: 200)
.clipShape(.rect(cornerRadius: 16))
}
.buttonStyle(.plain)
}
}Key Rules for CA-in-SwiftUI Wrappers
1. Create layers in `makeUIView` or the UIView subclass initializer, not in updateUIView. 2. Stop display links in `dismantleUIView` to prevent leaks and background CPU usage. 3. Guard against redundant animation starts in updateUIView -- it runs on every SwiftUI state change. 4. Set model values alongside CA animations so the layer state is correct after animations complete.
Bridging CA Animations with SwiftUI State
Triggering CA Animations from SwiftUI State Changes
struct PulseButton: UIViewRepresentable {
var pulseCount: Int // Increment to trigger a pulse
func makeUIView(context: Context) -> PulseUIView {
PulseUIView()
}
func updateUIView(_ uiView: PulseUIView, context: Context) {
// Only animate when pulseCount changes, not on every update
if context.coordinator.lastPulseCount != pulseCount {
context.coordinator.lastPulseCount = pulseCount
uiView.pulse()
}
}
func makeCoordinator() -> Coordinator { Coordinator() }
final class Coordinator {
var lastPulseCount = 0
}
}
final class PulseUIView: UIView {
private let pulseLayer = CAShapeLayer()
override init(frame: CGRect) {
super.init(frame: frame)
pulseLayer.fillColor = UIColor.systemBlue.withAlphaComponent(0.3).cgColor
layer.addSublayer(pulseLayer)
}
required init?(coder: NSCoder) { fatalError() }
override func layoutSubviews() {
super.layoutSubviews()
let size = min(bounds.width, bounds.height)
let rect = CGRect(
x: (bounds.width - size) / 2,
y: (bounds.height - size) / 2,
width: size,
height: size
)
pulseLayer.path = UIBezierPath(ovalIn: rect).cgPath
}
func pulse() {
let scaleAnim = CABasicAnimation(keyPath: "transform.scale")
scaleAnim.fromValue = 1.0
scaleAnim.toValue = 1.5
let opacityAnim = CABasicAnimation(keyPath: "opacity")
opacityAnim.fromValue = 1.0
opacityAnim.toValue = 0.0
let group = CAAnimationGroup()
group.animations = [scaleAnim, opacityAnim]
group.duration = 0.6
group.timingFunction = CAMediaTimingFunction(name: .easeOut)
pulseLayer.add(group, forKey: "pulse")
}
}Reading CA Animation Completion in SwiftUI
Use CAAnimationDelegate on the Coordinator to report animation completion back to SwiftUI:
struct AnimatedBadge: UIViewRepresentable {
@Binding var isAnimationComplete: Bool
func makeCoordinator() -> Coordinator { Coordinator(self) }
func makeUIView(context: Context) -> UIView {
let view = UIView()
let badge = CAShapeLayer()
badge.path = UIBezierPath(ovalIn: CGRect(x: 0, y: 0, width: 40, height: 40)).cgPath
badge.fillColor = UIColor.systemRed.cgColor
badge.name = "badge"
view.layer.addSublayer(badge)
return view
}
func updateUIView(_ uiView: UIView, context: Context) {}
func animateIn(_ uiView: UIView) {
guard let badge = uiView.layer.sublayers?.first(where: { $0.name == "badge" }) else { return }
let spring = CASpringAnimation(perceptualDuration: 0.5, bounce: 0.3)
spring.keyPath = "transform.scale"
spring.fromValue = 0.0
spring.toValue = 1.0
spring.delegate = uiView.next as? CAAnimationDelegate
badge.add(spring, forKey: "appear")
badge.transform = CATransform3DIdentity
}
final class Coordinator: NSObject, CAAnimationDelegate {
var parent: AnimatedBadge
init(_ parent: AnimatedBadge) { self.parent = parent }
func animationDidStop(_ anim: CAAnimation, finished flag: Bool) {
if flag {
parent.isAnimationComplete = true
}
}
}
}Performance Considerations
CA vs. SwiftUI Animation Performance
| Aspect | SwiftUI Animation | Core Animation |
|---|---|---|
| Rendering | View diffing + render tree | Direct layer manipulation |
| Thread | Main thread for state, render server for compositing | Same -- render server composites |
| Overhead | SwiftUI body re-evaluation per frame (for animatable) | No body re-evaluation |
| Best for | Standard UI transitions | Particle effects, wave animations, complex paths |
Guidelines
- Avoid mixing CA animations and SwiftUI animations on the same property. They use separate animation systems and will conflict.
- Use `CADisplayLink` sparingly. A running display link prevents the CPU from sleeping. Always invalidate when not needed.
- Prefer `CAShapeLayer` for path-based animations over redrawing in
draw(_:). Shape layers are GPU-accelerated. - Set `shouldRasterize = true` on complex static sublayer trees to cache them as bitmaps, but disable it during animation (rasterization prevents smooth per-frame updates).
- Match CA spring parameters to SwiftUI springs using the
perceptualDuration:bounce:initializer so animations feel consistent across the bridge boundary.
Related skills
FAQ
Which SwiftUI animation APIs does swiftui-animation cover?
swiftui-animation covers CustomAnimation on iOS 17+, Spring and UnitCurve types, PhaseAnimator, KeyframeAnimator multi-track patterns, Transaction keys, scoped implicit animation, and all iOS 17+ transition types.
When should developers invoke swiftui-animation?
Developers should invoke swiftui-animation when building iOS interfaces that need choreographed motion beyond basic withAnimation calls. The skill supplies deep reference examples for Claude Code and Cursor agents.
Is Swiftui Animation safe to install?
skills.sh reports 3 of 3 security scanners passed. Review the Security Audits panel on this page before installing in production.