Custom Shapes in SwiftUI: `Path`, `Shape`, `Canvas`, and Drawing APIs
SwiftUI’s built-in shapes — Rectangle, Circle, RoundedRectangle — get you far, but they can’t draw a star, a
progress ring, or the iconic Luxo Ball. The moment a designer hands you a custom badge, a film-slate clapperboard, or a
jagged progress indicator, you need Path and the Shape protocol.
This post covers the full SwiftUI drawing stack: raw Path construction, building reusable Shape conformances, the
InsettableShape protocol, and the high-performance Canvas API. We won’t cover CoreGraphics directly or SpriteKit —
this is purely SwiftUI’s drawing layer.
Contents
- The Problem
- Drawing with
Path - The
ShapeProtocol - The
InsettableShapeProtocol - High-Performance Drawing with
Canvas - Gradients and Strokes
- Advanced Usage
- Performance Considerations
- When to Use (and When Not To)
- Summary
The Problem
Suppose you’re building a Pixar movie card and need a shape where only the top-left and bottom-right corners are rounded
— the opposite corners remain sharp, giving the card a distinctive slanted feel. RoundedRectangle rounds all four
corners uniformly. There’s no built-in modifier to selectively round corners:
// ❌ This rounds ALL corners — not what we want
RoundedRectangle(cornerRadius: 16)
.fill(Color.blue)
.frame(width: 200, height: 280)
// ❌ This clips, but still applies the same radius to all corners
Image("toy-story-poster")
.clipShape(RoundedRectangle(cornerRadius: 16))
Note: iOS 16 introduced
UnevenRoundedRectangle, which solves this specific case. But there are countless shapesUnevenRoundedRectanglestill can’t express: stars, film slates, progress rings, wave curves. The underlying techniques are the same regardless.
This is where the Path and Shape APIs become essential.
Drawing with Path
Path is SwiftUI’s Bezier-path primitive. It works like
CoreGraphics’ CGPath but with a SwiftUI-friendly builder syntax. You describe a sequence of moves, lines, curves, and
arcs, and SwiftUI renders them as a vector shape.
The simplest path is a triangle — three points connected by lines:
Path { path in
path.move(to: CGPoint(x: 100, y: 0))
path.addLine(to: CGPoint(x: 200, y: 200))
path.addLine(to: CGPoint(x: 0, y: 200))
path.closeSubpath() // connects back to the move point
}
.fill(Color.yellow)
.frame(width: 200, height: 200)
The closeSubpath() call draws the final line segment from the last point back to the first, closing the shape. Without
it, you get an open path that can be stroked but not filled cleanly.
Paths support the full set of Bezier commands:
Path { path in
// Straight lines
path.move(to: CGPoint(x: 0, y: 100))
path.addLine(to: CGPoint(x: 100, y: 0))
// Quadratic curve (one control point)
path.addQuadCurve(
to: CGPoint(x: 200, y: 100),
control: CGPoint(x: 150, y: -50)
)
// Cubic curve (two control points — full Bezier)
path.addCurve(
to: CGPoint(x: 100, y: 200),
control1: CGPoint(x: 250, y: 150),
control2: CGPoint(x: 50, y: 250)
)
path.closeSubpath()
}
.stroke(Color.red, lineWidth: 2)
Paths also support arcs and ellipses, which are the foundation of progress rings and pie charts:
// A progress arc — think of the loading ring in a Pixar streaming app
Path { path in
path.addArc(
center: CGPoint(x: 100, y: 100),
radius: 80,
startAngle: .degrees(-90), // top of circle
endAngle: .degrees(216), // 85% complete
clockwise: false
)
}
.stroke(Color.blue, style: StrokeStyle(lineWidth: 12, lineCap: .round))
.frame(width: 200, height: 200)
Raw Path is useful for one-off drawings, but it has a critical limitation: it’s hardcoded to fixed coordinates. If the
frame changes, the shape doesn’t adapt. That’s what the Shape protocol solves.
The Shape Protocol
Shape is a protocol with a single requirement:
func path(in rect: CGRect) -> Path
The rect parameter is the frame SwiftUI allocates for your view. By expressing all your coordinates relative to
rect, your shape scales correctly at any size.
Building a FilmSlateShape
A film slate (clapperboard) is a good production example — it’s a rectangle body with a diagonal-striped top bar. Here’s a simplified version:
struct FilmSlateShape: Shape {
func path(in rect: CGRect) -> Path {
var path = Path()
let slateBodyTop = rect.height * 0.25
let boardHeight = rect.height * 0.18
// Main slate body
path.addRect(CGRect(
x: rect.minX,
y: slateBodyTop,
width: rect.width,
height: rect.height - slateBodyTop
))
// Top bar
path.addRect(CGRect(
x: rect.minX,
y: rect.minY,
width: rect.width,
height: boardHeight
))
// Diagonal stripe on top bar — left wedge
path.move(to: CGPoint(x: rect.width * 0.1, y: rect.minY))
path.addLine(to: CGPoint(x: rect.width * 0.3, y: rect.minY))
path.addLine(to: CGPoint(x: rect.width * 0.2, y: boardHeight))
path.addLine(to: CGPoint(x: rect.width * 0.0, y: boardHeight))
path.closeSubpath()
return path
}
}
// Usage — scales to any size
FilmSlateShape()
.fill(Color.black)
.frame(width: 120, height: 160)
Building a StarShape
Stars are a classic shape that require polar coordinate math. This produces an n-pointed star, useful for rating indicators in a Pixar movie review app:
struct StarShape: Shape {
var points: Int = 5
var innerRadiusFraction: CGFloat = 0.4
func path(in rect: CGRect) -> Path {
let center = CGPoint(x: rect.midX, y: rect.midY)
let outerRadius = min(rect.width, rect.height) / 2
let innerRadius = outerRadius * innerRadiusFraction
let angleIncrement = (2 * Double.pi) / Double(points)
let startAngle = -Double.pi / 2 // start at top
var path = Path()
for i in 0 ..< points {
let outerAngle = startAngle + Double(i) * angleIncrement
let innerAngle = outerAngle + angleIncrement / 2
let outerPoint = CGPoint(
x: center.x + outerRadius * CGFloat(cos(outerAngle)),
y: center.y + outerRadius * CGFloat(sin(outerAngle))
)
let innerPoint = CGPoint(
x: center.x + innerRadius * CGFloat(cos(innerAngle)),
y: center.y + innerRadius * CGFloat(sin(innerAngle))
)
if i == 0 {
path.move(to: outerPoint)
} else {
path.addLine(to: outerPoint)
}
path.addLine(to: innerPoint)
}
path.closeSubpath()
return path
}
}
// A gold Pixar Award star
StarShape(points: 5, innerRadiusFraction: 0.45)
.fill(Color.yellow)
.overlay(
StarShape(points: 5, innerRadiusFraction: 0.45)
.stroke(Color.orange, lineWidth: 2)
)
.frame(width: 80, height: 80)
UnevenRoundedRectangle — iOS 16 Built-In
Before rolling your own selectively-rounded-corner shape, check if iOS 16’s
UnevenRoundedRectangle covers your use
case:
@available(iOS 16.0, *)
UnevenRoundedRectangle(
topLeadingRadius: 20,
bottomLeadingRadius: 0,
bottomTrailingRadius: 20,
topTrailingRadius: 0
)
.fill(Color.indigo)
.frame(width: 200, height: 120)
Roll your own Shape only when the built-in doesn’t fit — custom bezier curves, non-rectangular outlines, or dynamic
shapes controlled by data.
The InsettableShape Protocol
InsettableShape extends Shape with one
additional requirement: func inset(by amount: CGFloat) -> some InsettableShape. This enables SwiftUI’s strokeBorder
modifier, which strokes a border inside the shape’s edge rather than centered on it.
For the StarShape above, adding InsettableShape support requires tracking the inset offset:
struct StarShape: Shape, InsettableShape {
var points: Int = 5
var innerRadiusFraction: CGFloat = 0.4
var insetAmount: CGFloat = 0
func path(in rect: CGRect) -> Path {
let insetRect = rect.insetBy(dx: insetAmount, dy: insetAmount)
let center = CGPoint(x: insetRect.midX, y: insetRect.midY)
let outerRadius = min(insetRect.width, insetRect.height) / 2
let innerRadius = outerRadius * innerRadiusFraction
let angleIncrement = (2 * Double.pi) / Double(points)
let startAngle = -Double.pi / 2
var path = Path()
for i in 0 ..< points {
let outerAngle = startAngle + Double(i) * angleIncrement
let innerAngle = outerAngle + angleIncrement / 2
let outerPoint = CGPoint(
x: center.x + outerRadius * CGFloat(cos(outerAngle)),
y: center.y + outerRadius * CGFloat(sin(outerAngle))
)
let innerPoint = CGPoint(
x: center.x + innerRadius * CGFloat(cos(innerAngle)),
y: center.y + innerRadius * CGFloat(sin(innerAngle))
)
if i == 0 { path.move(to: outerPoint) } else { path.addLine(to: outerPoint) }
path.addLine(to: innerPoint)
}
path.closeSubpath()
return path
}
func inset(by amount: CGFloat) -> StarShape {
var copy = self
copy.insetAmount += amount
return copy
}
}
// strokeBorder keeps the stroke inside the shape boundary
StarShape()
.strokeBorder(Color.orange, lineWidth: 4)
.frame(width: 80, height: 80)
Without InsettableShape, calling .strokeBorder falls through to a less accurate approximation. Implement it whenever
you plan to use your custom shape as a background or border.
High-Performance Drawing with Canvas
Canvas (iOS 15+) is SwiftUI’s imperative drawing surface.
Unlike composing many Path views, Canvas renders everything into a single layer — making it dramatically faster for
particle systems, charts, or any scenario with dozens or hundreds of drawn elements.
@available(iOS 15.0, *)
struct PixarStudioMap: View {
let studios: [PixarStudio]
var body: some View {
Canvas { context, size in
for studio in studios {
// Draw a location dot for each Pixar studio/satellite office
var dot = Path()
dot.addEllipse(in: CGRect(
x: studio.normalizedPosition.x * size.width - 10,
y: studio.normalizedPosition.y * size.height - 10,
width: 20,
height: 20
))
context.fill(dot, with: .color(studio.accentColor))
// Draw a label above each dot
context.draw(
Text(studio.name).font(.caption2).bold(),
at: CGPoint(
x: studio.normalizedPosition.x * size.width,
y: studio.normalizedPosition.y * size.height - 18
)
)
}
}
.frame(maxWidth: .infinity, minHeight: 300)
}
}
Canvas provides a GraphicsContext — a value type that exposes drawing operations similar to CoreGraphics but adapted
for SwiftUI. Key operations:
context.fill(path, with: .color(...))— fill a pathcontext.stroke(path, with: .color(...), lineWidth: 2)— stroke a pathcontext.draw(image:at:)/context.draw(text:at:)— render resolved symbolscontext.withCGContext { cgContext in ... }— drop to CoreGraphics when needed
Note:
Canvasdoes not participate in the SwiftUI hit-testing tree. You cannot add.onTapGestureto individual elements drawn inside aCanvas. For interactive elements, layer aZStackwith invisible overlay views on top of the canvas.
TimelineView + Canvas for Game-Loop Style Rendering
Combine TimelineView with Canvas for
continuously-animated drawings — think a Luxo lamp bouncing animation or a particle emitter:
@available(iOS 15.0, *)
struct LuxoParticleView: View {
@State private var particles: [LuxoParticle] = LuxoParticle.initial()
var body: some View {
TimelineView(.animation) { timeline in
Canvas { context, size in
let elapsed = timeline.date.timeIntervalSinceReferenceDate
for particle in particles {
let age = elapsed - particle.birthTime
let progress = age / particle.lifetime
guard progress < 1 else { continue }
let x = particle.origin.x + particle.velocity.x * age
let y = particle.origin.y + particle.velocity.y * age
+ 0.5 * 200 * age * age // gravity
var circle = Path()
circle.addEllipse(in: CGRect(x: x - 4, y: y - 4, width: 8, height: 8))
context.fill(
circle,
with: .color(particle.color.opacity(1 - progress))
)
}
}
}
.frame(maxWidth: .infinity, minHeight: 400)
.background(Color.black)
}
}
The TimelineView(.animation) schedule drives updates at display refresh rate (typically 60–120 Hz). The canvas reads
the timeline.date to calculate elapsed time for each particle.
Gradients and Strokes
Shapes and paths work with all of SwiftUI’s paint types — not just solid colors.
// Linear gradient fill — like a holographic Pixar logo effect
StarShape(points: 6)
.fill(
LinearGradient(
colors: [.purple, .blue, .cyan],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.frame(width: 120, height: 120)
// Angular gradient stroke — color-wheel ring
Circle()
.stroke(
AngularGradient(
colors: [.red, .orange, .yellow, .green, .blue, .purple, .red],
center: .center
),
lineWidth: 12
)
.frame(width: 120, height: 120)
// Stroke with custom dash pattern — dashed progress ring
Path { path in
path.addArc(
center: CGPoint(x: 60, y: 60),
radius: 50,
startAngle: .degrees(-90),
endAngle: .degrees(270),
clockwise: false
)
}
.stroke(
Color.blue,
style: StrokeStyle(
lineWidth: 8,
lineCap: .round,
lineJoin: .round,
dash: [12, 8] // 12pt dash, 8pt gap
)
)
.frame(width: 120, height: 120)
Tip: When using
.strokeon aShape, be aware that the stroke is centered on the path edge — half inside, half outside. Use.strokeBorderonInsettableShape-conforming types to keep the stroke fully inside the frame boundary.
Advanced Usage
Animating Shapes with AnimatableData
Shapes conform to Animatable via the animatableData
property. Expose any VectorArithmetic-conforming value to get free interpolation between shape states.
To animate the StarShape’s innerRadiusFraction from 0.2 to 0.5 with a spring:
struct StarShape: Shape {
var points: Int = 5
var innerRadiusFraction: CGFloat = 0.4
// Expose to the animation system
var animatableData: CGFloat {
get { innerRadiusFraction }
set { innerRadiusFraction = newValue }
}
func path(in rect: CGRect) -> Path {
// ... same implementation as before
}
}
struct AnimatedStarDemo: View {
@State private var isPulsing = false
var body: some View {
StarShape(innerRadiusFraction: isPulsing ? 0.2 : 0.5)
.fill(Color.yellow)
.frame(width: 100, height: 100)
.animation(.spring(duration: 0.6, bounce: 0.4), value: isPulsing)
.onTapGesture { isPulsing.toggle() }
}
}
For shapes with two animated properties, use
AnimatablePair:
var animatableData: AnimatablePair<CGFloat, CGFloat> {
get { AnimatablePair(innerRadiusFraction, CGFloat(points)) }
set {
innerRadiusFraction = newValue.first
points = Int(newValue.second.rounded())
}
}
GeometryReader Inside a Shape Parent
When a shape’s geometry depends on runtime layout information, combine GeometryReader in the parent view with a
relative-coordinate Shape. The Shape protocol already receives rect in path(in:), so this is rarely needed — but
it’s useful when shapes need to respond to sibling view sizes:
struct AdaptiveProgressRing: View {
var progress: CGFloat // 0.0 – 1.0
var body: some View {
GeometryReader { geo in
let radius = min(geo.size.width, geo.size.height) / 2 - 10
let center = CGPoint(x: geo.size.width / 2, y: geo.size.height / 2)
Path { path in
path.addArc(
center: center,
radius: radius,
startAngle: .degrees(-90),
endAngle: .degrees(-90 + 360 * progress),
clockwise: false
)
}
.stroke(Color.blue, style: StrokeStyle(lineWidth: 12, lineCap: .round))
}
}
}
Performance Considerations
The key performance insight is this: every SwiftUI view is a separate layer in the render tree. If you have 200 animated circles on screen — a particle explosion, a star field, a confetti shower — that’s 200 individual view layout passes, 200 hit-test regions, and 200 separate render nodes.
Canvas collapses all of that into a single Metal render pass. The performance difference is significant:
| Approach | 200 shapes | Suitable for |
|---|---|---|
200 Circle() views | High CPU, many layout passes | Static or near-static UI |
Single Canvas with 200 paths | Single GPU draw call | Particle systems, charts, game UI |
Use Canvas whenever:
- You’re drawing more than ~20 shapes that update frequently
- You need animation at display refresh rate (
TimelineView) - You’re building charts, graphs, or data visualizations
Use Shape conformances for:
- Reusable shape components that participate in SwiftUI layout
- Shapes used as clip shapes, backgrounds, or overlays on other views
- Shapes that need hit-testing or gesture recognition
Apple Docs:
Canvas— SwiftUI (iOS 15+)
When to Use (and When Not To)
| Scenario | Recommendation |
|---|---|
| One-off custom background shape | Path directly in the view body |
| Reusable branded shape (star, badge) | Shape protocol conformance |
Shape used as clipShape or background | Shape conformance, add InsettableShape for strokeBorder |
| 50+ shapes updating at 60 fps | Canvas — a single draw call outperforms 50 views |
| Shape with spring/ease animation | Shape + animatableData property |
| Per-frame animation (particles, games) | TimelineView + Canvas |
| Selectively rounded corners (iOS 16+) | UnevenRoundedRectangle — don’t rewrite what Apple ships |
| Interactive drawn elements (tap targets) | ZStack with Canvas background + SwiftUI overlay views |
Summary
Pathis SwiftUI’s Bezier drawing primitive — usemove,addLine,addCurve, andaddArcto describe any vector shape.- The
Shapeprotocol’spath(in rect: CGRect)requirement makes shapes resolution-independent and layout-aware. InsettableShapeenablesstrokeBorder— implement it for any shape used as a border or clip.Canvasrenders all its contents in a single Metal pass, making it the right tool for particle systems, charts, and anything with more than ~20 simultaneously updated shapes.- Expose
animatableDataon aShapeto get free interpolation between two states; useAnimatablePairfor multiple animated properties.
Shapes are closely related to SwiftUI’s animation system — once you have animatable shapes, the next natural step is building custom transitions and matched geometry effects. Check out SwiftUI Animations: Implicit, Explicit, Matched Geometry, and Custom Transitions for the full picture.