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.

12 min read13 min practice0/2 exercises4 recall cards

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
Guess firstAnswering before you read makes the explanation stick — even when you get it wrong.

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:

Where do the hour marks of a dial go? cos/sin, shifted so 0 sits at 12 o'clock.

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.

The ring's structure — track under trimmed fill. Modifiers are on the recorded tree.
Run to see the preview.

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:

ElementsInteractive/accessible per element?Use
a fewyesviews and shapes — the default
hundreds+, decorativenoCanvas — confetti, particles, waveforms
hundreds+, datareading onlyCanvas (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 buildEither machinery — the effect is what smooths the identity change you already understand.
Check yourself

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

Write the code

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.

Solution unlocks after 3 attempts

Trim the ring

Build the view

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.

Solution unlocks after 3 attempts
Design itSaved on this device. Never graded.

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?

Checkpoint

You can now:

  • Implement Shape.path(in:) and make custom parameters animate with animatableData
  • 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.

How well do you know this now? Rating yourself honestly, then being tested on it, is how you find out where your intuition is wrong.