Mastering SwiftUI Drawing: Build Animated Loaders, Waves, and Custom UI with Shape, Path & Canvas
Want to create stunning custom UI in SwiftUI? Learn how to draw and animate using Shape, Path, and Canvas — no Core Graphics or UIKit…

👋🏽 Introduction
Let’s be real — most SwiftUI tutorials stop at VStack, HStack, and the occasional flashy button with a shadow. But what if you want to build something that moves, draws itself, or morphs into a completely custom design?
That’s where SwiftUI’s lesser-known trio steps in: **Shape**, **Path**, and **Canvas** — your secret weapons for drawing custom UI without ever touching Core Graphics or UIKit.
These tools let you:
- Build animated loaders from scratch (bye-bye Lottie 👋)
- Visualize waveforms and live data
- Create effects like breathing blobs, radar pulses, or even fake 3D cubes (yes, really)
And the best part? Everything we’ll build is pure SwiftUI — composable, performant, and gorgeous.
Whether you’re crafting a dashboard, an onboarding animation, or just trying to impress yourself with geometry you forgot in high school — this guide has you covered.
Let’s draw some magic. ✨
✏️ Shape Basics — Drawing a Triangle
In SwiftUI, shapes aren’t just visual decorations — they’re first-class citizens. And when you conform to the Shape protocol, you're not drawing pixels… you're building reusable, animatable components that play perfectly with SwiftUI's layout and animation systems.
Let’s start simple. A good old-fashioned triangle.
🧱 Step 1: Define a Custom Shape
struct Triangle: Shape {
func path(in rect: CGRect) -> Path {
var path = Path()
path.move(to: CGPoint(x: rect.midX, y: rect.minY)) // Top center
path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY)) // Bottom right
path.addLine(to: CGPoint(x: rect.minX, y: rect.maxY)) // Bottom left
path.closeSubpath()
return path
}
}🎯 What’s happening here?
Shapeis a protocol with just one requirement:path(in rect:).- You’re given a
CGRect(the drawing area), and you return aPath— basically a vector outline. move(to:)sets the starting point of your drawing.addLine(to:)connects the dots.closeSubpath()seals the triangle.
🧠 Pro tip: In SwiftUI, (0,0) is the top-left corner, and y increases downward — classic Core Graphics style.
🌈 Step 2: Style it up
Triangle()
.fill(LinearGradient(
colors: [.indigo, .blue],
startPoint: .top,
endPoint: .bottom
))
.frame(width: 150, height: 150)
.shadow(radius: 10)🖼️ Output:
A crisp, centered triangle — filled with a vertical gradient and a soft drop shadow.

🤔 Why use Shape?
Because it’s:
- Resizable — scales beautifully without pixelation.
- Composable — works inside stacks, buttons, backgrounds, etc.
- Animatable — with just a little extra effort, you can animate changes in shape.
🧭 Path — Custom Drawing Power (Explained Clearly)
While Shape is great for reusable geometry, SwiftUI’s Path gives you total freedom — think of it like the digital pencil in your toolbox. You can sketch anything: lines, arcs, curves, or crazy custom patterns.
Let’s draw something fancy: A five-pointed star ⭐️
But first, don’t worry — I’ll explain everything.
⭐ Let’s Code a Star — With Developer-Friendly Commentary
struct StarPathView: View {
var body: some View {
Path { path in
let center = CGPoint(x: 100, y: 100) // Star will be drawn around this point
let outerRadius: CGFloat = 80 // Distance from center to outer tip
let innerRadius: CGFloat = 32 // Distance from center to inner corner
let numberOfPoints = 5 // Five-pointed star = 10 corners (outer+inner)
// Loop through 10 points: alternating between outer and inner
for i in 0..<numberOfPoints * 2 {
// Calculate angle for current point (in radians)
let angle = Double(i) * (.pi / Double(numberOfPoints))
// Alternate between outer and inner radius
let radius = i.isMultiple(of: 2) ? outerRadius : innerRadius
// Use cosine and sine to get x and y based on angle
let x = center.x + CGFloat(cos(angle)) * radius
let y = center.y + CGFloat(sin(angle)) * radius
if i == 0 {
// Start the path at the first point
path.move(to: CGPoint(x: x, y: y))
} else {
// Draw lines to connect the points
path.addLine(to: CGPoint(x: x, y: y))
}
}
// Close the path to finish the shape
path.closeSubpath()
}
.stroke(.orange, lineWidth: 4)
.frame(width: 200, height: 200)
.rotationEffect(.degrees(-90)) // Rotate to point star upward
}
}
Let’s break this down like a developer-friendly play-by-play:
💡 for i in 0..<numberOfPoints * 2 We’re creating 10 points, not 5 — because a 5-pointed star actually needs 10 coordinates:
- 5 for the outer tips
- 5 for the inner corners
We alternate between outer → inner → outer → inner…
So:
i = 0→ outer tipi = 1→ inner dipi = 2→ outer tip- and so on…
🧮 let angle = Double(i) * (.pi / Double(numberOfPoints)) This gives us the direction of the point in radians. Think of it like saying:
“What clock position should I place this point?”
.pi= 180°.pi * 2= 360° (full circle).pi / numberOfPointsgives us the angle between two star tipsimultiples it to step around the circle
So the angles look like this:
i = 0→ 0 rad → 3 o'clocki = 1→ 36° → just below topi = 2→ 72° → top-left …and so on, forming a star.
🎯 let radius = i.isMultiple(of: 2) ? outerRadius : innerRadius
This line alternates between two radii:
- Outer radius = star tips
- Inner radius = dips between tips
So:
- If
iis even → use outer radius - If
iis odd → use inner radius
This gives us the pointy star look instead of just drawing a polygon.
📍 let x = center.x + cos(angle) * radius
📍 let y = center.y + sin(angle) * radius Now we’re turning the angle + radius into screen coordinates.
Think of it like:
“From the center of the star, go this far (radius) in this direction (angle).”
cos(angle)gives horizontal offset (left/right)sin(angle)gives vertical offset (up/down)
So you’re placing each point around the invisible circle, alternating between big (outer tip) and small (inner corner).
🖌️ if i == 0 { path.move(to: …) } else { path.addLine(to: …) }
- The first point uses
move(to:)to start the drawing (like picking up the pen and placing it down). - Every next point uses
addLine(to:)to connect lines between the points. - At the end of the loop, the full star shape is traced.
Later, we call closeSubpath() to connect the last point back to the first and finish the shape.
✅ TL;DR (Mental Model)
Imagine you’re drawing a connect-the-dots star around a circle:
- Each dot = angle + radius = screen coordinate
- You alternate between long and short steps
- You draw lines between them
- Done — one gorgeous vector star 🌟
🌊 Drawing a Living Ocean in SwiftUI Using Canvas
When you think of Canvas, you might picture lines, circles, or charts. But what if you could use it to paint something alive? Let’s build a full-screen animated ocean — in pure SwiftUI.
No UIKit. No Core Graphics. Just Canvas, Path, and a little sine wave magic.
🎯 What We’ll Build
This isn’t just a sine wave.
We’re going to draw a dynamic wave that:
- Animates like a tide (moving horizontally)
- Gently floats up and down (like real water)
- Fills the screen from the wave down (not just a stroke)
- Has a rich blue-to-cyan gradient fill
- Works on all devices using just SwiftUI’s native tools
🧪 Full Code: Full-Screen Ocean Wave in SwiftUI
struct FullScreenOceanWave: View {
var body: some View {
GeometryReader { geometry in
TimelineView(.animation) { timeline in
let date = timeline.date.timeIntervalSinceReferenceDate
Canvas { context, size in
var path = Path()
let amplitude: CGFloat = 40 // Height of the wave
let frequency: CGFloat = 80 // How stretched the wave is
let speed = date * 2 // Controls horizontal animation
// Floating effect — moves the wave up and down
let floatOffset = sin(date * 1.5) * 15
let centerY = size.height * 0.4 + floatOffset
// Start drawing from the left edge
path.move(to: CGPoint(x: 0, y: centerY))
// Draw the wave across the width
for x in stride(from: 0, through: size.width, by: 1) {
let relativeX = x / frequency
let y = sin(relativeX + speed) * amplitude + centerY
path.addLine(to: CGPoint(x: x, y: y))
}
// Close the shape by drawing down to the bottom of the screen
path.addLine(to: CGPoint(x: size.width, y: size.height))
path.addLine(to: CGPoint(x: 0, y: size.height))
path.closeSubpath()
// Fill the shape with a vertical blue-to-cyan gradient
context.fill(
path,
with: .linearGradient(
Gradient(colors: [.blue, .cyan]),
startPoint: CGPoint(x: 0, y: centerY - amplitude),
endPoint: CGPoint(x: 0, y: size.height)
)
)
}
.frame(width: geometry.size.width, height: geometry.size.height)
}
}
.ignoresSafeArea() // Ensures the wave covers the entire screen
}
}🧠 Line-by-Line Explanation
GeometryReader { geometry in
This gives us the actual screen size so we can draw our canvas across the full width and height of the device.
TimelineView(.animation) { timeline in
TimelineView is a SwiftUI view that updates over time — think of it like a heartbeat. The .animation schedule ensures it updates every animation frame.
More on animation: Read Here
**Advanced Animations in SwiftUI: matchedGeometryEffect, TimelineView, PhaseAnimator & Beyond (2025…** _In this deep dive, we’re exploring SwiftUI’s most advanced animation tools — from layout-matching transitions and…_medium.comhttps://medium.com/swift-pal/advanced-animations-in-swiftui-matchedgeometryeffect-timelineview-phaseanimator-beyond-2025-da8876b7b0b9
let date = timeline.date.timeIntervalSinceReferenceDate
This gives us the current timestamp (in seconds) that keeps increasing. We’ll use this to animate the wave by shifting its phase.
Canvas { context, size in
The Canvas is a drawing surface. Think of it like your paintable area.
context= your drawing tools (pens, brushes)size= the dimensions of the canvas (fromGeometryReader)
var path = Path()
We start by creating a new Path, which is essentially a sequence of lines, curves, or shapes.
let amplitude: CGFloat = 40
This sets the height of the wave — the taller it is, the more pronounced the motion.
let frequency: CGFloat = 80
This controls how stretched out the wave is — higher values = longer wavelengths (more relaxed).
let speed = date * 2
We use time (date) to animate the wave horizontally. Multiplying it controls the speed of movement.
let floatOffset = sin(date * 1.5) * 15
Here’s the wave’s vertical floating effect.
sin(date * 1.5)gives us a smooth oscillation (goes from -1 to 1)* 15scales it up to a 30-point range (±15 points up/down)
let centerY = size.height * 0.4 + floatOffset
We define the vertical center of the wave:
size.height * 0.4places it slightly above center (feel free to tweak this)+ floatOffsetadds that slow up-down animation
path.move(to: CGPoint(x: 0, y: centerY))
This sets the starting point of our wave drawing — the far left edge of the screen, horizontally.
for x in stride(from: 0, through: size.width, by: 1) {
We loop through every pixel horizontally (x = 0 to screen width), stepping by 1.
let relativeX = x / frequency
We normalize the x-value based on frequency. This lets us pass smoother values into sin() so the wave stretches nicely.
let y = sin(relativeX + speed) * amplitude + centerY
This is the core wave formula:
sin(relativeX + speed)gives us a smooth curve across the screen, and it shifts over time.* amplitudemakes it tall or short.+ centerYmoves it up/down on screen.
The result is a point along the sine wave.
path.addLine(to: CGPoint(x: x, y: y))
We draw a line from the previous point to this new point — connecting all x-points to form the wave’s curve.
path.addLine(to: CGPoint(x: size.width, y: size.height))
path.addLine(to: CGPoint(x: 0, y: size.height))
These two lines complete the shape by drawing down to the bottom right, then over to the bottom left — essentially creating a filled shape from wave to bottom of screen.
path.closeSubpath()
This tells the drawing engine:
“Close the shape — connect the last point back to the first.”
Now we have a closed polygon we can fill.
context.fill(path, with: .linearGradient(...))
We now fill the wave shape with a vertical gradient from .blue to .cyan.
startPoint: near the top of the waveendPoint: at the bottom of the screen
This makes the ocean look deep and dimensional.
.frame(...) and .ignoresSafeArea()
.frame(...): Ensures the canvas fills the full screen size..ignoresSafeArea(): Prevents layout padding so the wave extends behind the safe areas (like the home bar).
💡 You Can Use This For…
- A calming background on a login or onboarding screen
- A meditation, sleep, or nature-themed app
- Just because your app deserves to be prettier than every competitor’s 🌊
🎯 Animated Loader with Path + Shape + Animation
You’ve seen spinners a hundred times. But here, we’re not dropping in a system ProgressView or importing Lottie files. We’re going to draw and animate a loader entirely with **Shape** + **Path**, with full control over its look and behavior.
🌀 What We’re Building
A circular loader that:
- Animates a stroke around a circle (think YouTube buffering)
- Uses
trim(from:to:)to reveal the stroke - Animates infinitely in a loop
- Can be fully customized with stroke width, color, style
Let’s dive in.
🧪 Full Code: Animated Circular Loader
struct ArcLoader: Shape {
// Controls how much of the circle is visible
var trim: CGFloat
// Enable animation of the trim value
var animatableData: CGFloat {
get { trim }
set { trim = newValue }
}
func path(in rect: CGRect) -> Path {
var path = Path()
// Draw an arc from 0 to trim * 360 degrees
path.addArc(
center: CGPoint(x: rect.midX, y: rect.midY),
radius: rect.width / 2,
startAngle: .degrees(0),
endAngle: .degrees(Double(trim) * 360),
clockwise: false
)
return path
}
}🎯 What’s Happening Here?
ArcLoaderis aShapethat draws part of a circle.- The
trimparameter defines how much of the arc is shown (from 0.0 to 1.0). animatableDatalets SwiftUI animatetrimsmoothly over time.- Inside
path(in:), we useaddArcto draw a curve from 0° totrim × 360°.
🔁 Add Animation with SwiftUI
Now let’s animate it:
struct LoaderView: View {
@State private var progress: CGFloat = 0
var body: some View {
ArcLoader(trim: progress)
.stroke(
AngularGradient(
gradient: Gradient(colors: [.purple, .blue]),
center: .center
),
style: StrokeStyle(lineWidth: 8, lineCap: .round)
)
.frame(width: 100, height: 100)
.onAppear {
withAnimation(
.linear(duration: 2).repeatForever(autoreverses: false)
) {
progress = 1
}
}
}
}Try the code, and share the experience with me on my social platforms.
🔍 Line-by-Line Breakdown
@State private var progress: This controls the current state of the stroke's trim.ArcLoader(trim: progress): We pass the animated value into our shape..stroke(...): Draw the shape using a gradient stroke and rounded edges..onAppear { withAnimation(...) }: Animateprogressfrom 0 to 1 over 2 seconds, looping infinitely.
🧠 Why This is Cool
- It’s fully customizable — thickness, color, speed, shape.
- You can turn this into a spinner, a circular progress bar, a timer ring, or even a pulsing radar.
- You understand what’s being drawn, not just using something off-the-shelf.
🧭 When to Use Shape, Path, or Canvas in SwiftUI
If you’re new to drawing in SwiftUI, it’s easy to get confused. “Should I use a Shape? Or draw a Path inside a Canvas? Or maybe all three?" 😵💫
Let’s break it down once and for all — with real-world use cases so you know exactly when to reach for what.
🎨 Shape: Best for Reusable & Animatable Geometry
A Shape is perfect when you want to define a custom visual structure that:
- Can be reused across multiple views
- Supports animation via
animatableDat - Stays lightweight and easy to test
🛠 Use Shape When:
- You’re building a reusable component like a custom loader, progress bar, or pie chart
- You want SwiftUI’s built-in animation system to handle morphing and transitions
- You want your shape to work seamlessly with
.stroke(),.fill(),.overlay()etc.
struct WaveShape: Shape {
func path(in rect: CGRect) -> Path {
var path = Path()
// Draw shape logic here
return path
}
}✅ Best for: Composable, animatable vector visuals
🖊️ Path: For Manual, One-Off Drawing
Path gives you full control — but without animation or view lifecycle benefits. Think of it like a pencil.
🛠 Use Path When:
- You’re inside a
CanvasorShape, and need to draw free-form graphics - You want to manually control curves, lines, and closures
- You don’t need standalone view behavior — you’re just sketching geometry
var path = Path()
path.move(to: ...)
path.addLine(to: ...)✅ Best for: Drawing shapes manually within other drawing containers
🖼️ Canvas: The Powerhouse for Dynamic, Time-Based Drawing
If Shape is your pencil and Path is your sketchpad, Canvas is your full-blown graphics workstation.
🛠 Use Canvas When:
- You need fine-grained control over rendering
- You want to use
**TimelineView**for frame-by-frame animation - You’re building things like animated waves, particle systems, or real-time visualizations
- You want to draw multiple layers or blend modes
Canvas { context, size in
// Draw shapes, apply gradients, perform transforms
}🔗 Where to Go From Here
If you enjoyed diving into drawing and animations in SwiftUI, here are a few hand-picked articles that continue the journey:
🎞️ Want to master advanced animation techniques?
Read: Advanced Animations in SwiftUI: matchedGeometryEffect, TimelineView, PhaseAnimator & Beyond
**Advanced Animations in SwiftUI: matchedGeometryEffect, TimelineView, PhaseAnimator & Beyond (2025…** _In this deep dive, we’re exploring SwiftUI’s most advanced animation tools — from layout-matching transitions and…_medium.comhttps://medium.com/swift-pal/advanced-animations-in-swiftui-matchedgeometryeffect-timelineview-phaseanimator-beyond-2025-da8876b7b0b9
🎯 Need to brush up your basics before diving deeper?
Start here: SwiftUI for Beginners in 2025: Build Your First iOS App Step-by-Step
**SwiftUI for Beginners in 2025: Build Your First iOS App Step-by-Step** _This step-by-step guide will walk you through building your very first iOS app — no prior experience needed! Learn the…_medium.comhttps://medium.com/swift-pal/swiftui-for-beginners-in-2025-build-your-first-ios-app-step-by-step-400c627f19bc
💅 Want your app to look as good as it moves?
Style it up: SwiftUI Styling Guide: Fonts, Themes, Dark Mode & Why Order Matters
**SwiftUI Styling Guide: Fonts, Themes, Dark Mode & Why Order Matters** _🎨 Give your SwiftUI app a complete style upgrade! Learn how to add custom fonts, build adaptive themes, support dark…_medium.comhttps://medium.com/swift-pal/swiftui-styling-guide-fonts-themes-dark-mode-why-order-matters-7fbf5389d384
✨ Wrapping Up
We just went from drawing simple shapes to animating full-screen waves and crafting custom loaders — all in pure SwiftUI.
Whether you’re building beautiful onboarding screens, meditative animations, or just want your UI to look a little less… default — Shape, Path, and Canvas are your new best friends.
SwiftUI’s drawing system isn’t just powerful — it’s actually fun once you get the hang of it. And the best part? No external libraries, no Core Graphics, and no spinning your wheels over UIKit.
🎉 Enjoyed this article? Your support means the world to me!
💬 Drop a comment below! I love hearing about your experiences and answering questions
🎬 Subscribe on Youtube and become early subscribers of my channel: https://www.youtube.com/@swift-pal
💼 Let’s connect on LinkedIn for more professional insights: https://www.linkedin.com/in/karan-pal
Happy coding! 🚀
New articles, straight to your inbox.
No spam, no filler — just new writing on iOS, the web, and AI when it ships. Unsubscribe anytime.
Keep reading
Why I'm Rebuilding My Blog From Scratch (and Leaving Medium)
After years of publishing on someone else's platform, I'm moving my writing to a home I actually own. Here's the reasoning, and what I'm building instead.
ReadData Is the Model: The Most Ignored Part of AI
A beginner-friendly guide to why data quality beats model hype.
Read