Drawing with SwiftUI: Path and Shape Basics

SwiftUI gives you two main tools for custom drawing: Path for describing geometry and Shape for turning that geometry into reusable, renderable views. This article shows how to draw lines, curves, circles, and custom figures, and how to choose the right tool for each job.

Quick answer: Use Path when you want to build drawing commands directly, and use Shape when you want a reusable SwiftUI view that can be filled, stroked, resized, and animated.

Difficulty: Beginner to Intermediate

You'll understand this better if you know: basic Swift syntax, how SwiftUI views are composed, and how modifiers like fill and stroke affect view rendering.

1. What Is SwiftUI Path and Shape?

Path is a collection of drawing instructions. It can move to a point, draw straight lines, create curves, and close a figure. Shape is a SwiftUI protocol that lets you describe a drawable outline as a path inside a rectangle.

In practice, Path is what you use to say “draw here, then line to there,” while Shape is what you use when you want SwiftUI to treat that drawing as a view.

2. Why SwiftUI Drawing Matters

Custom drawing is useful whenever built-in shapes and symbols are not enough. Instead of relying on image assets for every small icon or decorative element, you can generate scalable graphics that adapt to color, size, and layout.

SwiftUI drawing matters because it helps you:

Use custom drawing when the result needs to be lightweight, flexible, and responsive to layout changes.

3. Basic Syntax or Core Idea

Building a Path

A Path starts empty and then receives drawing commands. The most common commands are move, addLine, addQuadCurve, addCurve, and closeSubpath.

import SwiftUI

let triangle = Path { path in
    path.move(to: CGPoint(x: 50, y: 0))
    path.addLine(to: CGPoint(x: 0, y: 100))
    path.addLine(to: CGPoint(x: 100, y: 100))
    path.closeSubpath()
}

This code creates a triangle by moving to the top point, drawing two lines, and closing the shape back to the starting point.

Creating a Shape

A Shape must provide a path(in:) method. SwiftUI supplies the drawing rectangle, and your shape returns the geometry that fits inside it.

struct Triangle: Shape {
    func path(in rect: CGRect) -> Path {
        Path { path in
            path.move(to: CGPoint(x: rect.midX, y: rect.minY))
            path.addLine(to: CGPoint(x: rect.minX, y: rect.maxY))
            path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY))
            path.closeSubpath()
        }
    }
}

The key difference is that Path describes fixed geometry, while Shape describes geometry relative to the available bounds.

4. Step-by-Step Examples

Example 1: Draw a simple line

This example uses Path directly inside a Canvas-free SwiftUI view by placing it in a stroke modifier. It draws a diagonal line across a fixed square.

import SwiftUI

struct LineExample: View {
    var body: some View {
        Path { path in
            path.move(to: CGPoint(x: 20, y: 20))
            path.addLine(to: CGPoint(x: 180, y: 180))
        }
        .stroke(Color.blue, lineWidth: 4)
        .frame(width: 200, height: 200)
    }
}

This shows the simplest drawing flow: create a path, add commands, then stroke it.

Example 2: Draw and fill a custom badge

Here the same geometry is wrapped in a reusable Shape. Because it is a shape, you can fill it with a color and reuse it anywhere in your UI.

import SwiftUI

struct BadgeShape: Shape {
    func path(in rect: CGRect) -> Path {
        Path { path in
            path.move(to: CGPoint(x: rect.midX, y: rect.minY))
            path.addLine(to: CGPoint(x: rect.maxX, y: rect.midY))
            path.addLine(to: CGPoint(x: rect.midX, y: rect.maxY))
            path.addLine(to: CGPoint(x: rect.minX, y: rect.midY))
            path.closeSubpath()
        }
    }
}

struct BadgeView: View {
    var body: some View {
        BadgeShape()
            .fill(Color.orange)
            .frame(width: 120, height: 120)
    }
}

This is the preferred approach when the shape itself is part of the view hierarchy.

Example 3: Add a curve

Curves are useful for smooth icons and decorative lines. A quadratic curve uses one control point to bend the line between two points.

import SwiftUI

struct SmileShape: Shape {
    func path(in rect: CGRect) -> Path {
        Path { path in
            path.move(to: CGPoint(x: rect.minX, y: rect.midY))
            path.addQuadCurve(
                to: CGPoint(x: rect.maxX, y: rect.midY),
                control: CGPoint(x: rect.midX, y: rect.maxY)
            )
        }
    }
}

The curve bends downward because the control point sits below the line between the start and end points.

Example 4: Build a progress ring

This example uses Shape with a trimming technique commonly used in SwiftUI progress indicators. The shape can be animated by changing how much of the path is shown.

import SwiftUI

struct RingShape: Shape {
    var progress: Double

    func path(in rect: CGRect) -> Path {
        Path { path in
            path.addEllipse(in: rect.insetBy(dx: 8, dy: 8))
        }
        .trimmedPath(from: 0, to: progress)
    }
}

struct RingExample: View {
    let progress: Double = 0.75

    var body: some View {
        RingShape(progress: progress)
            .stroke(Color.green, lineWidth: 12)
            .frame(width: 140, height: 140)
    }
}

This pattern is common for circular progress indicators, timers, and fitness rings.

5. Practical Use Cases

If a graphic must scale cleanly and be reused in multiple sizes, a Shape is often better than an image.

6. Common Mistakes

Mistake 1: Forgetting to close a filled shape

If you want a closed region to fill, you need to end the path properly. Otherwise SwiftUI may treat the path as an open line instead of an enclosed area.

Problem: The path draws two sides of a triangle but never closes, so fill does not produce the expected solid shape.

import SwiftUI

struct BrokenTriangle: Shape {
    func path(in rect: CGRect) -> Path {
        Path { path in
            path.move(to: CGPoint(x: rect.midX, y: rect.minY))
            path.addLine(to: CGPoint(x: rect.minX, y: rect.maxY))
            path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY))
        }
    }
}

Fix: Close the subpath so SwiftUI knows the outline should form a complete region.

import SwiftUI

struct FixedTriangle: Shape {
    func path(in rect: CGRect) -> Path {
        Path { path in
            path.move(to: CGPoint(x: rect.midX, y: rect.minY))
            path.addLine(to: CGPoint(x: rect.minX, y: rect.maxY))
            path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY))
            path.closeSubpath()
        }
    }
}

The fixed version works because a closed path gives SwiftUI a complete outline to fill.

Mistake 2: Ignoring the drawing rectangle

A Shape should usually scale to the rectangle SwiftUI gives it. Hard-coding fixed coordinates can make the shape appear clipped, tiny, or off-center in different layouts.

Problem: The shape always draws in a 100 by 100 space, so it does not adapt to the actual frame size.

import SwiftUI

struct FixedSizeStar: Shape {
    func path(in rect: CGRect) -> Path {
        Path { path in
            path.move(to: CGPoint(x: 50, y: 0))
            // More points would still be hard-coded here.
        }
    }
}

Fix: Base the points on rect so the shape scales with its container.

import SwiftUI

struct ScaledStar: Shape {
    func path(in rect: CGRect) -> Path {
        let center = CGPoint(x: rect.midX, y: rect.midY)
        let radius = min(rect.width, rect.height) / 2
        
        return Path { path in
            path.move(to: CGPoint(x: center.x, y: center.y - radius))
            // Additional points would be calculated from center and radius.
        }
    }
}

This version respects layout, which is essential for reusable SwiftUI components.

Mistake 3: Using stroke when you meant fill

stroke draws only the outline. If you expected a solid area, the result will look empty or too thin.

Problem: The shape is outlined but not filled, so the interior remains transparent.

import SwiftUI

struct OutlinedHeartView: View {
    var body: some View {
        HeartShape()
            .stroke(Color.red, lineWidth: 2)
            .frame(width: 100, height: 100)
    }
}

struct HeartShape: Shape {
    func path(in rect: CGRect) -> Path {
        Path { path in
            path.move(to: CGPoint(x: rect.midX, y: rect.maxY))
            // Simplified for brevity.
            path.closeSubpath()
        }
    }
}

Fix: Use fill for solid shapes or combine fill and stroke when you need both.

import SwiftUI

struct FilledHeartView: View {
    var body: some View {
        HeartShape()
            .fill(Color.red)
            .frame(width: 100, height: 100)
    }
}

The corrected version works because fill paints the inside of the closed path.

7. Best Practices

Practice 1: Prefer Shape for reusable UI

If you expect to use the drawing in several places, put the geometry in a Shape. That makes it easy to style and size consistently.

struct ChevronShape: Shape {
    func path(in rect: CGRect) -> Path {
        Path { path in
            path.move(to: CGPoint(x: rect.minX, y: rect.minY))
            path.addLine(to: CGPoint(x: rect.midX, y: rect.maxY))
            path.addLine(to: CGPoint(x: rect.maxX, y: rect.minY))
        }
    }
}

Using a shape keeps your design system consistent across the app.

Practice 2: Keep coordinates relative to the rect

Relative coordinates make your custom drawing responsive. This is especially important on iPhone, iPad, and Mac where the same view may appear at very different sizes.

struct ResponsiveDiagonal: Shape {
    func path(in rect: CGRect) -> Path {
        Path { path in
            path.move(to: CGPoint(x: rect.minX, y: rect.minY))
            path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY))
        }
    }
}

This approach avoids brittle, fixed-size drawings.

Practice 3: Separate outline geometry from styling

Put the geometry in the shape and apply colors, line widths, and line caps outside it. That separation keeps the shape flexible.

struct TickMark: Shape {
    func path(in rect: CGRect) -> Path {
        Path { path in
            path.move(to: CGPoint(x: rect.minX, y: rect.midY))
            path.addLine(to: CGPoint(x: rect.midX, y: rect.maxY))
            path.addLine(to: CGPoint(x: rect.maxX, y: rect.minY))
        }
    }
}

TickMark()
    .stroke(Color.green, style: StrokeStyle(lineWidth: 6, lineCap: .round, lineJoin: .round))

This gives you one drawing definition and many styling possibilities.

8. Limitations and Edge Cases

Common confusion: If a custom shape appears clipped, the cause is often the frame size, the stroke width, or hard-coded points that do not match the available rectangle.

9. Practical Mini Project

Let’s build a simple star badge view that can be reused anywhere in a SwiftUI app. The shape is responsible for the outline, while the view applies color, shadow, and padding.

import SwiftUI

struct StarShape: Shape {
    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 * 0.45
        let points = 5
        let angleStep = Double(pi) / Double(points)
        
        var path = Path()
        
        for index in 0..<(points * 2) {
            let radius = index.isMultiple(of: 2) ? outerRadius : innerRadius
            let angle = Double(index) * angleStep - Double(pi) / 2
            let point = CGPoint(
                x: center.x + CGFloat(cos(angle)) * radius,
                y: center.y + CGFloat(sin(angle)) * radius
            )
            
            if index == 0 {
                path.move(to: point)
            } else {
                path.addLine(to: point)
            }
        }
        
        path.closeSubpath()
        return path
    }
}

struct StarBadgeView: View {
    var body: some View {
        StarShape()
            .fill(LinearGradient(
                colors: [Color.yellow, Color.orange],
                startPoint: .top,
                endPoint: .bottom
            ))
            .overlay(
                StarShape()
                    .stroke(Color.white, lineWidth: 2)
            )
            .frame(width: 140, height: 140)
            .shadow(radius: 8)
            .padding()
    }
}

This mini project combines geometry, styling, and layout into one reusable SwiftUI component. The star scales with its frame and can be styled like any other view.

10. Key Points

11. Practice Exercise

Build a reusable speech bubble shape that points to the lower-left side.

Expected output: A resizable speech bubble that keeps its proportions when the frame changes.

Hint: Use rect to calculate the bubble body and tail positions rather than hard-coding coordinates.

import SwiftUI

struct SpeechBubbleShape: Shape {
    func path(in rect: CGRect) -> Path {
        let cornerRadius = min(rect.width, rect.height) * 0.12
        let tailWidth = rect.width * 0.12
        let tailHeight = rect.height * 0.14
        let bodyRect = rect.insetBy(dx: 0, dy: 0)
        
        var path = Path()
        path.move(to: CGPoint(x: bodyRect.minX + cornerRadius, y: bodyRect.minY))
        path.addLine(to: CGPoint(x: bodyRect.maxX - cornerRadius, y: bodyRect.minY))
        path.addLine(to: CGPoint(x: bodyRect.maxX, y: bodyRect.maxY - cornerRadius))
        path.addLine(to: CGPoint(x: bodyRect.minX + cornerRadius, y: bodyRect.maxY - tailHeight))
        path.addLine(to: CGPoint(x: bodyRect.minX + tailWidth, y: bodyRect.maxY))
        path.addLine(to: CGPoint(x: bodyRect.minX, y: bodyRect.maxY - tailHeight))
        path.addLine(to: CGPoint(x: bodyRect.minX, y: bodyRect.minY + cornerRadius))
        path.closeSubpath()
        return path
    }
}

struct SpeechBubbleDemo: View {
    var body: some View {
        SpeechBubbleShape()
            .fill(Color.blue)
            .frame(width: 220, height: 140)
            .padding()
    }
}

This solution works because the bubble is built from proportional geometry and then rendered like any other SwiftUI shape.

12. Final Summary

SwiftUI drawing starts with Path, which gives you direct control over geometry through commands like moving, adding lines, and adding curves. When you wrap that geometry in a Shape, you get a reusable SwiftUI view that can be filled, stroked, clipped, and animated in a natural way.

For most app code, use Shape when the drawing should behave like a normal view and use Path when you need to build or manipulate the geometry more directly. The most reliable shapes are the ones that scale from the supplied rectangle, close their outlines when needed, and keep styling separate from drawing logic.

If you want to keep going, the next useful topic is shape animation with animatableData, which lets custom SwiftUI shapes change smoothly over time.