animations-transitions
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,FloatAngleCGSize,CGPoint,CGRectUnitPointColor(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
1. Use Explicit Animations
// GOOD: Explicit animation tied to specific value
.animation(.spring, value: isExpanded)
// AVOID: Implicit animation (deprecated, less performant)
.animation(.spring)
2. 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)
3. 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
4. Use drawingGroup for Complex Graphics
// For complex composited views
ComplexAnimatedShape()
.drawingGroup() // Renders to offscreen buffer
5. 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
6. 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
More from bluewaves-creations/bluewaves-skills
photographer-testino
Generate images in Mario Testino's glamorous vibrant style. Use when users ask for Testino style, high fashion glamour, bold saturated colors, warm luxurious photography, dynamic sensual energy.
35photographer-lindbergh
Generate images in Peter Lindbergh's iconic black and white style. Use when users ask for Lindbergh style, raw authentic beauty, emotional B&W portraits, supermodel aesthetic, or unretouched natural photography.
30photographer-lachapelle
Generate images in David LaChapelle's surreal pop art style. Use when users ask for LaChapelle style, pop surrealism, hyper-saturated colors, theatrical staging, baroque maximalism, kitsch aesthetic.
24epub-creator
Create production-quality EPUB 3 ebooks from markdown and images with automated QA, formatting fixes, and validation. Use when creating ebooks, converting markdown to EPUB, or compiling chapters into a publishable book. Handles markdown quirks, generates TOC, adds covers, and validates output automatically.
22photographer-vonunwerth
Generate images in Ellen von Unwerth's playful vintage style. Use when users ask for von Unwerth style, playful sensuality, vintage film noir, whimsical feminine photography, retro glamour, narrative storytelling.
19photographer-ritts
Generate images in Herb Ritts' sculptural black and white style. Use when users ask for Ritts style, classical Greek aesthetic, sculptural body photography, California golden hour, minimalist athletic portraits.
18