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

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 shapes UnevenRoundedRectangle still 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 path
  • context.stroke(path, with: .color(...), lineWidth: 2) — stroke a path
  • context.draw(image:at:) / context.draw(text:at:) — render resolved symbols
  • context.withCGContext { cgContext in ... } — drop to CoreGraphics when needed

Note: Canvas does not participate in the SwiftUI hit-testing tree. You cannot add .onTapGesture to individual elements drawn inside a Canvas. For interactive elements, layer a ZStack with 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 .stroke on a Shape, be aware that the stroke is centered on the path edge — half inside, half outside. Use .strokeBorder on InsettableShape-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:

Approach200 shapesSuitable for
200 Circle() viewsHigh CPU, many layout passesStatic or near-static UI
Single Canvas with 200 pathsSingle GPU draw callParticle 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)

ScenarioRecommendation
One-off custom background shapePath directly in the view body
Reusable branded shape (star, badge)Shape protocol conformance
Shape used as clipShape or backgroundShape conformance, add InsettableShape for strokeBorder
50+ shapes updating at 60 fpsCanvas — a single draw call outperforms 50 views
Shape with spring/ease animationShape + 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

  • Path is SwiftUI’s Bezier drawing primitive — use move, addLine, addCurve, and addArc to describe any vector shape.
  • The Shape protocol’s path(in rect: CGRect) requirement makes shapes resolution-independent and layout-aware.
  • InsettableShape enables strokeBorder — implement it for any shape used as a border or clip.
  • Canvas renders 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 animatableData on a Shape to get free interpolation between two states; use AnimatablePair for 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.