Lab 5.2 — Animated dashboard

Goal

Build a dashboard with animated metric cards, a Canvas-drawn bar chart, a PhaseAnimator entrance animation, and matchedGeometryEffect for tap-to-expand transitions. Practice the animation primitives from chapter 5.7.

By the end you’ll have a portfolio-grade animated UI showcase — the kind of work that shows up in design-conference SwiftUI talks.

Time

90–120 minutes.

Prereqs

  • Xcode 16+, iOS 17+
  • Chapter 5.7 (animations & transitions)

Setup

  1. New iOS App, SwiftUI, no SwiftData needed.
  2. Name: DashboardLab.

Build

1. Data

Metric.swift:

import Foundation

struct Metric: Identifiable, Hashable {
    let id = UUID()
    let title: String
    let value: Double
    let unit: String
    let trend: Double      // % change
    let sparkline: [Double]
}

extension Metric {
    static let sample: [Metric] = [
        Metric(title: "Revenue", value: 124_320, unit: "$",
               trend: 12.4, sparkline: [10, 12, 14, 11, 15, 18, 20]),
        Metric(title: "Users", value: 8_421, unit: "",
               trend: -2.1, sparkline: [40, 38, 36, 35, 33, 34, 36]),
        Metric(title: "Sessions", value: 32_115, unit: "",
               trend: 5.8, sparkline: [100, 105, 110, 108, 115, 120, 125]),
        Metric(title: "Conversion", value: 3.42, unit: "%",
               trend: 0.4, sparkline: [3.2, 3.3, 3.25, 3.4, 3.35, 3.45, 3.42]),
    ]
}

2. Entry animation with PhaseAnimator

DashboardView.swift:

import SwiftUI

struct DashboardView: View {
    @State private var animateIn = false
    @State private var expanded: Metric?
    @Namespace private var ns

    private let metrics = Metric.sample

    var body: some View {
        ZStack {
            ScrollView {
                LazyVGrid(columns: [.init(.adaptive(minimum: 160), spacing: 16)], spacing: 16) {
                    ForEach(Array(metrics.enumerated()), id: \.element.id) { idx, metric in
                        if expanded?.id != metric.id {
                            MetricCard(metric: metric)
                                .matchedGeometryEffect(id: metric.id, in: ns)
                                .onTapGesture {
                                    withAnimation(.spring(duration: 0.4, bounce: 0.25)) {
                                        expanded = metric
                                    }
                                }
                                .phaseAnimator([0, 1], trigger: animateIn) { content, phase in
                                    content
                                        .opacity(phase)
                                        .scaleEffect(phase == 0 ? 0.85 : 1)
                                        .offset(y: phase == 0 ? 20 : 0)
                                } animation: { _ in
                                    .spring(duration: 0.5, bounce: 0.25).delay(Double(idx) * 0.06)
                                }
                        } else {
                            Color.clear
                                .frame(height: 1)
                        }
                    }
                }
                .padding()
            }
            .navigationTitle("Dashboard")

            if let expanded {
                ExpandedCard(metric: expanded, namespace: ns) {
                    withAnimation(.spring(duration: 0.4, bounce: 0.2)) {
                        self.expanded = nil
                    }
                }
                .matchedGeometryEffect(id: expanded.id, in: ns)
                .padding()
                .transition(.opacity)
            }
        }
        .onAppear { animateIn = true }
    }
}

The trick:

  • Each card uses matchedGeometryEffect(id:in:) with its metric id.
  • When tapped, expanded = metric; we set the placeholder Color.clear in the grid position and render the ExpandedCard (also matchedGeometryEffect’d to the same id) — SwiftUI animates the geometry transition.
  • PhaseAnimator runs the entry animation on each card with a staggered delay.

3. Metric card

MetricCard.swift:

import SwiftUI

struct MetricCard: View {
    let metric: Metric

    var body: some View {
        VStack(alignment: .leading, spacing: 8) {
            HStack {
                Text(metric.title)
                    .font(.caption)
                    .foregroundStyle(.secondary)
                Spacer()
                TrendBadge(trend: metric.trend)
            }
            Text(formatted)
                .font(.title2.bold())
                .contentTransition(.numericText())
            SparklineView(values: metric.sparkline)
                .frame(height: 40)
                .foregroundStyle(metric.trend >= 0 ? .green : .red)
        }
        .padding()
        .background(.regularMaterial, in: .rect(cornerRadius: 16))
    }

    private var formatted: String {
        if metric.unit == "$" {
            "$\(Int(metric.value).formatted())"
        } else if metric.unit == "%" {
            String(format: "%.2f%%", metric.value)
        } else {
            "\(Int(metric.value).formatted())"
        }
    }
}

struct TrendBadge: View {
    let trend: Double
    var body: some View {
        Label("\(trend, specifier: "%+.1f")%", systemImage: trend >= 0 ? "arrow.up.right" : "arrow.down.right")
            .font(.caption2.weight(.semibold))
            .foregroundStyle(trend >= 0 ? .green : .red)
            .padding(.horizontal, 6).padding(.vertical, 3)
            .background((trend >= 0 ? Color.green : .red).opacity(0.15), in: .capsule)
    }
}

4. Canvas sparkline

SparklineView.swift:

import SwiftUI

struct SparklineView: View {
    let values: [Double]

    var body: some View {
        Canvas { context, size in
            guard values.count > 1 else { return }
            let minV = values.min() ?? 0
            let maxV = values.max() ?? 1
            let range = max(maxV - minV, 0.0001)

            var path = Path()
            for (idx, value) in values.enumerated() {
                let x = CGFloat(idx) / CGFloat(values.count - 1) * size.width
                let y = size.height - CGFloat((value - minV) / range) * size.height
                if idx == 0 {
                    path.move(to: CGPoint(x: x, y: y))
                } else {
                    path.addLine(to: CGPoint(x: x, y: y))
                }
            }

            context.stroke(path, with: .foreground, lineWidth: 2)

            // Fill below the line
            var fillPath = path
            fillPath.addLine(to: CGPoint(x: size.width, y: size.height))
            fillPath.addLine(to: CGPoint(x: 0, y: size.height))
            fillPath.closeSubpath()
            context.fill(fillPath, with: .foreground.opacity(0.2))
        }
    }
}

Canvas is the imperative drawing API — fast, ideal for charts that don’t need individual hit-testing.

5. Expanded card

ExpandedCard.swift:

import SwiftUI

struct ExpandedCard: View {
    let metric: Metric
    let namespace: Namespace.ID
    let onDismiss: () -> Void

    @State private var animatedValue: Double = 0

    var body: some View {
        VStack(alignment: .leading, spacing: 20) {
            HStack {
                Text(metric.title).font(.headline)
                Spacer()
                Button {
                    onDismiss()
                } label: {
                    Image(systemName: "xmark.circle.fill")
                        .font(.title2)
                        .foregroundStyle(.secondary)
                }
            }

            Text("\(animatedValue, specifier: "%.0f")")
                .font(.system(size: 56, weight: .bold, design: .rounded))
                .contentTransition(.numericText())
                .keyframeAnimator(initialValue: 0.0, trigger: metric.id) { content, value in
                    content.scaleEffect(value)
                } keyframes: { _ in
                    KeyframeTrack {
                        SpringKeyframe(1.0, duration: 0.4, spring: .bouncy)
                    }
                }

            BarChart(values: metric.sparkline)
                .frame(height: 180)

            TrendBadge(trend: metric.trend)
        }
        .padding(24)
        .frame(maxWidth: .infinity)
        .background(.regularMaterial, in: .rect(cornerRadius: 24))
        .shadow(radius: 20)
        .onAppear {
            withAnimation(.spring(duration: 0.6, bounce: 0.3)) {
                animatedValue = metric.value
            }
        }
    }
}

6. Animated bar chart

BarChart.swift:

import SwiftUI

struct BarChart: View {
    let values: [Double]
    @State private var progress: Double = 0

    var body: some View {
        Canvas { context, size in
            guard !values.isEmpty else { return }
            let maxV = values.max() ?? 1
            let barWidth = size.width / CGFloat(values.count) * 0.7
            let spacing = size.width / CGFloat(values.count) * 0.3

            for (idx, value) in values.enumerated() {
                let height = CGFloat(value / maxV) * size.height * progress
                let x = CGFloat(idx) * (barWidth + spacing) + spacing / 2
                let y = size.height - height
                let rect = CGRect(x: x, y: y, width: barWidth, height: height)
                context.fill(
                    Path(roundedRect: rect, cornerRadius: 4),
                    with: .linearGradient(
                        Gradient(colors: [.blue, .purple]),
                        startPoint: CGPoint(x: x, y: y),
                        endPoint: CGPoint(x: x, y: size.height)
                    )
                )
            }
        }
        .onAppear {
            withAnimation(.smooth(duration: 0.8)) { progress = 1 }
        }
    }
}

7. Wire and run

AnimatedDashboardApp.swift:

@main
struct AnimatedDashboardApp: App {
    var body: some Scene {
        WindowGroup {
            NavigationStack {
                DashboardView()
            }
        }
    }
}

Run, observe:

  • Cards stagger-fly in on launch
  • Tap a card → it expands smoothly to a detail view in the same screen position
  • Bar chart animates from 0 to full height
  • Value counts up
  • Tap X → it collapses back

Stretch goals

  1. Pull to refresh + value updates: .refreshable randomly perturbs metric values; contentTransition(.numericText()) animates the digit changes.
  2. Real chart with Swift Charts: Replace BarChart with Chart { BarMark(...) }.
  3. Gesture-driven expansion: Long press to start expanding, drag to commit/cancel.
  4. Time-range picker in the expanded view (1D/1W/1M/1Y) with morphing chart.
  5. Color theming via @Environment injection — try a “playful” vs “professional” theme.
  6. Mesh gradient backgrounds (iOS 18+) MeshGradient(width:height:points:colors:) for the expanded card background.

Notes & troubleshooting

  • matchedGeometryEffect requires the same id and namespace in both source and destination. A spelling mismatch silently breaks the animation.
  • The collapsed-card position must “exist” in the layout when the expanded card returns. Using Color.clear.frame(height: 1) is hacky; a cleaner approach is to keep MetricCard rendered with .opacity(0) and disable hit-testing while expanded.
  • PhaseAnimator runs once per phase change. Setting animateIn = true on appear triggers from 0 to 1. If you want repeating, use PhaseAnimator(phases: ...).
  • Canvas doesn’t redraw automatically. If you change values, mark them as @State or pass via @Bindable so SwiftUI invalidates.
  • keyframeAnimator(trigger:) runs once per trigger change. Use a value that changes when you want to re-trigger.
  • iOS 17 minimum for PhaseAnimator, KeyframeAnimator, contentTransition. Drop deployment target below = no go.

Where to next

Lab 5.3 (Multiplatform notes) builds a real cross-device app — iPad sidebar, Mac toolbar/commands, shared @Observable store.


Next: Lab 5.3 — Multiplatform notes