
Creating shapes using Path in the SwiftUI Canvas view
Learn how to combine basic shapes to build custom visualizations and layouts using SwiftUI and the Canvas view.
In the article "Exploring creative coding with Swift and SwiftUI", we saw how Xcode and the SwiftUI framework, along with its components, are ideal tools for experimenting with creative coding projects. A starting point for this exploratory process is combining SwiftUI’s Canvas
with basic geometric shapes, transforming circles, triangles, squares, pentagons and hexagons into frames that can move and change over time.
In this article, we’ll build a flexible set of shapes and then place them within an animated canvas that appears to breathe and spin dynamically.
Let's start by implementing a type that defines the shapes to be drawn on our canvas.
import SwiftUI
import CoreGraphics // For sin(_:) and cos(_:)
// Enum defining different shape types that can be drawn.
enum ShapeType: CaseIterable {
case circle, triangle, square, pentagon, hexagon
// Returns the path representing the shape inside a given rectangle.
func path(in rect: CGRect) -> Path {
switch self {
case .circle:
return Path(ellipseIn: rect)
case .triangle:
return polygonPath(sides: 3, in: rect)
case .square:
return polygonPath(sides: 4, in: rect)
case .pentagon:
return polygonPath(sides: 5, in: rect)
case .hexagon:
return polygonPath(sides: 6, in: rect)
}
}
// Creates a regular polygon path with the specified number of sides.
private func polygonPath(sides: Int, in rect: CGRect) -> Path {
let center = CGPoint(x: rect.midX, y: rect.midY)
let radius = min(rect.width, rect.height) / 2
var path = Path()
for index in 0..<sides {
// Angle in radians, rotated so first point is at the top
let angle = (Double(index) / Double(sides)) * 2 * .pi - .pi / 2
let vertex = CGPoint(
x: center.x + cos(angle) * radius,
y: center.y + sin(angle) * radius
)
index == 0 ? path.move(to: vertex) : path.addLine(to: vertex)
}
path.closeSubpath()
return path
}
}
The ShapeType
enumeration lists all the shapes that we want to create and define the polygonPath(sides:in:)
private method for calculating the coordinates of a regular polygon to be drawn.
It works by first finding the center of the given rectangle and then calculating a radius that fits within that space. Using this radius, it places each corner of the polygon around a circle by stepping through equal angles. Finally, it connects those points to form the outline of the shape. This makes it easy to draw any regular polygon just by changing the number of sides.
To draw a shape using a Path
in a Canvas
view, you need to define where the shape needs to be drawn and the size of the bounding rectangle. Then ,use the stroke(_:with:style:)
method of the Canvas' graphic context to define the path to be rendered and its color and style.
struct SimpleShapeView: View {
let shapeType: ShapeType = .hexagon
let shapeSize: CGFloat = 120
var body: some View {
Canvas { context, size in
let center = CGPoint(x: size.width / 2, y: size.height / 2)
// Define the shape’s bounding rect
let shapeRect = CGRect(
x: center.x - shapeSize / 2,
y: center.y - shapeSize / 2,
width: shapeSize,
height: shapeSize
)
// Get path for selected shape
let path = shapeType.path(in: shapeRect)
// Draw with a fixed color and stroke
context.stroke(
path,
with: .color(Color.white),
style: StrokeStyle(lineWidth: 2, lineCap: .round)
)
}
.background(Color.black.mix(with: Color.blue, by: 0.35))
.ignoresSafeArea()
}
}
Now that we know how to render a Path using Canvas
, let's make it a bit more interesting. Define a view called CreativeShapesView
, which will be responsible for rendering and animating our final output based on a series of different properties, using the TimelineView
and Canvas
views.
Let's start defining the series of parameters that will determine how the shapes are rendered.
struct CreativeShapesView: View {
// Animation configuration
let ringCount: Int = 5 // Number of concentric rings
let shapesPerRingBase: Int = 24 // Number of shapes in the innermost ring
let ringSpacing: CGFloat = 80 // Distance between rings
let lineWidth: CGFloat = 1.2 // Stroke width of each shape
let breatheAmplitude: CGFloat = 15 // Breathing motion amplitude
let breatheSpeed: Double = 1.2 // Breathing cycles per second
let spinSpeed: Double = 70 // Rotation speed in degrees per second
var body: some View {
// TODO: Render and animate the shapes
}
}
Now, inside the view body, let's wrap everything in a GeometryReader
to determine our drawing area's exact dimensions, then use a TimelineView
that provides a continuous time value to drive two types of motion throughout the animation.
var body: some View {
GeometryReader { geometry in
TimelineView(.animation) { timeline in
let currentTime = timeline.date.timeIntervalSinceReferenceDate
Canvas { context, canvasSize in
drawRings(in: context, size: canvasSize, time: currentTime)
}
.background(Color.black.mix(with: Color.blue, by: 0.35))
.ignoresSafeArea()
}
}
}
To draw each of the shapes, we create a method that takes the GraphicsContext
, the canvas size and the current time in the animation timeline and draws the shapes' path in a calculated position and color.
// Draws rings of shapes in a graphics context
private func drawRings(in context: GraphicsContext, size: CGSize, time: TimeInterval) {
let canvasCenter = CGPoint(x: size.width / 2, y: size.height / 2)
for ringIndex in 0..<ringCount {
let animatedRadius = calculateRadius(for: ringIndex, at: time)
let shapesInRing = shapesPerRingBase - ringIndex * 2
let shapeType = ShapeType.allCases[ringIndex % ShapeType.allCases.count]
let shapeSize = ringSpacing * 0.6
let angleOffset = Angle(degrees: Double(ringIndex) * 10).radians
for shapeIndex in 0..<shapesInRing {
let shapePath = generateShape(
ringIndex: ringIndex,
shapeIndex: shapeIndex,
totalShapes: shapesInRing,
center: canvasCenter,
radius: animatedRadius,
shapeSize: shapeSize,
shapeType: shapeType,
angleOffset: angleOffset,
time: time
)
let hue = calculateHue(for: ringIndex, shapeIndex: shapeIndex, totalShapes: shapesInRing)
let color = Color(hue: hue, saturation: 0.6, brightness: 0.9)
context.stroke(
shapePath,
with: .color(color),
style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)
)
}
}
}
// Calculates the radius in which each shape is drawn at a particular time
private func calculateRadius(for ringIndex: Int, at time: TimeInterval) -> CGFloat {
let baseRadius = ringSpacing * CGFloat(ringIndex + 1)
let breathing = sin(time * breatheSpeed + Double(ringIndex)) * Double(breatheAmplitude)
return baseRadius + CGFloat(breathing)
}
// Calculates the color of the shape based on where it's located
private func calculateHue(for ringIndex: Int, shapeIndex: Int, totalShapes: Int) -> Double {
let frac = Double(shapeIndex) / Double(totalShapes)
return (Double(ringIndex) / Double(ringCount) * 0.8) + frac * 0.2
}
// Generates the path of a shape based on a number of different parameters
private func generateShape(
ringIndex: Int,
shapeIndex: Int,
totalShapes: Int,
center: CGPoint,
radius: CGFloat,
shapeSize: CGFloat,
shapeType: ShapeType,
angleOffset: Double,
time: TimeInterval
) -> Path {
let fraction = Double(shapeIndex) / Double(totalShapes)
let angle = fraction * 2 * .pi + angleOffset
let shapeCenter = CGPoint(
x: center.x + cos(angle) * radius,
y: center.y + sin(angle) * radius
)
let shapeRect = CGRect(
x: shapeCenter.x - shapeSize / 2,
y: shapeCenter.y - shapeSize / 2,
width: shapeSize,
height: shapeSize
)
let path = shapeType.path(in: shapeRect)
let rotation = Angle(degrees: time * spinSpeed + fraction * 360)
let transform = CGAffineTransform(translationX: shapeRect.midX, y: shapeRect.midY)
.rotated(by: CGFloat(rotation.radians))
.translatedBy(x: -shapeRect.midX, y: -shapeRect.midY)
return path.applying(transform)
}
Each shape is placed using polar coordinates, which means we calculate its position based on an angle and a distance from the center of the screen rather than traditional x and y coordinates. For every ring, we determine a base radius and add an animated offset using a sine wave to make it expand and contract, creating a breathing-like effect that makes the entire pattern feel alive.
The animation works by redrawing the pattern many times per second within a single Canvas
block, where each ring contains different types of shapes that are positioned around invisible circular paths using mathematical calculations with sine and cosine functions.
The rings are spaced around the circle by dividing the full 360 degrees by the number of items in that ring, and each shape is drawn inside a rectangle at that calculated position with individual rotations applied so they spin around their centers. Since the rotation angle combines both global time and shape index, we create a field of polygons spinning at varying rates. The color of each shape is based on its ring index and its angle within the ring, creating a soft gradient of hues that shift as you move from the center outward.
Here is the final result:

The Canvas
view isn't just about drawing graphic elements across the screen, but about creating different types of layouts where every element's position is precisely calculated relative to the center point.
This approach uses trigonometry to place shapes along curved, circular paths instead of using traditional SwiftUI layout containers like HStack
or VStack
that arrange items in straight lines, showcasing how creative coding can break free from conventional rectangular layouts and create organic, radial arrangements that open up entirely new possibilities for designing visual experiences.
Here is the complete code of the CreativeShapesView
, so you can test it on Xcode.
import SwiftUI
import CoreGraphics
struct CreativeShapesView: View {
// Rendering and animation configurations
let ringCount: Int = 5
let shapesPerRingBase: Int = 24
let ringSpacing: CGFloat = 80
let lineWidth: CGFloat = 1.2
let breatheAmplitude: CGFloat = 15
let breatheSpeed: Double = 1.2
let spinSpeed: Double = 70
var body: some View {
GeometryReader { geometry in
TimelineView(.animation) { timeline in
let currentTime = timeline.date.timeIntervalSinceReferenceDate
Canvas { context, canvasSize in
drawRings(in: context, size: canvasSize, time: currentTime)
}
.background(Color.black.mix(with: Color.blue, by: 0.35))
.ignoresSafeArea()
}
}
}
// Methods for drawing the rings of shapes
private func drawRings(in context: GraphicsContext, size: CGSize, time: TimeInterval) {
let canvasCenter = CGPoint(x: size.width / 2, y: size.height / 2)
for ringIndex in 0..<ringCount {
let animatedRadius = calculateRadius(for: ringIndex, at: time)
let shapesInRing = shapesPerRingBase - ringIndex * 2
let shapeType = ShapeType.allCases[ringIndex % ShapeType.allCases.count]
let shapeSize = ringSpacing * 0.6
let angleOffset = Angle(degrees: Double(ringIndex) * 10).radians
for shapeIndex in 0..<shapesInRing {
let shapePath = generateShape(
ringIndex: ringIndex,
shapeIndex: shapeIndex,
totalShapes: shapesInRing,
center: canvasCenter,
radius: animatedRadius,
shapeSize: shapeSize,
shapeType: shapeType,
angleOffset: angleOffset,
time: time
)
let hue = calculateHue(for: ringIndex, shapeIndex: shapeIndex, totalShapes: shapesInRing)
let color = Color(hue: hue, saturation: 0.6, brightness: 0.9)
context.stroke(
shapePath,
with: .color(color),
style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)
)
}
}
}
private func calculateRadius(for ringIndex: Int, at time: TimeInterval) -> CGFloat {
let baseRadius = ringSpacing * CGFloat(ringIndex + 1)
let breathing = sin(time * breatheSpeed + Double(ringIndex)) * Double(breatheAmplitude)
return baseRadius + CGFloat(breathing)
}
private func calculateHue(for ringIndex: Int, shapeIndex: Int, totalShapes: Int) -> Double {
let frac = Double(shapeIndex) / Double(totalShapes)
return (Double(ringIndex) / Double(ringCount) * 0.8) + frac * 0.2
}
private func generateShape(
ringIndex: Int,
shapeIndex: Int,
totalShapes: Int,
center: CGPoint,
radius: CGFloat,
shapeSize: CGFloat,
shapeType: ShapeType,
angleOffset: Double,
time: TimeInterval
) -> Path {
let fraction = Double(shapeIndex) / Double(totalShapes)
let angle = fraction * 2 * .pi + angleOffset
let shapeCenter = CGPoint(
x: center.x + cos(angle) * radius,
y: center.y + sin(angle) * radius
)
let shapeRect = CGRect(
x: shapeCenter.x - shapeSize / 2,
y: shapeCenter.y - shapeSize / 2,
width: shapeSize,
height: shapeSize
)
let path = shapeType.path(in: shapeRect)
let rotation = Angle(degrees: time * spinSpeed + fraction * 360)
let transform = CGAffineTransform(translationX: shapeRect.midX, y: shapeRect.midY)
.rotated(by: CGFloat(rotation.radians))
.translatedBy(x: -shapeRect.midX, y: -shapeRect.midY)
return path.applying(transform)
}
}