SwiftUI Canvas and TimelineView: Build Custom Drawings and 60fps Animated Graphics in iOS 26
Build custom drawings and 60fps animated graphics with SwiftUI Canvas and TimelineView in iOS 26. Particles, audio visualizers, drawingGroup performance, and the gotchas that hurt frame rate.
SwiftUI Canvas is an immediate-mode drawing surface that lets you render arbitrary 2D graphics with a GraphicsContext, and TimelineView is the view that drives it forward in time at a schedule you control. Together they replace the dozen overlapping Shape views you'd otherwise need for particle systems, audio visualizers, custom charts, or any animation that can't be expressed as a tween between two view states. In iOS 26 the pair picked up better symbol resolution, sharper interaction with .drawingGroup() Metal compositing, and a few new schedule helpers that make holding a steady 60fps less of a juggling act.
Canvas runs its draw closure every time SwiftUI invalidates the view. Pair it with TimelineView when you want time-driven redraws instead of state-driven ones.
TimelineView(.animation) ticks at the display's refresh rate (60Hz, 90Hz, or 120Hz on ProMotion). .animation(minimumInterval:) caps it, and .periodic ticks on a fixed cadence regardless of display.
Pre-resolve images with context.resolveSymbol(id:) outside the hot path. Re-resolving SF Symbols per frame is the single biggest performance trap I see.
Add .drawingGroup() to push Canvas contents onto a Metal-backed offscreen layer. You trade memory for roughly 3x render throughput on dense scenes.
Canvas isn't interactive on its own. Overlay a transparent Color.clear with gesture modifiers to receive hits, then convert points into Canvas coordinate space.
For motion that responds to user input (drags, scrolls), drive Canvas from @State. For autonomous motion (particles, clocks), drive it from TimelineView.
What is SwiftUI Canvas?
Canvas is a view whose body is a draw closure: Canvas { context, size in ... }. SwiftUI hands you a GraphicsContext (a value type wrapping a Core Graphics state) and the current view size in points. You issue draw commands (fill, stroke, draw, drawLayer) and they composite into the view's backing layer. There's no display list and no retained scene graph, so you redraw the whole frame every time SwiftUI calls your closure.
So, that immediate-mode model is what makes Canvas the right tool when you'd otherwise need dozens of overlapping Shape views. A starfield with 500 stars as individual Circle()s is 500 layout passes per frame. The same starfield in a single Canvas is one draw closure. The cost difference is visible in Instruments: Hitch Time Ratio goes from "noticeable" to "imperceptible" around the 200-shape mark in my own testing on an iPhone 15 Pro.
Canvas is also where you reach for blend modes, custom gradients along arbitrary paths, and any drawing that needs GraphicsContext.clipToLayer or addFilter, APIs that aren't surfaced on the Shape protocol. For an animated background built from sampled gradients, Canvas is far more flexible than SwiftUI MeshGradient, though MeshGradient is faster when its constraints fit your design.
What is TimelineView and what schedules can it use?
TimelineView is a view that re-evaluates its body on a schedule you provide. The body receives a TimelineViewDefaultContext with a date property, which is the timeline's current moment. You drive your drawing from that date, most often by subtracting it from a stored "start" date to get an elapsed-time delta in seconds.
iOS 26 ships four schedule types you'll use day-to-day:
.animation: ticks at the display's native refresh rate. On a ProMotion device that's up to 120Hz, and SwiftUI lowers it adaptively when nothing's changing.
.animation(minimumInterval: 1.0/30.0): caps the schedule at 30fps. Useful when a scene doesn't visually benefit from 60fps but you still want time-driven updates.
.periodic(from: .now, by: 1.0): ticks once per second regardless of refresh rate. Right for clocks, countdown timers, or anything where a redraw between seconds is wasted work.
.explicit(_:): you provide an explicit sequence of dates. Use it for choreographed sequences where ticks happen at non-uniform intervals.
The schedule decides when the body re-runs. What you do with the resulting context.date decides what the user sees. A common pattern is to compute let t = context.date.timeIntervalSince(start) and feed t into trig functions, easing curves, or particle simulations.
Your first Canvas: drawing paths and shapes
Here's the minimal Canvas. A red circle filled in the center, with a stroked rectangle around it. Notice that Path is the same value type you use with Shape, so anything you already know about Path composition transfers over.
import SwiftUI
struct FirstCanvas: View {
var body: some View {
Canvas { context, size in
// Fill a red circle at the center.
let circle = Path(ellipseIn: CGRect(
x: size.width / 2 - 60,
y: size.height / 2 - 60,
width: 120, height: 120
))
context.fill(circle, with: .color(.red))
// Stroke a rounded rectangle border.
let border = Path(roundedRect: CGRect(origin: .zero, size: size)
.insetBy(dx: 8, dy: 8),
cornerRadius: 16)
context.stroke(border, with: .color(.secondary), lineWidth: 2)
}
.frame(height: 240)
}
}
A few things worth internalizing from this snippet. First, context is a var in the closure signature in real production code (declare it inout-style by reassigning to a local) when you need to push transforms or opacity that should only apply to one draw call. Those mutations are scoped to the local copy. Second, the size argument is in points, not pixels; the GraphicsContext handles backing scale. Third, you don't have to draw anything at all. Returning early from the closure renders nothing, which is the cleanest way to clear the view.
How do you animate Canvas in SwiftUI?
To animate Canvas you wrap it in TimelineView and drive the drawing from context.date. The pattern below renders a circle whose radius pulses with a sine wave, the kind of "breathing" effect you'd use behind a "listening" indicator. I keep the spring-response feel by mapping the sine output through an ease curve rather than feeding raw sine into a radius value. The perceived motion ends up much smoother.
struct BreathingCircle: View {
let start = Date()
var body: some View {
TimelineView(.animation) { timeline in
Canvas { context, size in
let t = timeline.date.timeIntervalSince(start)
// 0.4s response feel: one full breath every 2.5s.
let phase = sin(t * .pi / 1.25) * 0.5 + 0.5
let eased = phase * phase * (3 - 2 * phase) // smoothstep
let radius = 40 + eased * 60
let rect = CGRect(
x: size.width / 2 - radius,
y: size.height / 2 - radius,
width: radius * 2, height: radius * 2
)
context.fill(
Path(ellipseIn: rect),
with: .color(.blue.opacity(0.35))
)
}
}
.frame(height: 240)
}
}
For motion that has to feel like a SwiftUI spring (a button settling after release, say), don't fight TimelineView. Drive the value through withAnimation(.spring(response: 0.4, dampingFraction: 0.7)) on a @State property, then read the animated value inside the Canvas closure. SwiftUI will invalidate the Canvas across the animation duration, no TimelineView needed. Reserve TimelineView for autonomous motion the user isn't directly steering. The SwiftUI animations guide covers the spring and keyframe side; this article covers what to do when neither fits.
Resolving symbols, images, and text efficiently
The single biggest performance trap I see in Canvas code is re-resolving SF Symbols, images, or text every frame. I shipped a meditation app last year where the first build did exactly this and dropped to 24fps on an iPhone 12. GraphicsContext has a resolveSymbol(id:) method that returns a ResolvedSymbol, and the symbols themselves come from a symbols: trailing view builder on Canvas itself. Resolve once, draw many.
Canvas { context, size in
// resolve() is cheap because the view tree is already built;
// the expensive work (rasterization, glyph layout) happens once.
guard let star = context.resolveSymbol(id: "star") else { return }
for i in 0..<200 {
let x = CGFloat.random(in: 0...size.width)
let y = CGFloat.random(in: 0...size.height)
context.draw(star, at: CGPoint(x: x, y: y))
}
} symbols: {
Image(systemName: "star.fill")
.foregroundStyle(.yellow)
.tag("star")
}
The same applies to Text. Pre-resolve with context.resolve(Text("…")) when you'll draw it more than once per frame. context.draw(_:in:) also accepts a GraphicsContext.ResolvedImage, which you build with context.resolve(Image("hero")). The resolved values are valid for the lifetime of that single draw call, so re-resolve at the top of each closure invocation. Don't try to cache across frames.
Build a particle system with Canvas + TimelineView
Particles are the canonical Canvas use case: hundreds of small objects whose state depends on time. Here's a compact emitter that throws sparks upward with gravity. The simulation is stateless (every frame we recompute particle positions from the time elapsed since each particle's birth) which keeps the code under 50 lines and avoids the bookkeeping of mutable particle arrays.
struct Particle: Identifiable {
let id = UUID()
let birth: Date
let origin: CGPoint
let velocity: CGVector
let hue: Double
}
struct ParticleField: View {
@State private var particles: [Particle] = []
let emit = Date()
var body: some View {
TimelineView(.animation) { timeline in
Canvas { context, size in
let now = timeline.date
for p in particles {
let age = now.timeIntervalSince(p.birth)
guard age < 2.0 else { continue }
let x = p.origin.x + p.velocity.dx * age
let y = p.origin.y + p.velocity.dy * age
+ 200 * age * age // gravity
let alpha = 1.0 - age / 2.0
let rect = CGRect(x: x - 2, y: y - 2,
width: 4, height: 4)
context.fill(
Path(ellipseIn: rect),
with: .color(Color(hue: p.hue,
saturation: 0.9,
brightness: 1.0,
opacity: alpha))
)
}
}
.onTapGesture { location in
spawn(at: location)
}
}
.frame(height: 360)
.background(.black)
}
func spawn(at point: CGPoint) {
for _ in 0..<40 {
let angle = Double.random(in: -.pi ... 0)
let speed = Double.random(in: 100...260)
particles.append(Particle(
birth: .now,
origin: point,
velocity: CGVector(dx: cos(angle) * speed,
dy: sin(angle) * speed),
hue: Double.random(in: 0...1)
))
}
// Prune dead particles to keep array small.
particles.removeAll { Date().timeIntervalSince($0.birth) > 2.0 }
}
}
Two things to note about this design. The simulation runs at the schedule's tick rate, not at a fixed timestep. That's fine for cosmetic effects, but you'll want fixed-step integration if particles ever collide or chain forces. And tapping outside the Canvas won't spawn anything, because onTapGesture is on the Canvas itself, which sits inside the TimelineView. That's intentional, but easy to forget once you start nesting views.
Build a real-time audio visualizer
Audio visualizers are another classic Canvas use case. The drawing is trivial (vertical bars whose height comes from an FFT magnitude array), but you need TimelineView to refresh at display rate while a separate audio thread fills the magnitude buffer. Use @Observable for the audio model so SwiftUI invalidates the Canvas as new samples arrive.
@Observable final class AudioLevels {
var magnitudes: [Float] = Array(repeating: 0, count: 32)
// ... wire up AVAudioEngine tap; write to magnitudes on main actor ...
}
struct Visualizer: View {
let levels: AudioLevels
var body: some View {
TimelineView(.animation(minimumInterval: 1.0/30.0)) { _ in
Canvas { context, size in
let count = levels.magnitudes.count
let barWidth = size.width / CGFloat(count) * 0.7
let gap = size.width / CGFloat(count) * 0.3
for (i, mag) in levels.magnitudes.enumerated() {
let h = CGFloat(mag) * size.height
let x = CGFloat(i) * (barWidth + gap) + gap / 2
let rect = CGRect(x: x, y: size.height - h,
width: barWidth, height: h)
let path = Path(roundedRect: rect, cornerRadius: 2)
context.fill(path, with: .linearGradient(
Gradient(colors: [.purple, .pink]),
startPoint: CGPoint(x: 0, y: size.height),
endPoint: CGPoint(x: 0, y: 0)
))
}
}
}
.frame(height: 180)
}
}
I cap the schedule at 30fps because audio meters past that rate look identical to the human eye, and on a 120Hz display you save 75% of the GPU work. Apple's TimelineView documentation confirms the schedule throttles the entire subtree, not just the Canvas.
When should you use Canvas vs Shape in SwiftUI?
Both render 2D graphics, but they sit at different layers of the SwiftUI tree. Use this table to decide quickly:
Dimension
Canvas
Shape
Render model
Immediate-mode draw closure
Retained view in the hierarchy
Best for
Many primitives, time-driven motion
One or a few primitives with view-tree composition
Animation
Drive from TimelineView or @State
Implicit animation via animatableData
Interaction
Manual hit testing
Built-in (gestures attach normally)
Cost at 200+ primitives
One layout pass
200 layout passes, measurable hitch
Accessibility
Manual via .accessibilityRepresentation
Inherits from view tree
Metal acceleration
Via .drawingGroup()
Automatic where applicable
The rule I follow: if I can describe the drawing as "one decorative element" (a custom badge, a wave divider, a progress ring), it's a Shape. If I'm rendering many things at once, or driving them from a clock, it's a Canvas. For complex compositions like custom charts or dashboards, I sometimes mix both: a Shape for each axis line, a Canvas for the data points. The same trade-off applies when you're composing animation-heavy screens using Liquid Glass in SwiftUI, where the cost of overlapping shapes adds up quickly.
Performance: drawingGroup, refresh rate, and the 16ms budget
Canvas draws on the main thread by default. To push it onto a Metal-backed offscreen buffer, add .drawingGroup() after the Canvas:
Canvas { context, size in
// ... 500 fills, 200 strokes ...
}
.drawingGroup() // composite via Metal
The tradeoff is memory. Each .drawingGroup() allocates an offscreen texture sized to the view in pixels, and that texture lives for as long as the view is on screen. For a full-screen Canvas on a 6.7-inch iPhone at 3x density, that's about 13 MB per layer. Apply it where the render cost is high; skip it where it isn't.
The frame budget on ProMotion is 8.3ms (120Hz), and on a standard display 16.6ms (60Hz). You don't get the full budget, because the system reserves a chunk for compositing, so target 10ms on ProMotion and budget for the rest. Use Instruments' Animation Hitches template to find frames that miss; chart Hitch Time Ratio over a representative interaction.
Two more knobs. context.opacity = 0.5 applied before a batch of fills is dramatically cheaper than setting opacity on every Color. And context.blendMode = .plusLighter lets you fake additive glow without compositing layers. Both are state on the context, scoped to your draw closure.
Handling taps, drags, and hit testing inside Canvas
Canvas itself has no notion of "objects". It's pixels. To make tappable regions, attach the gesture to a transparent overlay (or to the Canvas itself), capture the location, and hit-test in Swift code:
struct TappableNodes: View {
@State private var nodes: [CGPoint] = [
CGPoint(x: 60, y: 80), CGPoint(x: 200, y: 140),
CGPoint(x: 320, y: 60)
]
@State private var selected: Int?
var body: some View {
Canvas { context, size in
for (i, p) in nodes.enumerated() {
let r: CGFloat = (i == selected) ? 18 : 12
let rect = CGRect(x: p.x - r, y: p.y - r,
width: r * 2, height: r * 2)
context.fill(Path(ellipseIn: rect),
with: .color(i == selected ? .orange : .blue))
}
}
.gesture(
SpatialTapGesture()
.onEnded { event in
selected = nodes.firstIndex {
hypot($0.x - event.location.x,
$0.y - event.location.y) < 20
}
}
)
.frame(height: 220)
}
}
SpatialTapGesture gives you the location in the view's coordinate space, which is also Canvas's coordinate space, so no conversion needed. For drag interactions, use DragGesture the same way, reading value.location on update. For complex hit testing (z-ordering, hit shapes that aren't circles), keep a list of Path objects in @State and call contains(_:) on them. The same approach extends to composed SwiftUI gestures like simultaneous drag-and-magnify.
Common gotchas and how to fix them
A few more issues that bite in production. Snapshots taken with ImageRenderer sometimes drop the Canvas contents. This happens when the renderer captures before TimelineView ticks even once. Fix it by passing a small delay via Task.sleep before calling render(rasterizationScale:). And if you're trying to animate a value via withAnimation while also reading context.date in the same Canvas, you'll get the @State change interpolating at SwiftUI's animation rate while the TimelineView tick fires independently. Usually fine, occasionally a desync. When that matters, drop TimelineView and drive purely from animated state. (I hit this exact bug shipping a meditation timer where the seek-bar handle drifted ahead of the breath circle by a frame; pulling TimelineView fixed it instantly.)
Frequently Asked Questions
Is Canvas in SwiftUI hardware accelerated?
Canvas drawing runs on the CPU by default. To push the compositing onto Metal, add .drawingGroup() after the Canvas. That allocates an offscreen GPU buffer and blits the result. For dense scenes (300+ primitives or complex blend modes), this is often a 3x throughput win, at the cost of a per-view texture allocation sized to the screen.
What's the difference between TimelineView .animation and .periodic?
.animation ticks at the display's refresh rate (60Hz, 90Hz, or 120Hz depending on hardware) and SwiftUI throttles it down when motion stalls. .periodic(from:by:) ticks at a fixed cadence regardless of display. Use it for clocks, countdowns, or any UI where a redraw between intervals is wasted work.
Can you use Canvas inside a List or ScrollView?
Yes, and it's a great way to render heavy custom cells without paying for many overlapping shape views per row. Be careful with TimelineView inside a recycled scroll cell. When the cell is offscreen, SwiftUI may not pause the timeline. Wrap the TimelineView in a check against scenePhase, or use .onAppear and .onDisappear to start and stop a state flag the schedule reads from.
Why does my Canvas look blurry on Retina displays?
You're probably stroking a 1-point line at a non-integer position. The GraphicsContext samples through a 1.0-point pen that anti-aliases by default. Offset stroked lines by 0.5 points (or set StrokeStyle(lineWidth: 1, lineCap: .square) with integer coordinates) to align them to the pixel grid.
Does Canvas work with SwiftUI Previews in Xcode 26?
Yes, and TimelineView animations run in interactive previews. Static previews capture the timeline at Date(), so a clock or animation may render frozen. Switch the preview to interactive mode (the play icon in the preview canvas) to see motion. Heavy Canvas + TimelineView load can hit the preview process's CPU budget; if previews start crashing, see the SwiftUI preview crash fix.
How to use SwiftUI PhotosPicker in iOS 26 for multi-select, video filtering, and file-based loading. Covers race conditions, iCloud progress, and VoiceOver.
Swift RegexBuilder gives you type-safe regular expressions with named captures, inline transforms, and Unicode-correct matching by default. Learn the DSL, compare it to NSRegularExpression, and avoid production traps.
Build SwiftUI Metal shaders in iOS 26 with colorEffect, distortionEffect, and layerEffect. Includes MSL setup, TimelineView animation, and GPU cost tips with working code.