All articles
iOS15 min read

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…

K
Karan Pal
Author
Image generated by AI
Image generated by AI

👋🏽 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:

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?

🧠 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:

🧭 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:

We alternate between outer → inner → outer → inner…

So:

🧮 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?”

So the angles look like this:

🎯 let radius = i.isMultiple(of: 2) ? outerRadius : innerRadius

This line alternates between two radii:

So:

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).”

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: …) }

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:

🌊 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:

🧪 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.

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.

let centerY = size.height * 0.4 + floatOffset

We define the vertical center of the wave:

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:

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.

This makes the ocean look deep and dimensional.

.frame(...) and .ignoresSafeArea()

💡 You Can Use This For…

🎯 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:

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?

🔁 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

🧠 Why This is Cool

🧭 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:

🛠 Use Shape When:

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:

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:

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! 🚀

● The newsletter

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