Applying Transformations Within the Graphics Context of a SwiftUI Canvas View

Applying Transformations Within the Graphics Context of a SwiftUI Canvas View

Discover how to apply transformations like scaling, rotating, and translating to create dynamic and flexible visual content.

Computational art is often driven by animations that continuously mutate the content, sometimes ending up in a hypnotic effect with just simple shapes.

In the previous article, we saw how simple shapes can be used to create engaging visual content. In this article, we will explore how, starting from basic shapes, we can allow them to mutate and morph into each other fluidly.

Let's start by implementing a type that defines the shapes to be drawn on our canvas.

Drawing Polygons

To create a polygon, we need to define a set of points arranged evenly around a circle, where each point becomes a corner (or vertex) of the shape.

import SwiftUI
import CoreGraphics // For sin(_:) and cos(_:)

struct PolygonShape {
    let sides: Int

    // Returns the vertices of a regular polygon inside the given rect.
    func points(in rect: CGRect) -> [CGPoint] {
        let center = CGPoint(x: rect.midX, y: rect.midY)
        let radius = min(rect.width, rect.height) / 2

        return (0..<sides).map { i in
            let angle = (Double(i) / Double(sides)) * 2 * .pi - .pi / 2
            return CGPoint(
                x: center.x + cos(angle) * radius,
                y: center.y + sin(angle) * radius
            )
        }
    }
}

The result is a set of coordinates that outline a symmetrical polygon. Once we have the set of points, we can use SwiftUI’s Path to connect them, forming the visible outline of the shape on the screen.

Interpolating Between Polygons

To animate a polygon morphing into another, we need to calculate a new polygon that smoothly transitions between the original and target polygons and should be determined by interpolating the vertices of the original and target polygons based on a progress value.

Let's define a new method that will be responsible for merging two different shapes based on the number of vertices:

func interpolatedPolygon(polygonA: PolygonShape, polygonB: PolygonShape, in rect: CGRect, progress: Double) -> Path {
        // Uses the greater number of sides between the two shapes to align their point counts
        let maxPoints = max(polygonA.sides, polygonB.sides)

        // Normalizes both shapes to have the same number of points
        let fromPoints = evenlyMappedPoints(from: polygonA, maxPoints: maxPoints, in: rect)
        let toPoints = evenlyMappedPoints(from: polygonB, maxPoints: maxPoints, in: rect)

        var path = Path()

        for i in 0..<maxPoints {
            let a = fromPoints[i]
            let b = toPoints[i]

            // Interpolates between each pair of points using the progress value (0...1)
            let interpolated = CGPoint(
                x: a.x + (b.x - a.x) * progress,
                y: a.y + (b.y - a.y) * progress
            )

            // Moves to the first point, then add lines to the rest
            if i == 0 {
                path.move(to: interpolated)
            } else {
                path.addLine(to: interpolated)
            }
        }

        path.closeSubpath()
        return path
    }

private func evenlyMappedPoints(from shape: PolygonShape, maxPoints: Int, in rect: CGRect) -> [CGPoint] {
    let points = shape.points(in: rect)

    // Maps the polygon’s original points to a uniform distribution of `maxPoints`
    return (0..<maxPoints).map { i in
        let index = Double(i) / Double(maxPoints) * Double(shape.sides)
        return points[Int(index) % shape.sides]
    }
}

The interpolatedPolygon function takes two PolygonShape instances,from and to, along with a progress value. The progress is a Double between 0 and 1. A value of 0 means the result should match the from shape exactly, while a value of 1 means it should fully resemble the to shape.

To perform the interpolation, both polygons must be represented by the same number of points. For example, if we’re morphing a triangle into a hexagon, both shapes are temporarily expanded to six points. This doesn’t change the visual structure of the triangle, but it allows us to pair each of its corners with a matching corner from the hexagon.

After making sure both shapes have the same number of points we go through each pair of points, one from the first shape and one from the second. For each pair, we find a new point that is between them, based on the animation status. When the progress is 0, the new point matches the first shape’s point; when it’s 1, it matches the second shape’s point; and values in between create points in between the two.

Using all these new points, we draw a new shape that looks like it’s smoothly changing from the first polygon into the second.

Drawing with Canvas and TimelineView

Now, let’s integrate everything using Canvas and TimelineView. TimelineView enables us to create real-time animations by providing a continuously updating time value. Within the Canvas, we utilize transformations to independently rotate and scale each layer.

struct MorphShapeView: View {
    var body: some View {
        TimelineView(.animation) { timeline in
            // Gets the current time (seconds since reference date)
            let currentTime = timeline.date.timeIntervalSinceReferenceDate

            Canvas { context, size in
                // Calculates the center point of the canvas
                let center = CGPoint(x: size.width / 2, y: size.height / 2)

                // Moves the origin to the center for easier drawing of centered shapes
                context.translateBy(x: center.x, y: center.y)

                drawLayers(context: &context, time: currentTime)
            }
        }
        .background(Color.black)
        .ignoresSafeArea()
    }
}

To draw each of the shapes, we need to create a method that takes the GraphicsContext and the current time in the animation timeline and draws the shapes' path in a calculated position and color.

private func drawLayers(context: inout GraphicsContext, time t: TimeInterval) {
    let layers = 8

    for index in 0..<layers {
        // Calculates rotation angle for each layer, increasing speed per layer
        let rotation = Angle(degrees: t * ( Double(index))).radians

        // Calculates scale oscillation for a pulsing effect
        let scale = 1.0 + 0.08 * CGFloat(sin(t * 1.5 + Double(index)))

        // Create a transform with rotation followed by scaling
        let transform = CGAffineTransform.identity
            .rotated(by: rotation)
            .scaledBy(x: scale, y: scale)

        // Apply the transform to the drawing context
        context.concatenate(transform)

        // Defines a circular bounding rect for the polygon, increasing radius per layer
        let radius = CGFloat(60 + index * 45)
        let rect = CGRect(x: -radius, y: -radius, width: radius * 2, height: radius * 2)

        
        let shapeA = PolygonShape(sides: 3 + (index % 3))

        let shapeB = PolygonShape(sides: 6)

        // Calculates morph progress oscillating smoothly between 0 and 1
        let morphProgress = (sin(t + Double(index)) + 1) / 2

        // Generates the interpolated polygon path based on morph progress
        let path = interpolatedPolygon(polygonA: shapeA, polygonB: shapeB, in: rect, progress: morphProgress)

        // Calculates a hue value
        let hue = (Double(index) / Double(layers)) + t.truncatingRemainder(dividingBy: 10) / 10
        let color = Color(hue: hue.truncatingRemainder(dividingBy: 1), saturation: 0.8, brightness: 1.0)

        
        context.stroke(path, with: .color(color.opacity(0.9)), style: StrokeStyle(lineWidth: 1.5))

        // Reverse the transform to avoid accumulating transforms for the next layer
        context.concatenate(transform.inverted())
    }
}

The drawLayers function is responsible for drawing multiple animated, morphing polygon layers on top of each other. These layers are transformed, colored, and interpolated, with each layer having a different rotation, scale, and shape transition.

For each layer, we calculate a rotation angle and scale factor that evolve over time. The rotation and scale are both implemented using CGAffineTransform and applied to the GraphicsContext using context.concatenate(transform). This modifies the context’s coordinate space so that anything drawn afterward is rotated and scaled according to our transform.

For the shape itself, we pick two polygons: shapeA is a polygon with 3 to 5 sides (based on the loop index), and shapeB is always a hexagon. The morphProgress value determines how far along the interpolation is, and animates over time to smoothly shift between the two.

After drawing each transformed shape, we reset the graphics context by applying the inverse of the transform we just used. This ensures that the next layer doesn’t inherit any rotation or scaling from the previous one. By restoring the context every time, we guarantee that each layer is drawn cleanly and independently in the same starting coordinate space.

Final Result

0:00
/0:14

By combining transformations like rotation and scaling with smooth shape morphing, we can make simple polygons twist, shift, and flow into entirely new forms. Each shape moves on its own, changing size and orientation, while also gradually transforming into another shape, making them an example of how creative use of motion and geometry can turn static elements into something dynamic and expressive.

Below is the full code for MorphShapeView, ready to explore in Xcode.

import SwiftUI
import CoreGraphics // For sin(_:) and cos(_:)

struct PolygonShape {
    let sides: Int

    // Returns the vertices of a regular polygon inside the given rect.
    func points(in rect: CGRect) -> [CGPoint] {
        let center = CGPoint(x: rect.midX, y: rect.midY)
        let radius = min(rect.width, rect.height) / 2

        return (0..<sides).map { i in
            let angle = (Double(i) / Double(sides)) * 2 * .pi - .pi / 2
            return CGPoint(
                x: center.x + cos(angle) * radius,
                y: center.y + sin(angle) * radius
            )
        }
    }
}

struct MorphShapeView: View {
    var body: some View {
        TimelineView(.animation) { timeline in
            // Gets the current time (seconds since reference date)
            let currentTime = timeline.date.timeIntervalSinceReferenceDate

            Canvas { context, size in
                // Calculates the center point of the canvas
                let center = CGPoint(x: size.width / 2, y: size.height / 2)

                // Moves the origin to the center for easier drawing of centered shapes
                context.translateBy(x: center.x, y: center.y)

                drawLayers(context: &context, time: currentTime)
            }
        }
        .background(Color.black)
        .ignoresSafeArea()
    }
    
    private func drawLayers(context: inout GraphicsContext, time t: TimeInterval) {
        let layers = 8

        for index in 0..<layers {
            // Calculates rotation angle for each layer, increasing speed per layer
            let rotation = Angle(degrees: t * ( Double(index))).radians

            // Calculates scale oscillation for a pulsing effect
            let scale = 1.0 + 0.08 * CGFloat(sin(t * 1.5 + Double(index)))

            // Create a transform with rotation followed by scaling
            let transform = CGAffineTransform.identity
                .rotated(by: rotation)
                .scaledBy(x: scale, y: scale)

            // Apply the transform to the drawing context
            context.concatenate(transform)

            // Defines a circular bounding rect for the polygon, increasing radius per layer
            let radius = CGFloat(60 + index * 45)
            let rect = CGRect(x: -radius, y: -radius, width: radius * 2, height: radius * 2)

            
            let shapeA = PolygonShape(sides: 3 + (index % 3))

            let shapeB = PolygonShape(sides: 6)

            // Calculates morph progress oscillating smoothly between 0 and 1
            let morphProgress = (sin(t + Double(index)) + 1) / 2

            // Generates the interpolated polygon path based on morph progress
            let path = interpolatedPolygon(polygonA: shapeA, polygonB: shapeB, in: rect, progress: morphProgress)

            // Calculates a hue value
            let hue = (Double(index) / Double(layers)) + t.truncatingRemainder(dividingBy: 10) / 10
            let color = Color(hue: hue.truncatingRemainder(dividingBy: 1), saturation: 0.8, brightness: 1.0)

            
            context.stroke(path, with: .color(color.opacity(0.9)), style: StrokeStyle(lineWidth: 1.5))

            // Reverse the transform to avoid accumulating transforms for the next layer
            context.concatenate(transform.inverted())
        }
    }
    
    func interpolatedPolygon(polygonA: PolygonShape, polygonB: PolygonShape, in rect: CGRect, progress: Double) -> Path {
        // Uses the greater number of sides between the two shapes to align their point counts
        let maxPoints = max(polygonA.sides, polygonB.sides)

        // Normalizes both shapes to have the same number of points
        let fromPoints = evenlyMappedPoints(from: polygonA, maxPoints: maxPoints, in: rect)
        let toPoints = evenlyMappedPoints(from: polygonB, maxPoints: maxPoints, in: rect)

        var path = Path()

        for i in 0..<maxPoints {
            let a = fromPoints[i]
            let b = toPoints[i]

            // Interpolates between each pair of points using the progress value (0...1)
            let interpolated = CGPoint(
                x: a.x + (b.x - a.x) * progress,
                y: a.y + (b.y - a.y) * progress
            )

            // Moves to the first point, then add lines to the rest
            if i == 0 {
                path.move(to: interpolated)
            } else {
                path.addLine(to: interpolated)
            }
        }

        path.closeSubpath()
        return path
    }

    private func evenlyMappedPoints(from shape: PolygonShape, maxPoints: Int, in rect: CGRect) -> [CGPoint] {
        let points = shape.points(in: rect)

        // Maps the polygon’s original points to a uniform distribution of `maxPoints`
        return (0..<maxPoints).map { i in
            let index = Double(i) / Double(maxPoints) * Double(shape.sides)
            return points[Int(index) % shape.sides]
        }
    }
}