SwiftUI Animations and Transitions
Comprehensive guide to SwiftUI animations, the new @Animatable macro (iOS 26), transitions, and motion design best practices for modern iOS development.
Prerequisites
-
iOS 17+ for PhaseAnimator/KeyframeAnimator
-
iOS 26+ for @Animatable macro
-
Xcode 26+
@Animatable Macro (iOS 26 - NEW!)
The Revolution in Custom Animations
iOS 26 introduces the @Animatable macro, eliminating the tedious boilerplate previously required for animating custom shapes and views.
Before iOS 26 (Manual Approach)
// OLD WAY - Lots of boilerplate struct PieSlice: Shape { var startAngle: Angle var endAngle: Angle
// Manual animatableData implementation required
var animatableData: AnimatablePair<Double, Double> {
get {
AnimatablePair(startAngle.radians, endAngle.radians)
}
set {
startAngle = Angle(radians: newValue.first)
endAngle = Angle(radians: newValue.second)
}
}
func path(in rect: CGRect) -> Path {
var path = Path()
let center = CGPoint(x: rect.midX, y: rect.midY)
let radius = min(rect.width, rect.height) / 2
path.move(to: center)
path.addArc(center: center, radius: radius,
startAngle: startAngle, endAngle: endAngle,
clockwise: false)
path.closeSubpath()
return path
}
}
After iOS 26 (With @Animatable)
// NEW WAY - Just add @Animatable @Animatable struct PieSlice: Shape { var startAngle: Angle var endAngle: Angle
func path(in rect: CGRect) -> Path {
var path = Path()
let center = CGPoint(x: rect.midX, y: rect.midY)
let radius = min(rect.width, rect.height) / 2
path.move(to: center)
path.addArc(center: center, radius: radius,
startAngle: startAngle, endAngle: endAngle,
clockwise: false)
path.closeSubpath()
return path
}
}
@AnimatableIgnored
Exclude properties from animation:
@Animatable struct CustomShape: Shape { var animatedValue: CGFloat @AnimatableIgnored var staticConfiguration: Bool // Not animated
func path(in rect: CGRect) -> Path {
// Use both values, but only animatedValue will animate
}
}
Supported Types for Animation
The @Animatable macro automatically handles:
-
CGFloat , Double , Float
-
Angle
-
CGSize , CGPoint , CGRect
-
UnitPoint
-
Color (component interpolation)
-
Custom types conforming to VectorArithmetic
Complex Example with Multiple Properties
@Animatable struct MorphingShape: Shape { var cornerRadius: CGFloat var insetAmount: CGFloat var rotation: Angle @AnimatableIgnored var fillColor: Color
func path(in rect: CGRect) -> Path {
let insetRect = rect.insetBy(dx: insetAmount, dy: insetAmount)
var path = Path(roundedRect: insetRect, cornerRadius: cornerRadius)
let transform = CGAffineTransform(rotationAngle: rotation.radians)
return path.applying(transform)
}
}
// Usage struct MorphingView: View { @State private var isExpanded = false
var body: some View {
MorphingShape(
cornerRadius: isExpanded ? 50 : 10,
insetAmount: isExpanded ? 20 : 50,
rotation: isExpanded ? .degrees(45) : .zero,
fillColor: .blue
)
.fill(.blue)
.frame(width: 200, height: 200)
.onTapGesture {
withAnimation(.spring(duration: 0.6, bounce: 0.3)) {
isExpanded.toggle()
}
}
}
}
withAnimation
Basic Usage
struct ContentView: View { @State private var isExpanded = false
var body: some View {
VStack {
Rectangle()
.frame(width: isExpanded ? 200 : 100,
height: isExpanded ? 200 : 100)
Button("Toggle") {
withAnimation {
isExpanded.toggle()
}
}
}
}
}
With Animation Type
withAnimation(.spring(duration: 0.5, bounce: 0.3)) { isExpanded.toggle() }
withAnimation(.easeInOut(duration: 0.3)) { opacity = 1.0 }
withAnimation(.linear(duration: 1.0)) { progress = 1.0 }
Completion Handler (iOS 17+)
withAnimation(.easeInOut(duration: 0.5)) { showDetails = true } completion: { // Called when animation completes print("Animation finished") fetchMoreData() }
Nested Animations with Different Timings
Button("Animate") { withAnimation(.spring(duration: 0.4)) { isExpanded = true }
withAnimation(.easeOut(duration: 0.6).delay(0.2)) {
opacity = 1.0
}
}
Animation Types
Built-in Animations
// Linear - constant speed .linear(duration: 0.3)
// Ease variants - acceleration/deceleration .easeIn(duration: 0.3) // Slow start .easeOut(duration: 0.3) // Slow end .easeInOut(duration: 0.3) // Slow both
// Default (ease in/out) .default
Spring Animations (Modern)
// Modern spring with duration and bounce .spring(duration: 0.5, bounce: 0.3)
// Bounce values: // 0.0 = no bounce (critically damped) // 0.5 = medium bounce // 1.0 = maximum bounce (never settles)
// Extra bounce parameter for overshoot .spring(duration: 0.5, bounce: 0.4, blendDuration: 0.2)
Spring Presets
// Bouncy - playful, energetic .bouncy .bouncy(duration: 0.4, extraBounce: 0.1)
// Snappy - quick, responsive .snappy .snappy(duration: 0.3, extraBounce: 0.05)
// Smooth - gentle, elegant .smooth .smooth(duration: 0.5, extraBounce: 0.0)
Interactive Spring
// For gesture-driven animations .interactiveSpring() .interactiveSpring(response: 0.3, dampingFraction: 0.7)
// Best for drag gestures .interactiveSpring(response: 0.15, dampingFraction: 0.86, blendDuration: 0.25)
Custom Timing Curves
// Bezier curve timing .timingCurve(0.2, 0.8, 0.2, 1.0, duration: 0.5)
// Parameters: (x1, y1, x2, y2) // Start: (0, 0), End: (1, 1) // Control points define the curve shape
Animation Modifiers
// Delay before starting .spring().delay(0.2)
// Speed multiplier .spring().speed(2.0) // 2x faster
// Repeat .linear(duration: 1.0).repeatCount(3) .linear(duration: 1.0).repeatForever()
// Autoreverse .easeInOut(duration: 0.5).repeatForever(autoreverses: true)
Explicit Animation Modifier
Preferred Approach
struct ContentView: View { @State private var scale = 1.0
var body: some View {
Circle()
.scaleEffect(scale)
// Explicit: animate only when scale changes
.animation(.spring, value: scale)
.onTapGesture {
scale = scale == 1.0 ? 1.5 : 1.0
}
}
}
Why Explicit is Better
// AVOID: Implicit animation (animates everything) Circle() .animation(.spring) // Deprecated warning in newer iOS
// PREFER: Explicit animation (precise control) Circle() .animation(.spring, value: specificValue)
Multiple Explicit Animations
struct MultiAnimatedView: View { @State private var scale = 1.0 @State private var opacity = 1.0 @State private var rotation = 0.0
var body: some View {
Rectangle()
.scaleEffect(scale)
.opacity(opacity)
.rotationEffect(.degrees(rotation))
// Different animations for different properties
.animation(.bouncy, value: scale)
.animation(.easeOut(duration: 0.2), value: opacity)
.animation(.spring(duration: 1.0), value: rotation)
}
}
Transitions
Basic Transitions
struct ContentView: View { @State private var showDetail = false
var body: some View {
VStack {
if showDetail {
DetailView()
.transition(.slide)
}
Button("Toggle") {
withAnimation {
showDetail.toggle()
}
}
}
}
}
Built-in Transitions
.transition(.opacity) // Fade in/out .transition(.scale) // Scale from center .transition(.scale(scale: 0.5)) // Scale from 50% .transition(.slide) // Slide from leading edge .transition(.move(edge: .top)) // Move from specific edge .transition(.push(from: .bottom)) // Push with replacement .transition(.offset(x: 100, y: 0)) // Custom offset
Combined Transitions
// Combine multiple transitions .transition(.scale.combined(with: .opacity))
// Chain combinations .transition( .scale(scale: 0.8) .combined(with: .opacity) .combined(with: .offset(y: 20)) )
Asymmetric Transitions
// Different transitions for insert vs removal .transition(.asymmetric( insertion: .scale.combined(with: .opacity), removal: .slide ))
// Common pattern: slide in from one side, out the other .transition(.asymmetric( insertion: .push(from: .trailing), removal: .push(from: .leading) ))
Custom Transitions
extension AnyTransition { static var flipFromBottom: AnyTransition { .modifier( active: FlipModifier(angle: -90), identity: FlipModifier(angle: 0) ) } }
struct FlipModifier: ViewModifier { let angle: Double
func body(content: Content) -> some View {
content
.rotation3DEffect(
.degrees(angle),
axis: (x: 1, y: 0, z: 0)
)
.opacity(angle == 0 ? 1 : 0)
}
}
// Usage DetailView() .transition(.flipFromBottom)
Phase Animator (iOS 17+)
Basic Usage
enum AnimationPhase: CaseIterable { case initial case middle case final
var scale: CGFloat {
switch self {
case .initial: return 1.0
case .middle: return 1.2
case .final: return 1.0
}
}
var opacity: Double {
switch self {
case .initial: return 1.0
case .middle: return 0.5
case .final: return 1.0
}
}
}
struct PulsingView: View { var body: some View { PhaseAnimator(AnimationPhase.allCases) { phase in Circle() .fill(.blue) .scaleEffect(phase.scale) .opacity(phase.opacity) } } }
Triggered Animation
struct TriggerableAnimation: View { @State private var trigger = false
var body: some View {
VStack {
PhaseAnimator(
AnimationPhase.allCases,
trigger: trigger
) { phase in
Star()
.scaleEffect(phase.scale)
.rotationEffect(.degrees(phase.rotation))
}
Button("Animate") {
trigger.toggle()
}
}
}
}
Custom Animation Per Phase
PhaseAnimator(AnimationPhase.allCases) { phase in ContentView(phase: phase) } animation: { phase in switch phase { case .initial: .spring(duration: 0.3) case .middle: .easeOut(duration: 0.2) case .final: .bouncy } }
Keyframe Animator (iOS 17+)
Basic Keyframe Animation
struct AnimationValues { var scale = 1.0 var rotation = 0.0 var yOffset = 0.0 }
struct BouncingView: View { @State private var trigger = false
var body: some View {
Circle()
.fill(.blue)
.frame(width: 100, height: 100)
.keyframeAnimator(
initialValue: AnimationValues(),
trigger: trigger
) { content, value in
content
.scaleEffect(value.scale)
.rotationEffect(.degrees(value.rotation))
.offset(y: value.yOffset)
} keyframes: { _ in
KeyframeTrack(\.scale) {
SpringKeyframe(1.2, duration: 0.2)
SpringKeyframe(0.9, duration: 0.15)
SpringKeyframe(1.0, duration: 0.15)
}
KeyframeTrack(\.rotation) {
LinearKeyframe(0, duration: 0.1)
SpringKeyframe(10, duration: 0.15)
SpringKeyframe(-10, duration: 0.15)
SpringKeyframe(0, duration: 0.1)
}
KeyframeTrack(\.yOffset) {
SpringKeyframe(-30, duration: 0.2)
SpringKeyframe(0, duration: 0.3)
}
}
.onTapGesture {
trigger.toggle()
}
}
}
Keyframe Types
KeyframeTrack(.value) { // Linear interpolation LinearKeyframe(targetValue, duration: 0.3)
// Spring-based interpolation
SpringKeyframe(targetValue, duration: 0.3)
SpringKeyframe(targetValue, duration: 0.3, spring: .bouncy)
// Cubic bezier interpolation
CubicKeyframe(targetValue, duration: 0.3)
// Move without animation
MoveKeyframe(targetValue)
}
Complex Multi-Track Animation
struct ComplexAnimationValues { var xOffset = 0.0 var yOffset = 0.0 var scale = 1.0 var opacity = 1.0 var blur = 0.0 }
struct ComplexAnimation: View { @State private var animating = false
var body: some View {
Image(systemName: "star.fill")
.font(.system(size: 50))
.keyframeAnimator(
initialValue: ComplexAnimationValues(),
repeating: animating
) { content, value in
content
.offset(x: value.xOffset, y: value.yOffset)
.scaleEffect(value.scale)
.opacity(value.opacity)
.blur(radius: value.blur)
} keyframes: { _ in
KeyframeTrack(\.xOffset) {
LinearKeyframe(0, duration: 0.25)
LinearKeyframe(100, duration: 0.5)
LinearKeyframe(100, duration: 0.25)
LinearKeyframe(0, duration: 0.5)
}
KeyframeTrack(\.yOffset) {
SpringKeyframe(-50, duration: 0.5)
SpringKeyframe(0, duration: 0.5)
}
KeyframeTrack(\.scale) {
SpringKeyframe(1.5, duration: 0.25)
SpringKeyframe(1.0, duration: 0.25)
SpringKeyframe(1.2, duration: 0.25)
SpringKeyframe(1.0, duration: 0.25)
}
}
.onAppear {
animating = true
}
}
}
Matched Geometry Effect
Hero Transitions
struct HeroTransition: View { @Namespace private var animation @State private var isExpanded = false
var body: some View {
VStack {
if isExpanded {
// Expanded state
RoundedRectangle(cornerRadius: 20)
.fill(.blue)
.matchedGeometryEffect(id: "card", in: animation)
.frame(width: 300, height: 400)
} else {
// Collapsed state
RoundedRectangle(cornerRadius: 10)
.fill(.blue)
.matchedGeometryEffect(id: "card", in: animation)
.frame(width: 100, height: 100)
}
}
.onTapGesture {
withAnimation(.spring(duration: 0.5, bounce: 0.3)) {
isExpanded.toggle()
}
}
}
}
Tab Bar Selection Indicator
struct TabBar: View { @Namespace private var animation @State private var selectedTab = 0 let tabs = ["Home", "Search", "Profile"]
var body: some View {
HStack {
ForEach(Array(tabs.enumerated()), id: \.offset) { index, title in
Button(title) {
withAnimation(.spring(duration: 0.3)) {
selectedTab = index
}
}
.padding()
.background {
if selectedTab == index {
Capsule()
.fill(.blue.opacity(0.2))
.matchedGeometryEffect(id: "background", in: animation)
}
}
}
}
}
}
Properties for Matched Geometry
.matchedGeometryEffect( id: "identifier", in: namespace, properties: .frame, // What to match: .frame, .position, .size anchor: .center, // Anchor point for matching isSource: true // Whether this is the source of truth )
Content Transition
Text Morphing
struct CounterView: View { @State private var count = 0
var body: some View {
Text("\(count)")
.font(.largeTitle)
.contentTransition(.numericText())
.onTapGesture {
withAnimation {
count += 1
}
}
}
}
Available Content Transitions
// Numeric text morphing .contentTransition(.numericText()) .contentTransition(.numericText(value: count)) .contentTransition(.numericText(countsDown: true))
// Interpolate between text .contentTransition(.interpolate)
// Identity (no transition) .contentTransition(.identity)
// Opacity crossfade .contentTransition(.opacity)
// Symbol effect .contentTransition(.symbolEffect(.replace))
Symbol Effects
SF Symbol Animations
struct SymbolEffectsView: View { @State private var isActive = false
var body: some View {
VStack(spacing: 30) {
// Bounce effect
Image(systemName: "bell.fill")
.symbolEffect(.bounce, value: isActive)
// Pulse effect
Image(systemName: "heart.fill")
.symbolEffect(.pulse)
// Variable color
Image(systemName: "wifi")
.symbolEffect(.variableColor.iterative)
// Scale effect
Image(systemName: "star.fill")
.symbolEffect(.scale.up, isActive: isActive)
// Replace effect
Image(systemName: isActive ? "checkmark.circle" : "circle")
.contentTransition(.symbolEffect(.replace))
Button("Toggle") {
withAnimation {
isActive.toggle()
}
}
}
.font(.largeTitle)
}
}
Symbol Effect Options
// Bounce variations .symbolEffect(.bounce) .symbolEffect(.bounce.up) .symbolEffect(.bounce.down) .symbolEffect(.bounce.byLayer) .symbolEffect(.bounce.wholeSymbol)
// Variable color .symbolEffect(.variableColor) .symbolEffect(.variableColor.iterative) .symbolEffect(.variableColor.reversing) .symbolEffect(.variableColor.cumulative)
// Scale .symbolEffect(.scale.up) .symbolEffect(.scale.down)
// Pulse .symbolEffect(.pulse) .symbolEffect(.pulse.byLayer)
Interactive Animations
Drag Gesture Animation
struct DraggableCard: View { @State private var offset = CGSize.zero @State private var isDragging = false
var body: some View {
RoundedRectangle(cornerRadius: 20)
.fill(.blue)
.frame(width: 200, height: 300)
.offset(offset)
.scaleEffect(isDragging ? 1.05 : 1.0)
.animation(.interactiveSpring(response: 0.3), value: isDragging)
.gesture(
DragGesture()
.onChanged { value in
offset = value.translation
isDragging = true
}
.onEnded { value in
isDragging = false
withAnimation(.spring(duration: 0.5, bounce: 0.3)) {
offset = .zero
}
}
)
}
}
Gesture State for Smooth Tracking
struct SmoothDrag: View { @GestureState private var dragOffset = CGSize.zero @State private var position = CGSize.zero
var body: some View {
Circle()
.fill(.blue)
.frame(width: 100, height: 100)
.offset(
x: position.width + dragOffset.width,
y: position.height + dragOffset.height
)
.animation(.interactiveSpring(), value: dragOffset)
.gesture(
DragGesture()
.updating($dragOffset) { value, state, _ in
state = value.translation
}
.onEnded { value in
position.width += value.translation.width
position.height += value.translation.height
}
)
}
}
Velocity-Based Animation
struct VelocityDrag: View { @State private var offset = CGSize.zero
var body: some View {
RoundedRectangle(cornerRadius: 20)
.fill(.blue)
.frame(width: 200, height: 300)
.offset(offset)
.gesture(
DragGesture()
.onChanged { value in
offset = value.translation
}
.onEnded { value in
// Use velocity for natural-feeling spring back
let velocity = CGSize(
width: value.predictedEndTranslation.width - value.translation.width,
height: value.predictedEndTranslation.height - value.translation.height
)
withAnimation(.spring(
response: 0.5,
dampingFraction: 0.7,
blendDuration: 0
)) {
offset = .zero
}
}
)
}
}
Scroll Animations
Scroll Transition (iOS 17+)
struct ScrollTransitionView: View { var body: some View { ScrollView { LazyVStack(spacing: 20) { ForEach(0..<20) { index in RoundedRectangle(cornerRadius: 12) .fill(.blue.gradient) .frame(height: 100) .scrollTransition { content, phase in content .opacity(phase.isIdentity ? 1 : 0.5) .scaleEffect(phase.isIdentity ? 1 : 0.9) .blur(radius: phase.isIdentity ? 0 : 2) } } } .padding() } } }
Visual Effect Modifier (iOS 17+)
struct ParallaxScroll: View { var body: some View { ScrollView { LazyVStack(spacing: 0) { ForEach(0..<10) { index in Image("photo(index)") .resizable() .aspectRatio(contentMode: .fill) .frame(height: 300) .clipped() .visualEffect { content, proxy in content .offset(y: parallaxOffset(proxy)) } } } } }
func parallaxOffset(_ proxy: GeometryProxy) -> CGFloat {
let frame = proxy.frame(in: .scrollView)
return -frame.minY * 0.3
}
}
Performance Best Practices
- Use Explicit Animations
// GOOD: Explicit animation tied to specific value .animation(.spring, value: isExpanded)
// AVOID: Implicit animation (deprecated, less performant) .animation(.spring)
- Animate Efficiently
// GOOD: Animate transforms (GPU-accelerated) .scaleEffect(scale) .rotationEffect(.degrees(rotation)) .offset(x: offsetX, y: offsetY) .opacity(opacity)
// AVOID: Animating layout-affecting properties when possible // (These trigger re-layout each frame) .frame(width: animatedWidth) .padding(animatedPadding)
- Limit Animation Scope
// GOOD: Animation on specific view ChildView() .animation(.spring, value: childState)
// AVOID: Animation on parent affecting all children ParentView() .animation(.spring, value: anyChange) // All children animate
- Use drawingGroup for Complex Graphics
// For complex composited views ComplexAnimatedShape() .drawingGroup() // Renders to offscreen buffer
- Keep Durations Short
// RECOMMENDED: Under 0.4 seconds for UI feedback .spring(duration: 0.3, bounce: 0.2)
// Reserve longer animations for: // - Onboarding flows // - Celebrations // - State transitions
- Test on Device
// Simulator timing is NOT accurate // Always test animations on physical device // Different devices have different performance characteristics
Common Patterns
Loading Spinner
struct LoadingSpinner: View { @State private var isAnimating = false
var body: some View {
Circle()
.trim(from: 0, to: 0.7)
.stroke(.blue, lineWidth: 4)
.frame(width: 40, height: 40)
.rotationEffect(.degrees(isAnimating ? 360 : 0))
.animation(
.linear(duration: 1.0).repeatForever(autoreverses: false),
value: isAnimating
)
.onAppear {
isAnimating = true
}
}
}
Pulsing Indicator
struct PulsingDot: View { @State private var isPulsing = false
var body: some View {
Circle()
.fill(.green)
.frame(width: 12, height: 12)
.scaleEffect(isPulsing ? 1.2 : 1.0)
.opacity(isPulsing ? 0.6 : 1.0)
.animation(
.easeInOut(duration: 0.8).repeatForever(autoreverses: true),
value: isPulsing
)
.onAppear {
isPulsing = true
}
}
}
Shake Effect
struct ShakeEffect: GeometryEffect { var amount: CGFloat = 10 var shakesPerUnit = 3 var animatableData: CGFloat
func effectValue(size: CGSize) -> ProjectionTransform {
ProjectionTransform(CGAffineTransform(translationX:
amount * sin(animatableData * .pi * CGFloat(shakesPerUnit)),
y: 0))
}
}
// Usage TextField("Email", text: $email) .modifier(ShakeEffect(animatableData: shakeAmount)) .onChange(of: hasError) { withAnimation(.default) { shakeAmount = hasError ? 1 : 0 } }
Official Resources
-
SwiftUI Animations Documentation
-
Animating Views and Transitions
-
PhaseAnimator Documentation
-
KeyframeAnimator Documentation
-
WWDC23: Wind your way through advanced animations