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
- New iOS App, SwiftUI, no SwiftData needed.
- 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 placeholderColor.clearin the grid position and render theExpandedCard(alsomatchedGeometryEffect’d to the same id) — SwiftUI animates the geometry transition. PhaseAnimatorruns 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
- Pull to refresh + value updates:
.refreshablerandomly perturbs metric values;contentTransition(.numericText())animates the digit changes. - Real chart with Swift Charts: Replace
BarChartwithChart { BarMark(...) }. - Gesture-driven expansion: Long press to start expanding, drag to commit/cancel.
- Time-range picker in the expanded view (1D/1W/1M/1Y) with morphing chart.
- Color theming via
@Environmentinjection — try a “playful” vs “professional” theme. - Mesh gradient backgrounds (iOS 18+)
MeshGradient(width:height:points:colors:)for the expanded card background.
Notes & troubleshooting
matchedGeometryEffectrequires the sameidandnamespacein 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 keepMetricCardrendered with.opacity(0)and disable hit-testing while expanded. PhaseAnimatorruns once per phase change. SettinganimateIn = trueon appear triggers from0to1. If you want repeating, usePhaseAnimator(phases: ...).Canvasdoesn’t redraw automatically. If you changevalues, mark them as@Stateor pass via@Bindableso 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.