Animation, gestures and drawing · advanced
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?
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.