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.
- Path is the low-level geometry builder.
- Shape wraps that geometry in a SwiftUI view.
- Both can be used for custom icons, charts, badges, separators, and decorative illustrations.
- Shape works especially well with fill, stroke, and animation.
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:
- Create vector graphics that scale cleanly on any screen.
- Reduce dependency on bitmap assets for simple artwork.
- Build reusable components such as progress rings, separators, checkmarks, and badges.
- Animate geometry in a natural SwiftUI way.
- Match your app’s visual style more precisely than stock controls allow.
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
- Custom icons such as arrows, stars, checkmarks, and hearts.
- Reusable UI pieces like pill badges, separators, and speech bubbles.
- Charts and gauges where geometry needs to respond to values.
- Decorative backgrounds and abstract illustrations.
- Animated progress indicators and loading rings.
- Clipped content shapes for buttons, cards, and avatars.
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
- Path and Shape are vector-based, so complex artwork can become expensive if you add many segments or animate them frequently.
- Coordinate math matters. A shape that looks correct at one size may look distorted if its points are not derived from rect.
- stroke draws centered on the path line, which means thick strokes can extend outside the bounds you expected.
- fill only makes sense for closed geometry. Open paths do not produce a proper filled interior.
- Some shapes may look slightly different on different platforms because of antialiasing and rendering differences.
- SwiftUI shapes are not the same as Core Graphics drawing APIs, even though they solve similar problems.
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
- Path records drawing commands such as lines and curves.
- Shape turns a path into a reusable SwiftUI view.
- Use fill for solid regions and stroke for outlines.
- Base coordinates on the provided rectangle so drawings scale correctly.
- Closed paths are necessary for filled shapes.
- Custom shapes are ideal for icons, badges, charts, and animated indicators.
11. Practice Exercise
Build a reusable speech bubble shape that points to the lower-left side.
- Create a Shape named SpeechBubbleShape.
- Draw a rounded rectangle-like outline using Path commands.
- Add a small triangular tail near the bottom-left edge.
- Fill it with a color and apply it in a SwiftUI view.
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.