Shapes, Canvas and matched geometry
Custom drawing — the Shape protocol, trim-based progress rings, Canvas for many-element scenes, and the hero transition.
By the end you will be able to
- Implement the Shape protocol and explain why shapes animate for free
- Build trim-based progress indicators from the fraction-to-angle math
- Choose between Shape, Canvas and views — and wire a matchedGeometryEffect hero pair
Circle().trim(from: 0, to: 0.7) draws 70% of a circle's outline. Why is this one modifier the foundation of nearly every progress ring, activity dial and loading arc on iOS?
The Shape protocol: one method
struct Wedge: Shape {
var fraction: Double // 0…1 of a full turn
func path(in rect: CGRect) -> Path {
var path = Path()
let centre = CGPoint(x: rect.midX, y: rect.midY)
path.move(to: centre)
path.addArc(
center: centre,
radius: rect.width / 2,
startAngle: .degrees(-90), // 12 o'clock
endAngle: .degrees(-90 + fraction * 360),
clockwise: false
)
path.closeSubpath()
return path
}
}
Wedge(fraction: 0.3)
.fill(.orange)
.frame(width: 120, height: 120)
A Shape is a view that describes an outline: SwiftUI hands you the rectangle it decided on (the layout negotiation from U1-02 — shapes accept the full proposal) and you return a Path built from moves, lines, arcs and curves. Everything else — fill, stroke, gradients, shadows, hit-testing — comes from the framework.
Because a shape is a value describing geometry, one more protocol makes it animate: expose your parameter as animatableData, and SwiftUI interpolates it through every frame:
var animatableData: Double {
get { fraction }
set { fraction = newValue }
}
With that, withAnimation { wedge.fraction = 0.8 } sweeps the wedge — the framework calls path(in:) per frame with interpolated fractions. This is the whole custom-drawing animation story: make the parameter a number, tell SwiftUI it is animatable, and the diff engine does the rest.
The geometry is just trigonometry
Points on a circle are the one formula custom drawing uses constantly, and it runs right here:
Read the outputs against a clock face: 0 → (0, −100) is straight up (screen y grows downward, so up is negative); 0.25 → (100, 0) is 3 o'clock. Every dial, radial menu, pie chart and star shape you will ever draw is this function called in a loop — the star just alternates between two radii.
Progress rings: trim does the work
The production pattern skips custom paths entirely:
struct ProgressRing: View {
var progress: Double // 0…1
var body: some View {
ZStack {
Circle()
.stroke(.gray.opacity(0.2), lineWidth: 10) // the track
Circle()
.trim(from: 0, to: progress) // the fill
.stroke(.blue, style: StrokeStyle(lineWidth: 10, lineCap: .round))
.rotationEffect(.degrees(-90)) // start at 12 o'clock
}
.animation(.spring, value: progress)
}
}
Three moves worth naming: the two-layer ZStack (dim full track under bright partial fill) is what makes it read as progress; trim starts at 3 o'clock so the rotation brings it to 12; and the value-bound animation means callers just set progress and the ring sweeps. The Activity-rings look is this exact structure with gradients.
Canvas: when views become too many
Views are cheap, but not free — a particle system or a 10,000-point chart as individual views spends most of its time in layout no one needs. Canvas is immediate-mode drawing: one view, one closure, draw everything yourself:
Canvas { context, size in
for particle in particles {
let rect = CGRect(x: particle.x, y: particle.y, width: 4, height: 4)
context.fill(Path(ellipseIn: rect), with: .color(.white.opacity(particle.alpha)))
}
}
The trade is explicit: you gain raw throughput and lose everything view-ness provided — no per-element hit testing, no accessibility elements, no per-element animation (you re-draw each frame, typically driven by TimelineView). The decision table:
| Elements | Interactive/accessible per element? | Use |
|---|---|---|
| a few | yes | views and shapes — the default |
| hundreds+, decorative | no | Canvas — confetti, particles, waveforms |
| hundreds+, data | reading only | Canvas (or Swift Charts, purpose-built for data) |
The hero transition: matchedGeometryEffect
The grid-photo-expands-into-detail effect — one element appearing to travel between two layouts — is a pairing declaration:
@Namespace private var heroSpace
// In the grid:
ThumbnailView(photo: photo)
.matchedGeometryEffect(id: photo.id, in: heroSpace)
// In the detail (shown when selected):
FullPhotoView(photo: photo)
.matchedGeometryEffect(id: photo.id, in: heroSpace)
When the state flips between the two branches (inside withAnimation), SwiftUI sees the same id in the same namespace on the outgoing and incoming views — and instead of fading one out and one in, it interpolates frame-to-frame: position, size, the lot. The rules that make it work, and whose violation makes the classic glitches:
- Exactly one visible view per id at a time — both visible at once produces the infamous jump-flash.
- The pair must share a
@Namespace— ids are scoped to it. - It composes with everything from the animation lesson: the flip must happen under an animation, and the branches are the
buildEithermachinery — the effect is what smooths the identity change you already understand.
Your confetti burst renders 400 pieces as individual Image views and stutters. The pieces are decorative — untappable, invisible to VoiceOver. What is the principled fix?
The dial math
Implement starPoints — the alternating-radius loop behind every star, badge and gear shape. Outer points sit on the outer radius, inner points halfway between them on the inner radius.
Trim the ring
Build the two-layer progress ring: a dim full track under a blue fill trimmed to 0.4, rotated to start at 12 o'clock.
Design the drawing layer for an audio-recording screen: a live waveform of the last 5 seconds, a circular record button that pulses while recording, and a "processing" arc afterwards. For each element: Shape, Canvas or plain views — justified by count, interactivity and accessibility. Where does animatableData appear, and what drives the waveform's redraws?
You can now:
- Implement
Shape.path(in:)and make custom parameters animate withanimatableData - Build the canonical progress ring and explain each structural move
- Choose views vs Canvas by count, interactivity and accessibility
- Wire a matched-geometry hero pair and name the one-visible-view rule
Next up: UIKit interop — the bridge to two decades of framework the platform still runs on.