
Ios Animation Implementation
- 47 installs
- 74 repo stars
- Updated July 21, 2026
- existential-birds/beagle
Helps with ai & agent building tasks.
About
ios-animation-implementation is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted development.
- ios-animation-implementation
- AI & Agent Building
- AI-coding skill
Ios Animation Implementation by the numbers
- 47 all-time installs (skills.sh)
- Ranked #7,551 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Jul 28, 2026 (Skillselion catalog sync)
npx skills add https://github.com/existential-birds/beagle --skill ios-animation-implementationAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 47 |
|---|---|
| repo stars | ★ 74 |
| Last updated | July 21, 2026 |
| Repository | existential-birds/beagle ↗ |
What it does
Helps with ai & agent building tasks.
Files
iOS Animation Implementation
Write animation code that uses Apple's frameworks directly. Third-party animation libraries add dependency risk and often lag behind new OS releases — Apple's APIs are well-optimized for the render pipeline and get free improvements with each iOS version.
Before Writing Custom Animation
Check whether the system already handles the motion you need. Apple's HIG: "Many system components automatically include motion, letting you offer familiar and consistent experiences throughout your app." System components also automatically adjust for accessibility settings and input methods — Liquid Glass (iOS 26) responds with greater emphasis to direct touch and produces subdued effects for trackpad. Custom animation can't match this adaptiveness for free, so prefer system-provided motion when it exists.
Skip custom animation when:
- Standard navigation transitions cover your case (push, pop, sheet, fullScreenCover)
- SF Symbol
.symbolEffectprovides the feedback you need .contentTransition(.numericText)handles your data change- The system's default spring on
withAnimationis sufficient
Write custom animation when:
- The system doesn't provide the spatial relationship you need (hero transitions, custom gestures)
- You need coordinated multi-property choreography
- The animation is a signature moment that defines the app's identity
- Gesture-driven interaction requires custom progress mapping
API Selection
Choose the right API for the job. Start with SwiftUI animations (simplest, most declarative), drop to UIKit when you need interactive control, and reach for Core Animation only when you need layer-level precision.
| Need | API | Why |
|---|---|---|
| State-driven property changes | withAnimation / .animation(_:value:) | Declarative, automatic interpolation |
| Multi-step sequenced animation | PhaseAnimator | Discrete phases with per-phase timing |
| Per-property timeline control | KeyframeAnimator | Independent keyframe tracks per property |
| Hero transitions between views | matchedGeometryEffect + Namespace | Geometry matching across view identity |
| Navigation push/pop with zoom | .navigationTransition(.zoom) | iOS 18+ built-in zoom transition |
| Custom view insertion/removal | Transition protocol conformance | TransitionPhase-based modifier |
| In-view content swap | .contentTransition() | Numeric text, interpolation, opacity |
| Scroll-position-based effects | .scrollTransition | Phase-driven scroll-linked animation |
| SF Symbol animation | .symbolEffect() | Bounce, pulse, wiggle, breathe, rotate |
| Interactive/interruptible (UIKit) | UIViewPropertyAnimator | Pause, resume, reverse, scrub |
| Per-layer property animation | CABasicAnimation / CASpringAnimation | Shadow, border, cornerRadius animation |
| Complex choreography (layers) | CAKeyframeAnimation + CAAnimationGroup | Multi-property layer animation |
| Physics simulation | UIDynamicAnimator | Gravity, collision, snap, attachment |
| Haptic feedback paired with animation | .sensoryFeedback modifier | Tied to value changes |
| Animated background gradients | MeshGradient | 2D grid of positioned, animated colors |
Implementation by Category
Detailed patterns and code examples live in the reference files. Load the one that matches your task:
| Task | Reference |
|---|---|
| SwiftUI declarative animations (withAnimation, springs, phase, keyframe) | references/swiftui-animations.md |
| View transitions (navigation, modal, custom Transition protocol) | references/transitions.md |
| Gesture-driven interactive animations | references/gesture-animations.md |
| Core Animation and UIKit animation patterns | references/core-animation.md |
When to Load References
- Writing
withAnimation, spring parameters,PhaseAnimator, orKeyframeAnimator→ swiftui-animations.md - Building navigation transitions, modal presentations,
matchedGeometryEffect, or customTransition→ transitions.md - Implementing drag-to-dismiss, swipe actions, pinch/rotate, or scroll-linked effects → gesture-animations.md
- Working with
CABasicAnimation,UIViewPropertyAnimator, layer animations, or bridging SwiftUI↔UIKit → core-animation.md
Spring Parameters Quick Reference
Springs are the default animation type in modern SwiftUI. Use duration and bounce — not mass/stiffness/damping unless bridging to UIKit/CA.
| Preset | Duration | Bounce | Use Case |
|---|---|---|---|
.smooth | 0.5 | 0.0 | Default transitions, most state changes |
.snappy | 0.3 | 0.15 | Micro-interactions, toggles, quick feedback |
.bouncy | 0.5 | 0.3 | Playful moments, attention-drawing |
.interactiveSpring | 0.15 | 0.0 | Gesture tracking, drag following |
| Custom | varies | varies | .spring(duration: 0.4, bounce: 0.2) |
Accessibility & Multimodal Feedback
Apple's HIG: "Make motion optional" and "supplement visual feedback by also using alternatives like haptics and audio to communicate." Every animation must handle Reduce Motion, and important state changes should use multiple feedback channels — not animation alone.
@Environment(\.accessibilityReduceMotion) private var reduceMotion
// Pattern 1: Conditional animation
withAnimation(reduceMotion ? .none : .spring()) {
isExpanded.toggle()
}
// Pattern 2: Simplified alternative
.animation(reduceMotion ? .easeOut(duration: 0.15) : .spring(duration: 0.5, bounce: 0.3), value: isActive)
// Pattern 3: Skip entirely
if !reduceMotion {
view.phaseAnimator(phases) { /* ... */ }
}Reduce Motion fallback options (from most to least graceful): 1. Crossfade — replace motion with opacity transition 2. Shortened — same animation, much faster (0.1–0.15s), no bounce 3. Instant — .animation(.none) or skip the animation block entirely
Cancellation & Interruptibility
Apple's HIG: "Don't make people wait for an animation to complete before they can do anything, especially if they have to experience the animation more than once." Every animation must be interruptible.
- Spring animations retarget automatically — this is the default and almost always what you want
- For gesture-driven animations, the user is always in control — let them cancel mid-flight
- For sequenced animations (KeyframeAnimator, PhaseAnimator with trigger), ensure the UI remains interactive during playback
- Never disable user interaction during an animation unless there's a critical reason (e.g., destructive action confirmation)
Performance Checklist
- Animate on the render server when possible — Core Animation runs off the main thread, SwiftUI's
drawingGroup()moves rendering to Metal - Avoid animating view identity changes (
.id()modifier) — this destroys and recreates the view - Use
geometryGroup()when parent geometry changes cause child layout anomalies during animation - Provide explicit
shadowPathwhen animating shadows — without it, the system recalculates the path every frame - In lists and scroll views, avoid per-item blur/shadow animations — these cause offscreen rendering for each cell
- Keep
PhaseAnimatorand looping animations lightweight — they run continuously - For frequent interactions, prefer system-provided animation over custom motion — Apple's HIG: "generally avoid adding motion to UI interactions that occur frequently"
- Profile with Instruments → "Animation Hitches" template to find frame drops
Gates (before marking work complete)
Run in order; satisfy each Pass before treating the task as done.
1. API fit — Choose the mechanism from the API Selection table for this task. Pass: You can state in one sentence which “Need” row and API you applied (not a different layer “just because”). 2. Reduce Motion — Custom timing must follow Accessibility & Multimodal Feedback. Pass: Non-trivial motion branches on accessibilityReduceMotion (or you only used system defaults / no custom timing). 3. Interruptibility — Matches Cancellation & Interruptibility. Pass: No blanket allowsHitTesting(false) for the whole animation unless the task explicitly requires it and a short comment says why. 4. Heavy or continuous motion — Loops, PhaseAnimator always-on, per-cell blur/shadow, or other Performance Checklist red flags. Pass: You ran Instruments (Animation Hitches) and captured a note or path to the trace, or you simplified the pattern first and can point to the change.
Core Animation & UIKit
When to Use Core Animation
Drop to Core Animation when you need:
- Layer property animation (shadow, border, cornerRadius) that SwiftUI doesn't directly expose
- Precise timing control synchronized with external events
- Animation on non-UIView layers (CAShapeLayer, CAGradientLayer, CAEmitterLayer)
- Complex layer hierarchies with independent animation timelines
CABasicAnimation
Single-value interpolation from fromValue to toValue.
let animation = CABasicAnimation(keyPath: "shadowOpacity")
animation.fromValue = 0
animation.toValue = 0.5
animation.duration = 0.3
animation.timingFunction = CAMediaTimingFunction(name: .easeOut)
animation.fillMode = .forwards
animation.isRemovedOnCompletion = false
layer.add(animation, forKey: "shadowFadeIn")
// Set the model value to match the animation end state
layer.shadowOpacity = 0.5Always set the model value to the final state. Without this, the layer snaps back when the animation completes and is removed.
CASpringAnimation
Spring physics at the layer level. Subclass of CABasicAnimation.
let spring = CASpringAnimation(keyPath: "transform.scale")
spring.fromValue = 0.8
spring.toValue = 1.0
spring.mass = 1.0
spring.stiffness = 200
spring.damping = 15
spring.initialVelocity = 5
spring.duration = spring.settlingDuration // let the spring calculate its own duration
layer.add(spring, forKey: "scaleSpring")
layer.transform = CATransform3DIdentityUse settlingDuration as the duration — it calculates how long the spring needs based on its parameters.
CAKeyframeAnimation
Multi-value animation with per-segment timing control.
let keyframe = CAKeyframeAnimation(keyPath: "position")
keyframe.values = [
CGPoint(x: 100, y: 100),
CGPoint(x: 200, y: 50),
CGPoint(x: 300, y: 100)
]
keyframe.keyTimes = [0, 0.4, 1.0] // timing as fraction of duration
keyframe.timingFunctions = [
CAMediaTimingFunction(name: .easeOut),
CAMediaTimingFunction(name: .easeIn)
]
keyframe.duration = 0.6
layer.add(keyframe, forKey: "pathAnimation")
layer.position = CGPoint(x: 300, y: 100)Path-Based Animation
Animate along a bezier path.
let pathAnimation = CAKeyframeAnimation(keyPath: "position")
let path = UIBezierPath()
path.move(to: startPoint)
path.addCurve(to: endPoint, controlPoint1: cp1, controlPoint2: cp2)
pathAnimation.path = path.cgPath
pathAnimation.duration = 0.5
pathAnimation.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
pathAnimation.rotationMode = .rotateAuto // orient along path
layer.add(pathAnimation, forKey: "curvedPath")CAAnimationGroup
Combine multiple animations with shared timing.
let group = CAAnimationGroup()
let scale = CABasicAnimation(keyPath: "transform.scale")
scale.fromValue = 1.0
scale.toValue = 1.2
let opacity = CABasicAnimation(keyPath: "opacity")
opacity.fromValue = 1.0
opacity.toValue = 0.0
group.animations = [scale, opacity]
group.duration = 0.3
group.fillMode = .forwards
group.isRemovedOnCompletion = false
layer.add(group, forKey: "scaleAndFade")CATransaction
Control implicit animation parameters or group explicit changes.
// Disable implicit animations
CATransaction.begin()
CATransaction.setDisableActions(true)
layer.position = newPosition
CATransaction.commit()
// Custom duration for implicit animations
CATransaction.begin()
CATransaction.setAnimationDuration(0.5)
CATransaction.setCompletionBlock {
print("Animation complete")
}
layer.opacity = 0
CATransaction.commit()CAShapeLayer Animation
Animate strokeEnd for drawing effects, path for morphing.
// Draw-on animation
let shapeLayer = CAShapeLayer()
shapeLayer.path = circlePath.cgPath
shapeLayer.strokeEnd = 0
shapeLayer.lineWidth = 3
shapeLayer.strokeColor = UIColor.blue.cgColor
shapeLayer.fillColor = nil
view.layer.addSublayer(shapeLayer)
let draw = CABasicAnimation(keyPath: "strokeEnd")
draw.fromValue = 0
draw.toValue = 1
draw.duration = 1.0
draw.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
shapeLayer.strokeEnd = 1
shapeLayer.add(draw, forKey: "drawCircle")UIView.animate
Block-based UIView animation. Simple and sufficient for most UIKit needs.
UIView.animate(withDuration: 0.3, delay: 0, options: [.curveEaseOut]) {
view.alpha = 0
view.transform = CGAffineTransform(scaleX: 0.9, y: 0.9)
} completion: { _ in
view.removeFromSuperview()
}
// Spring
UIView.animate(
withDuration: 0.5,
delay: 0,
usingSpringWithDamping: 0.7,
initialSpringVelocity: 0.5,
options: []
) {
view.center = targetPoint
}iOS 18: SwiftUI Animation in UIKit
Use SwiftUI Animation types directly in UIKit.
// Direct usage
UIView.animate(.spring(duration: 0.5, bounce: 0.2)) {
view.center = newCenter
}
// In UIViewRepresentable — bridging SwiftUI transaction animations
struct BridgedView: UIViewRepresentable {
var offset: CGFloat
func updateUIView(_ uiView: UIView, context: Context) {
context.animate {
uiView.transform = CGAffineTransform(translationX: offset, y: 0)
}
}
}The context.animate call automatically uses whatever animation is active in the SwiftUI transaction — if the parent used withAnimation(.spring()), the UIView change inherits that spring.
UIViewPropertyAnimator
Full lifecycle control: create, start, pause, resume, reverse, scrub.
let animator = UIViewPropertyAnimator(
duration: 0.4,
timingParameters: UISpringTimingParameters(dampingRatio: 0.8)
)
animator.addAnimations {
view.transform = CGAffineTransform(translationX: 0, y: -200)
view.alpha = 0
}
animator.addCompletion { position in
if position == .end {
view.removeFromSuperview()
}
}
// Interactive scrubbing
animator.pauseAnimation()
animator.fractionComplete = gestureProgress // 0.0 to 1.0
// Resume with spring
animator.continueAnimation(
withTimingParameters: UISpringTimingParameters(dampingRatio: 0.85),
durationFactor: 0.5 // fraction of remaining duration
)UIDynamicAnimator
Physics-based animations using behavior composition.
let animator = UIDynamicAnimator(referenceView: containerView)
// Snap behavior — spring to a point
let snap = UISnapBehavior(item: cardView, snapTo: targetPoint)
snap.damping = 0.5
animator.addBehavior(snap)
// Gravity + collision for falling effect
let gravity = UIGravityBehavior(items: [cardView])
let collision = UICollisionBehavior(items: [cardView])
collision.translatesReferenceBoundsIntoBoundary = true
animator.addBehavior(gravity)
animator.addBehavior(collision)
// Item properties
let behavior = UIDynamicItemBehavior(items: [cardView])
behavior.elasticity = 0.6
behavior.friction = 0.2
animator.addBehavior(behavior)Use sparingly — physics simulations are powerful but can produce unpredictable results if behaviors conflict. For most cases, spring animations achieve a similar feel with more control.
Performance Notes
- Core Animation runs on the render server (separate process), not the main thread
- Avoid animating properties that trigger offscreen rendering:
cornerRadius+masksToBounds, complexshadowPath,shouldRasterizeon frequently changing layers - Provide explicit
shadowPath— without it, the system must calculate the shadow from the layer's composited alpha every frame shouldRasterize = truecaches the layer's rendered content — good for static complex layers, bad for layers that change frequently (the cache gets invalidated)- Group related layer changes in
CATransactionto batch commits
Gesture-Driven Animations
Gesture + Spring Completion Pattern
The core pattern: track gesture state, apply to view, animate to final position on release.
struct DraggableCard: View {
@State private var offset: CGSize = .zero
@State private var isDragging = false
var body: some View {
CardView()
.offset(offset)
.gesture(
DragGesture()
.onChanged { value in
offset = value.translation
isDragging = true
}
.onEnded { value in
let threshold: CGFloat = 150
let velocity = value.predictedEndTranslation
if abs(value.translation.width) > threshold ||
abs(velocity.width) > 500 {
// Dismiss
withAnimation(.spring(duration: 0.3, bounce: 0.0)) {
offset = CGSize(
width: velocity.width > 0 ? 500 : -500,
height: value.translation.height
)
}
} else {
// Snap back
withAnimation(.spring(duration: 0.5, bounce: 0.2)) {
offset = .zero
}
}
isDragging = false
}
)
.animation(.interactiveSpring, value: isDragging)
}
}GestureState for Transient Values
@GestureState automatically resets when the gesture ends — useful for tracking intermediate state.
@GestureState private var dragOffset: CGSize = .zero
var body: some View {
CardView()
.offset(dragOffset)
.gesture(
DragGesture()
.updating($dragOffset) { value, state, _ in
state = value.translation
}
)
.animation(.interactiveSpring, value: dragOffset)
}Limitation: @GestureState resets instantly. If you need animated return-to-origin, use @State with explicit onEnded spring.
Velocity-Preserving Completion
Capture gesture velocity and pass it to the completion spring for natural-feeling release.
.onEnded { value in
let dx = targetOffset - offset.width
let dy = targetOffset - offset.height
let velocity = CGVector(
dx: abs(dx) > 1 ? value.velocity.width / dx : 0,
dy: abs(dy) > 1 ? value.velocity.height / dy : 0
)
withAnimation(.interpolatingSpring(
stiffness: 200,
damping: 25,
initialVelocity: sqrt(velocity.dx * velocity.dx + velocity.dy * velocity.dy)
)) {
offset = targetOffset
}
}Rubber-Banding
When dragging past bounds, resistance increases logarithmically.
func rubberBand(_ offset: CGFloat, limit: CGFloat, coefficient: CGFloat = 0.55) -> CGFloat {
let absOffset = abs(offset)
guard absOffset > limit else { return offset }
let overflow = absOffset - limit
let dampened = limit + coefficient * overflow / (1 + coefficient * overflow / limit)
return offset > 0 ? dampened : -dampened
}
// Usage
.offset(y: rubberBand(dragOffset.height, limit: maxDrag))Interactive Dismiss with Progress
Map gesture progress to a 0–1 dismissal progress for visual feedback.
struct InteractiveDismiss: View {
@State private var offset: CGFloat = 0
@Environment(\.dismiss) private var dismiss
private var progress: CGFloat {
min(1, max(0, offset / 300))
}
var body: some View {
ContentView()
.offset(y: offset)
.scaleEffect(1 - progress * 0.1)
.opacity(1 - progress * 0.3)
.background {
Color.black.opacity(0.5 * (1 - progress))
.ignoresSafeArea()
}
.gesture(
DragGesture()
.onChanged { value in
offset = max(0, value.translation.height)
}
.onEnded { value in
if progress > 0.4 || value.velocity.height > 600 {
withAnimation(.spring(duration: 0.3)) {
offset = 1000 // large enough to animate offscreen
}
// No withAnimation completion API in SwiftUI — asyncAfter
// is the pragmatic approach. Keep duration in sync with spring above.
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
dismiss()
}
} else {
withAnimation(.spring(duration: 0.4, bounce: 0.15)) {
offset = 0
}
}
}
)
}
}Magnification (Pinch to Zoom)
struct ZoomableImage: View {
@State private var currentScale: CGFloat = 1.0
@GestureState private var gestureScale: CGFloat = 1.0
private var effectiveScale: CGFloat {
max(1.0, min(5.0, currentScale * gestureScale))
}
var body: some View {
Image("photo")
.resizable()
.scaledToFit()
.scaleEffect(effectiveScale)
.gesture(
MagnifyGesture()
.updating($gestureScale) { value, state, _ in
state = value.magnification
}
.onEnded { value in
withAnimation(.spring(duration: 0.3, bounce: 0.1)) {
currentScale = max(1.0, min(5.0, currentScale * value.magnification))
}
}
)
.onTapGesture(count: 2) {
withAnimation(.spring(duration: 0.35, bounce: 0.15)) {
currentScale = currentScale > 1.0 ? 1.0 : 2.0
}
}
}
}Rotation Gesture
@State private var rotation: Angle = .zero
@GestureState private var gestureRotation: Angle = .zero
var body: some View {
DialView()
.rotationEffect(rotation + gestureRotation)
.gesture(
RotateGesture()
.updating($gestureRotation) { value, state, _ in
state = value.rotation
}
.onEnded { value in
withAnimation(.spring(duration: 0.3)) {
// Snap to nearest 45°
let total = (rotation + value.rotation).degrees
let snapped = (total / 45).rounded() * 45
rotation = .degrees(snapped)
}
}
)
}Scroll-Linked Animations
scrollTransition
Phase-based animation tied to scroll position.
.scrollTransition(.animated(.spring(duration: 0.3))) { content, phase in
content
.opacity(phase.isIdentity ? 1 : 0.4)
.scaleEffect(phase.isIdentity ? 1 : 0.9)
.rotationEffect(.degrees(phase.isIdentity ? 0 : phase.value * 5))
}Parallax Effect
Different scroll speeds for background vs foreground.
ScrollView {
GeometryReader { geo in
let minY = geo.frame(in: .global).minY
Image("hero")
.resizable()
.scaledToFill()
.offset(y: minY > 0 ? -minY * 0.5 : 0) // parallax factor
.frame(height: 300 + (minY > 0 ? minY : 0))
}
.frame(height: 300)
// Regular content below
ContentView()
}UIKit: UIViewPropertyAnimator for Interactive Animations
When you need interactive scrubbing or need to bridge with UIKit navigation.
class InteractiveDismissAnimator {
private var animator: UIViewPropertyAnimator?
func beginInteraction() {
animator = UIViewPropertyAnimator(
duration: 0.5,
dampingRatio: 0.9
) {
self.presentedView.transform = CGAffineTransform(
translationX: 0,
y: self.presentedView.bounds.height
)
self.dimmingView.alpha = 0
}
animator?.pauseAnimation()
}
func updateInteraction(progress: CGFloat) {
animator?.fractionComplete = progress
}
func endInteraction(shouldComplete: Bool) {
if shouldComplete {
animator?.continueAnimation(
withTimingParameters: UISpringTimingParameters(dampingRatio: 0.85),
durationFactor: 0.5
)
} else {
animator?.isReversed = true
animator?.continueAnimation(
withTimingParameters: UISpringTimingParameters(dampingRatio: 0.9),
durationFactor: 0.3
)
}
}
}SwiftUI Animations
withAnimation vs .animation(_:value:)
withAnimation wraps a state change — all views affected by that state animate. Use when the animation is tied to an event (tap, toggle, data load).
.animation(_:value:) attaches to a specific view and animates whenever value changes. Use when the animation should happen every time a binding or state property updates, regardless of what triggered it.
// BAD — deprecated form without value:
.animation(.spring())
// GOOD — explicit value binding
.animation(.spring(), value: isExpanded)
// GOOD — event-driven
Button("Toggle") {
withAnimation(.snappy) {
isExpanded.toggle()
}
}Scoping withAnimation
withAnimation should wrap the minimal state mutation, not the entire action.
// BAD — wraps unrelated work
withAnimation {
viewModel.loadData() // network call doesn't need animation
isLoading = false
items = newItems
}
// GOOD — only animate the state change
viewModel.loadData()
withAnimation(.smooth) {
isLoading = false
items = newItems
}Spring Animations
Springs are the default in iOS 17+. Specify with duration and bounce.
// Named presets
withAnimation(.smooth) { } // duration: 0.5, bounce: 0.0
withAnimation(.snappy) { } // duration: 0.3, bounce: 0.15
withAnimation(.bouncy) { } // duration: 0.5, bounce: 0.3
// Custom tuning
withAnimation(.spring(duration: 0.4, bounce: 0.2)) { }
// With extra bounce on a preset
withAnimation(.snappy(extraBounce: 0.1)) { }Custom Timing Curves
For precise easing control beyond the built-in presets, define a cubic Bézier curve with two control points.
// Cubic Bézier — control points (x1, y1) and (x2, y2)
withAnimation(.timingCurve(0.68, -0.55, 0.27, 1.55, duration: 0.4)) { }
// Equivalent to CSS ease-in-out
withAnimation(.timingCurve(0.42, 0, 0.58, 1, duration: 0.3)) { }The built-in easing functions are shorthand for specific curves:
| Preset | Bézier Approximation |
|---|---|
.easeIn | (0.42, 0, 1, 1) |
.easeOut | (0, 0, 0.58, 1) |
.easeInOut | (0.42, 0, 0.58, 1) |
.linear | (0, 0, 1, 1) |
Repeating Animations
Chain .repeatCount(_:autoreverses:) or .repeatForever(autoreverses:) onto any animation to loop it.
// Pulse 3 times then stop
withAnimation(.easeInOut(duration: 0.3).repeatCount(3, autoreverses: true)) {
isPulsing.toggle()
}
// Continuous rotation (no autoreverse for smooth loop)
withAnimation(.linear(duration: 2).repeatForever(autoreverses: false)) {
rotationAngle = .degrees(360)
}
// Bouncy repeat with autoreverse
.animation(
.spring(duration: 0.5, bounce: 0.3).repeatCount(2, autoreverses: true),
value: isActive
)autoreverses: true (default) plays the animation forward then backward per cycle. Set to false for one-directional loops like rotation or progress.
Speed and Delay
Modify animation timing without changing the curve itself.
// Half-speed spring (takes twice as long)
withAnimation(.spring().speed(0.5)) {
isExpanded.toggle()
}
// Delay start by 0.3s (useful for staggered sequences)
withAnimation(.snappy.delay(0.3)) {
showSecondElement = true
}
// Combined — staggered cards
ForEach(Array(items.enumerated()), id: \.element.id) { index, item in
CardView(item: item)
.animation(
.spring(duration: 0.4).delay(Double(index) * 0.05),
value: isVisible
)
}Transaction Control
Transaction lets you override or suppress animations at the point of a state change, useful when you need different animation behavior than what the view's .animation() modifier provides.
// Suppress all animations for a state change
var transaction = Transaction()
transaction.disablesAnimations = true
withTransaction(transaction) {
selectedItem = newItem // updates instantly, no animation
}
// Override with a custom animation
var transaction = Transaction(animation: .spring(duration: 0.6, bounce: 0.2))
withTransaction(transaction) {
isExpanded = true
}
// Inside a view modifier — read and modify the current transaction
.transaction { transaction in
if skipAnimation {
transaction.animation = nil
}
}Use withTransaction over withAnimation when you need to disable animations or override them for a specific state change without affecting the view's default animation modifiers.
PhaseAnimator
Cycles through discrete phases. Each transition between phases is a separate animation. Use for multi-step sequences.
enum PulsePhase: CaseIterable {
case idle, scale, reset
}
Circle()
.phaseAnimator(PulsePhase.allCases) { content, phase in
content
.scaleEffect(phase == .scale ? 1.3 : 1.0)
.opacity(phase == .scale ? 0.7 : 1.0)
} animation: { phase in
switch phase {
case .idle: .easeOut(duration: 0.3)
case .scale: .spring(duration: 0.4, bounce: 0.3)
case .reset: .easeInOut(duration: 0.5)
}
}Triggered PhaseAnimator
Pass a trigger value to run the sequence once per change instead of continuously.
.phaseAnimator(PulsePhase.allCases, trigger: tapCount) { content, phase in
// ...
}KeyframeAnimator
Per-property timeline control. Each property gets its own KeyframeTrack with independent timing curves and durations.
struct AnimationValues {
var scale: Double = 1.0
var rotation: Angle = .zero
var yOffset: Double = 0
}
Text("🎉")
.keyframeAnimator(initialValue: AnimationValues(), trigger: celebrate) { content, value in
content
.scaleEffect(value.scale)
.rotationEffect(value.rotation)
.offset(y: value.yOffset)
} keyframes: { _ in
KeyframeTrack(\.scale) {
SpringKeyframe(1.5, duration: 0.2)
SpringKeyframe(1.0, duration: 0.3)
}
KeyframeTrack(\.rotation) {
LinearKeyframe(.degrees(-15), duration: 0.1)
SpringKeyframe(.degrees(15), duration: 0.15)
SpringKeyframe(.zero, duration: 0.25)
}
KeyframeTrack(\.yOffset) {
SpringKeyframe(-20, duration: 0.2)
SpringKeyframe(0, duration: 0.3)
}
}Keyframe Types
| Type | Interpolation | Use Case |
|---|---|---|
LinearKeyframe | Constant rate | Rotation, progress bars |
SpringKeyframe | Spring physics | Most property animations |
CubicKeyframe | Bezier curve | Precise easing control |
MoveKeyframe | Instant jump | Reset to new position without interpolation |
Content Transitions
Animate content changes within a view (not insertion/removal).
// Animated number change
Text(score, format: .number)
.contentTransition(.numericText(countsDown: score < previousScore))
// Symbol replacement
Image(systemName: isFavorite ? "heart.fill" : "heart")
.contentTransition(.symbolEffect(.replace))
// General interpolation
Text(statusMessage)
.contentTransition(.interpolate)Symbol Effects
SF Symbols 5+ (iOS 17) and SF Symbols 6 (iOS 18) animations.
// Discrete (trigger once)
Image(systemName: "bell")
.symbolEffect(.bounce, value: notificationCount)
// Continuous
Image(systemName: "network")
.symbolEffect(.pulse)
// iOS 18
Image(systemName: "arrow.trianglehead.clockwise")
.symbolEffect(.rotate)
Image(systemName: "bell")
.symbolEffect(.wiggle, value: hasAlert)
Image(systemName: "circle")
.symbolEffect(.breathe)Scroll Transitions
Animate views based on their position in a scroll view.
ScrollView {
LazyVStack {
ForEach(items) { item in
ItemCard(item: item)
.scrollTransition { content, phase in
content
.opacity(phase.isIdentity ? 1 : 0.3)
.scaleEffect(phase.isIdentity ? 1 : 0.85)
.blur(radius: phase.isIdentity ? 0 : 2)
}
}
}
}ScrollTransitionPhase: .topLeading (entering from top/leading), .identity (fully visible), .bottomTrailing (exiting to bottom/trailing).
Mesh Gradients (iOS 18+)
Animated background gradients using a 2D control point grid.
struct AnimatedMeshBackground: View {
@State private var phase: CGFloat = 0
var body: some View {
MeshGradient(
width: 3, height: 3,
points: [
[0, 0], [0.5, 0], [1, 0],
[0, 0.5], [0.5 + 0.1 * sin(phase), 0.5 + 0.1 * cos(phase)], [1, 0.5],
[0, 1], [0.5, 1], [1, 1]
],
colors: [.blue, .purple, .indigo, .cyan, .mint, .teal, .blue, .purple, .indigo]
)
.onAppear {
withAnimation(.linear(duration: 5).repeatForever(autoreverses: false)) {
phase = .pi * 2
}
}
}
}Sensory Feedback
Pair haptics with animations for reinforcement.
Button("Like") {
withAnimation(.bouncy) { isLiked.toggle() }
}
.sensoryFeedback(.impact(flexibility: .soft, intensity: 0.7), trigger: isLiked)
// Other useful feedback types
.sensoryFeedback(.selection, trigger: selectedTab) // tab switch
.sensoryFeedback(.success, trigger: taskCompleted) // completion
.sensoryFeedback(.warning, trigger: errorOccurred) // alert
.sensoryFeedback(.increase, trigger: count) // incrementCustom Animation Protocol (iOS 17+)
For animation behaviors not covered by built-in types.
struct DecayAnimation: CustomAnimation {
let decayRate: Double
func animate<V: VectorArithmetic>(value: V, time: Double, context: inout AnimationContext<V>) -> V? {
let factor = pow(decayRate, time)
if factor < 0.001 { return nil } // animation complete
return value.scaled(by: factor)
}
}
extension Animation {
static func decay(rate: Double = 0.998) -> Animation {
Animation(DecayAnimation(decayRate: rate))
}
}Transitions
Custom Transition Protocol (iOS 17+)
Define reusable view insertion/removal animations.
struct SlideAndFade: Transition {
var edge: Edge
func body(content: Content, phase: TransitionPhase) -> some View {
content
.opacity(phase.isIdentity ? 1 : 0)
.offset(
x: phase == .willAppear ? (edge == .leading ? -50 : 50) :
phase == .didDisappear ? (edge == .leading ? -50 : 50) : 0
)
}
}
extension AnyTransition {
static func slideAndFade(from edge: Edge) -> AnyTransition {
.init(SlideAndFade(edge: edge))
}
}
// Usage
if showDetail {
DetailView()
.transition(.slideAndFade(from: .trailing))
}TransitionPhase
| Phase | Meaning | Typical Use |
|---|---|---|
.willAppear | View is about to insert | Set "before" state (offscreen, transparent) |
.identity | View is fully presented | Normal appearance |
.didDisappear | View is about to remove | Set "after" state (offscreen, transparent) |
Asymmetric Transitions
Different animations for insertion vs removal.
.transition(.asymmetric(
insertion: .move(edge: .bottom).combined(with: .opacity),
removal: .scale(scale: 0.8).combined(with: .opacity)
))matchedGeometryEffect
Hero transitions between views using shared geometry.
struct ContentView: View {
@Namespace private var animation
@State private var isExpanded = false
var body: some View {
if isExpanded {
ExpandedCard(namespace: animation)
.onTapGesture {
withAnimation(.spring(duration: 0.45, bounce: 0.15)) {
isExpanded = false
}
}
} else {
CompactCard(namespace: animation)
.onTapGesture {
withAnimation(.spring(duration: 0.45, bounce: 0.15)) {
isExpanded = true
}
}
}
}
}
struct CompactCard: View {
var namespace: Namespace.ID
var body: some View {
RoundedRectangle(cornerRadius: 12)
.fill(.blue)
.matchedGeometryEffect(id: "card", in: namespace)
.frame(width: 100, height: 100)
}
}
struct ExpandedCard: View {
var namespace: Namespace.ID
var body: some View {
RoundedRectangle(cornerRadius: 24)
.fill(.blue)
.matchedGeometryEffect(id: "card", in: namespace)
.frame(maxWidth: .infinity, maxHeight: 400)
}
}Common Pitfalls
1. Both views visible simultaneously — only one view with a given matched ID should be in the hierarchy at a time. Use if/else, not opacity toggling.
2. Unstable IDs — the id parameter must be the same value on both sides. Use a model ID, not an index.
3. Missing `isSource` — when matching multiple properties (position and size separately), set isSource: true on the view that defines the geometry, isSource: false on the one that follows.
4. Missing `geometryGroup()` — when the parent's geometry is also changing (e.g., the parent frame animates), wrap the parent with .geometryGroup() to isolate layout resolution.
Zoom Navigation Transition (iOS 18+)
Built-in zoom-style push/pop for NavigationStack.
struct GridView: View {
@Namespace private var namespace
var body: some View {
NavigationStack {
LazyVGrid(columns: columns) {
ForEach(items) { item in
NavigationLink {
DetailView(item: item)
.navigationTransition(.zoom(sourceID: item.id, in: namespace))
} label: {
ItemCell(item: item)
.matchedTransitionSource(id: item.id, in: namespace)
}
}
}
}
}
}Works with:
NavigationStackpush/pop.fullScreenCover.sheet
Automatically handles back-gesture interruption and is continuously interactive.
Full-Screen Cover Transitions
.fullScreenCover uses system-managed presentation animation. The .transition() modifier does not affect modal presentation — it only applies to views conditionally inserted/removed within the same hierarchy.
To customize full-screen cover appearance, animate content inside the cover view (e.g., in .onAppear), or use navigationTransition(.zoom) on iOS 18+.
For zoom-style cover transitions on iOS 18+:
.fullScreenCover(isPresented: $showPhoto) {
PhotoViewer(photo: selectedPhoto)
.navigationTransition(.zoom(sourceID: selectedPhoto.id, in: namespace))
}Sheet Presentation Transitions
Sheets have system-managed presentation animation. To customize the content appearance within the sheet:
.sheet(isPresented: $showSettings) {
SettingsView()
.presentationDetents([.medium, .large])
.presentationDragIndicator(.visible)
}Sheet content that changes state can animate internally with standard withAnimation.
Tab View Transitions
Animate tab switches with content transitions.
TabView(selection: $selectedTab) {
// tabs
}
// Custom transition between tab content
.onChange(of: selectedTab) {
withAnimation(.snappy) {
// trigger any state changes for the new tab
}
}For custom tab-like views, use matchedGeometryEffect on the tab indicator:
HStack {
ForEach(tabs) { tab in
Button(tab.title) { selectedTab = tab }
.background {
if selectedTab == tab {
Capsule()
.fill(.blue)
.matchedGeometryEffect(id: "indicator", in: namespace)
}
}
}
}NavigationStack Path Animation
Animate programmatic navigation changes.
@State private var path = NavigationPath()
NavigationStack(path: $path) { /* ... */ }
// Animated push
withAnimation(.smooth) {
path.append(destination)
}
// Animated pop to root
withAnimation(.smooth) {
path.removeLast(path.count)
}