Make writing a habit together! This is the third day of my participation in the “Gold Digging Day New Plan · April More text Challenge”. Click here for more details.

Part 5 of this advanced SwiftUI animation series will explore the Canvas view. Technically, it’s not an animated view, but when combined with the TimelineView from Part 4, it opens up a lot of interesting possibilities, as this digital rain example shows.

I had to postpone this article for a few weeks because the Canvas view is a bit unstable. We’re still in the beta phase, so that’s to be expected. However, the crashes generated by this view make some examples here unshareable. Not all of the problems have been solved, but each example now runs smoothly. At the end of the article, I’ll point out some of the solutions I’ve found.

A simple Canvas

In short, the Canvas is a SwiftUI view that gets draw instructions from a render closure. Unlike most closures in the SwiftUI API, it is not a view generator. This means we can use Swift without any restrictions.

The closure takes two parameters: context and size. Context uses a new SwiftUI type GraphicsContext, which contains many methods and properties that allow us to draw just about anything. Here is a basic example of how to use Canvas.

struct ContentView: View {
    var body: some View {
        Canvas { context, size in
            let rect = CGRect(origin: .zero, size: size).insetBy(dx: 25, dy: 25)

            // Path
            let path = Path(roundedRect: rect, cornerRadius: 35.0)

            // Gradient
            let gradient = Gradient(colors: [.green, .blue])
            let from = rect.origin
            let to = CGPoint(x: rect.width + from.x, y: rect.height + from.y)
            
            // Stroke path
            context.stroke(path, with: .color(.blue), lineWidth: 25)
            
            // Fill path
            context.fill(path, with: .linearGradient(gradient,
                                                     startPoint: from,
                                                     endPoint: to))
        }
    }
}
Copy the code

The Canvas initializer also has other parameters (opacity, colorMode colorMode, and render synchronization rendersAsynchronously). See Apple’s documentation for more information.

Graph Context – GraphicsContext

GraphicsContext has a lot of methods and properties, but I’m not going to use this article as a reference to list them all. It’s a long list, and it can be a little overwhelming. However, WHEN I updated the Companion for SwiftUI app, I did have to go through all of these methods. It gave me a whole picture. I’m going to try to categorize what’s out there so you can get the same thing.

  • Drawing Paths
  • Drawing Images and Text
  • Drawing Symbols (aka SwiftUI views)
  • Mutating the Graphics Context
  • Reusing CoreGraphics Code
  • Animating the Canvas
  • Canvas Crashes

Path – Paths

The first thing to do to draw a path is to create it. Starting with the first version of SwiftUI, paths can be created and modified in a number of ways. Some of the initializers available are:

let path = Path(roundedRect: rect, cornerSize: CGSize(width: 10, height: 50), style: .continuous)
Copy the code
let cgPath = CGPath(ellipseIn: rect, transform: nil)
let path = Path(cgPath)
Copy the code
let path = Path {
    let points: [CGPoint] =[.init(x: 10, y: 10).init(x: 0, y: 50).init(x: 100, y: 100).init(x: 100, y: 0),]$0.move(to: .zero)
    $0.addLines(points)
}
Copy the code

Paths can also be created from a SwiftUI shape. Shape has a path method that you can use to create a path:

let path = Circle().path(in: rect)
Copy the code

Of course, this also applies to custom shapes:

let path = MyCustomShape().path(in: rect)
Copy the code

Fill the path

To fill a path, use the context.fill() method:

fill(_ path: Path, with shading: GraphicsContext.Shading, style: FillStyle = FillStyle())
Copy the code

Shading indicates how to fill the shape (with color, gradient, tile image, etc.). If you need to indicate the style to use, use the FillStyle type (that is, the even odd/antisense attribute).

Path Stroke – Stroke

To draw a path, use one of these GraphicsContext methods:

stroke(_ path: Path, with shading: GraphicsContext.Shading, style: StrokeStyle)
stroke(_ path: Path, with shading: GraphicsContext.Shading, lineWidth: CGFloat = 1)
Copy the code

You can specify a shading to show how to draw the path. If you need to specify dashes, line caps, connections, etc., use style style. Alternatively, you can specify only the line width.

For a complete example of how to stroke and fill a shape, see the example above (a simple Canvas).

Images and Text – Image & Text

The image and text are drawn using the context draw() method, and there are two versions:

draw(image_or_text, at point: CGPoint, anchor: UnitPoint = .center)
draw(image_or_text, in rect: CGRect)
Copy the code

In the case of images, the second version of Draw () has an additional optional argument, style:

draw(image, in rect: CGRect, style: FillStyle = FillStyle())
Copy the code

Before one of these elements can be drawn, they must be parsed. By parsing, SwiftUI takes into account the environment (e.g., color scheme, display resolution, etc.). In addition, parsing these elements reveals some interesting properties that may be further used in our drawing logic. For example, the parsed text tells us the final size of the specified font. Or we can change the shadows of parsed elements before drawing. To learn more about the available properties and methods, see ResolvedImage and ResolvedText.

Use the context’s resolve() method to get the ResolvedImage from the Image and ResolvedText from the Text.

Parsing is optional, and the draw() method also accepts Image and Text (instead of ResolvedImage and ResolvedText). In this case, draw() automatically parses them. This is handy if you don’t have any use for parsed properties and methods.

In this case, the text is resolved. We use its size to calculate the shading and apply the shading:

struct ExampleView: View {
    var body: some View {
        Canvas { context, size in
            
            let midPoint = CGPoint(x: size.width/2, y: size.height/2)
            let font = Font.custom("Arial Rounded MT Bold", size: 36)
            
            var resolved = context.resolve(Text("Hello World!").font(font))
            
            let start = CGPoint(x: (size.width - resolved.measure(in: size).width) / 2.0, y: 0)
            let end = CGPoint(x: size.width - start.x, y: 0)
            
            resolved.shading = .linearGradient(Gradient(colors: [.green, .blue]),
                                               startPoint: start,
                                               endPoint: end)
            
            context.draw(resolved, at: midPoint, anchor: .center)
        }
    }
}
Copy the code

Symbols – Symbols

When talking about Canvas, the symbol “Symbols” just refers to any SwiftUI. Not to be confused with the SF notation, which is something completely different. The Canvas view has a way of referring to the SwiftUI view, parsing it into a symbol, and then drawing it.

The view to resolve is passed in a ViewBuilder closure, as shown in the following example. In order to reference a view, it needs to be marked with a unique hashed identifier. Note that a parsed symbol can be drawn more than once on the Canvas.

struct ExampleView: View {
    var body: some View {
        Canvas { context, size in
            
            let r0 = context.resolveSymbol(id: 0)!
            let r1 = context.resolveSymbol(id: 1)!
            let r2 = context.resolveSymbol(id: 2)!
            
            context.draw(r0, at: .init(x: 10, y: 10), anchor: .topLeading)
            context.draw(r1, at: .init(x: 30, y: 20), anchor: .topLeading)
            context.draw(r2, at: .init(x: 50, y: 30), anchor: .topLeading)
            context.draw(r0, at: .init(x: 70, y: 40), anchor: .topLeading)

        } symbols: {
            RoundedRectangle(cornerRadius: 10.0).fill(.cyan)
                .frame(width: 100, height: 50)
                .tag(0)
            
            RoundedRectangle(cornerRadius: 10.0).fill(.blue)
                .frame(width: 100, height: 50)
                .tag(1)

            RoundedRectangle(cornerRadius: 10.0).fill(.indigo)
                .frame(width: 100, height: 50)
                .tag(2)}}}Copy the code

The ViewBuilder can also use a ForEach. The same example could be rewritten like this:

struct ExampleView: View {
    let colors: [Color] = [.cyan, .blue, .indigo]
    
    var body: some View {
        Canvas { context, size in
            
            let r0 = context.resolveSymbol(id: 0)!
            let r1 = context.resolveSymbol(id: 1)!
            let r2 = context.resolveSymbol(id: 2)!
            
            context.draw(r0, at: .init(x: 10, y: 10), anchor: .topLeading)
            context.draw(r1, at: .init(x: 30, y: 20), anchor: .topLeading)
            context.draw(r2, at: .init(x: 50, y: 30), anchor: .topLeading)
            context.draw(r0, at: .init(x: 70, y: 40), anchor: .topLeading)

        } symbols: {
            ForEach(Array(colors.enumerated()), id: \.0) { n, c in
                RoundedRectangle(cornerRadius: 10.0).fill(c)
                    .frame(width: 100, height: 50)
                    .tag(n)
            }
        }
    }
}
Copy the code

Animated Symbols – Animated Symbols

I was pleasantly surprised when I tested what would happen if the view was parsed as an animation as a symbol. And guess what, the canvas is constantly repainting it to keep it animated.

struct ContentView: View {
    var body: some View {
        Canvas { context, size in
            
            let symbol = context.resolveSymbol(id: 1)!
            
            context.draw(symbol, at: CGPoint(x: size.width/2, y: size.height/2), anchor: .center)

        } symbols: {
            SpinningView()
                .tag(1)}}}struct SpinningView: View {
    @State private var flag = true
    
    var body: some View {
        Text("")
            .font(.custom("Arial", size: 72))
            .rotationEffect(.degrees(flag ? 0 : 360))
            .onAppear{
                withAnimation(.linear(duration: 1.0).repeatForever(autoreverses: false)) {
                    flag.toggle()
                }
            }
    }
}
Copy the code

Changing the graphics Context

The graphics context can be changed using one of the following methods:

  • addFilter
  • clip
  • clipToLayer
  • concatenate
  • rotate
  • scaleBy
  • translateBy

If you’re familiar with AppKit’s NSGraphicContext or CoreGraphic’s CGContext, you’re probably used to pushing (saving) and popping (restoring) graph context state from the stack. Canvas GraphicsContext works a little differently, and if you want to make a temporary change to the context, you have several options.

To illustrate this point, let’s look at the following example. We need to draw three houses in three colors. Only the middle house needs to be blurred:

All of the following examples will use the following CGPoint extensions:

extension CGPoint {
    static func +(lhs: CGPoint.rhs: CGPoint) -> CGPoint {
        return CGPoint(x: lhs.x + rhs.x, y: lhs.y + rhs.y)
    }

    static func -(lhs: CGPoint.rhs: CGPoint) -> CGPoint {
        return CGPoint(x: lhs.x - rhs.x, y: lhs.y - rhs.y)
    }
}
Copy the code

Here are three ways to achieve the same result:

1. Sort the corresponding operations

Where possible, you can choose to sort the draw operations in a way that suits you. In this case, drawing the blurry house at the end will solve the problem. Otherwise, as long as you add a blur filter, all drawing operations will continue to blur.

Sometimes this may not work, and even if it does, it may become unreadable code. If this is the case, check the other options.

struct ExampleView: View {
    var body: some View {
        Canvas { context, size in
            
            // All drawing is done at x4 the size
            context.scaleBy(x: 4, y: 4)

            let midpoint = CGPoint(x: size.width / (2 * 4), y: size.height / (2 * 4))
            
            var house = context.resolve(Image(systemName: "house.fill"))

            // Left house
            house.shading = .color(.red)
            context.draw(house, at: midpoint - CGPoint(x: house.size.width, y: 0), anchor: .center)

            // Right house
            house.shading = .color(.blue)
            context.draw(house, at: midpoint + CGPoint(x: house.size.width, y: 0), anchor: .center)
            
            // Center house
            context.addFilter(.blur(radius: 1.0, options: .dithersResult), options: .linearColor)
            house.shading = .color(.green)
            context.draw(house, at: midpoint, anchor: .center)

        }
    }
}
Copy the code

2. Copy the context

Since the graph context is a value type, you can simply create a copy. All changes made to the replica do not affect the original context. Once you’re done, you can continue drawing on the original (unchanged) context.

struct ExampleView: View {
    var body: some View {
        Canvas { context, size in
            
            // All drawing is done at x4 the size
            context.scaleBy(x: 4, y: 4)

            let midpoint = CGPoint(x: size.width / (2 * 4), y: size.height / (2 * 4))
            
            var house = context.resolve(Image(systemName: "house.fill"))

            // Left house
            house.shading = .color(.red)
            context.draw(house, at: midpoint - CGPoint(x: house.size.width, y: 0), anchor: .center)

            // Center house
            var blurContext = context
            
            blurContext.addFilter(.blur(radius: 1.0, options: .dithersResult), options: .linearColor)
            house.shading = .color(.green)
            blurContext.draw(house, at: midpoint, anchor: .center)

            // Right house
            house.shading = .color(.blue)
            context.draw(house, at: midpoint + CGPoint(x: house.size.width, y: 0), anchor: .center)

        }
    }
}	
Copy the code

3. By using layer context

Finally, you can use the context method: drawLayer. This method has a closure that receives a copy of the context you can use. All changes to the layer context will not affect the original context:

struct ExampleView: View {
    var body: some View {
        Canvas { context, size in
            
            // All drawing is done at x4 the size
            context.scaleBy(x: 4, y: 4)

            let midpoint = CGPoint(x: size.width / (2 * 4), y: size.height / (2 * 4))
            
            var house = context.resolve(Image(systemName: "house.fill"))

            // Left house
            house.shading = .color(.red)
            context.draw(house, at: midpoint - CGPoint(x: house.size.width, y: 0), anchor: .center)

            // Center house
            context.drawLayer { layerContext in
                layerContext.addFilter(.blur(radius: 1.0, options: .dithersResult), options: .linearColor)
                house.shading = .color(.green)
                layerContext.draw(house, at: midpoint, anchor: .center)
            }
            
            // Right house
            house.shading = .color(.blue)
            context.draw(house, at: midpoint + CGPoint(x: house.size.width, y: 0), anchor: .center)

            
        }
    }
}
Copy the code

Reuse CoreGraphics code

If you already have code to draw using CoreGraphics, you can use it. The Canvas context has a withCGContext method that can save you in cases like this:

struct ExampleView: View {
    
    var body: some View {
        Canvas { context, size in
            
            context.withCGContext { cgContext in
                
                // CoreGraphics code here}}}}Copy the code

Animate the canvas

By wrapping the Canvas inside the TimelineView, we can achieve some pretty interesting animations. Basically, every time the timeline is updated, you get a chance to draw a new animation frame.

The rest of this article assumes that you are already familiar with TimelineView, but if you are not, you can check out Part 4 of this series to learn more.

In the example below, our Canvas draws an analog clock for a given date. We get the animated clock by placing the Canvas inside the TimelineView and updating the date with a timeline. Part of the screen shot below is accelerated to show how the minute and hour hands move, which would not be easy to observe otherwise:

When we create animations with Canvas, we usually use the.animation of the timeline. This can be updated as quickly as possible, redrawing our Canvas several times per second. However, when possible, we should use the minimumInterval parameter to limit the number of updates per second. This will lower the CPU requirements. For example, in this case, there is no visual difference between using.animation and.animation(minimumInterval: 0.06). However, on my test hardware, CPU utilization dropped from 30% to 14%. Using a higher minimum interval may start to look visually obvious, so you may need to do some wrong experiments to find the best value.

To further improve performance, you should consider whether there are parts of the Canvas that don’t need to be constantly redrawn. In our example, only the clock hands are moving; the rest of the clock remains stationary. Therefore, it would be wise to split it into two overlapping canvases. One draws everything but the clock hands (outside the timeline view), and the other draws only the clock hands, within the timeline view. By implementing this change, the CPU was reduced from 16% to 6%.

struct Clock: View {
    var body: some View {
        ZStack {
            ClockFaceCanvas(a)TimelineView(.animation(minimumInterval: 0.06)) { timeline in
                ClockHandsCanvas(date: timeline.date)
            }
        }
    }
}
Copy the code

By carefully analyzing our canvas and making a few changes, we were able to increase CPU usage by a factor of five (from 30% to 6%). By the way, if you can accept a second hand that updates every second, you will further reduce CPU usage to less than 1%. You should test to find what works best for you.

Divide and conquer

Once we get to know Canvas, we might want to draw everything with it. However, sometimes the best choice is to choose what to do and where to do it. The Matrix Digital Rain animation is a good example.

Let’s analyze what’s going on here. We have a list of characters that appear, the number of characters grows, slowly slips off, and finally decreases until it disappears. Each column is drawn with a gradient. There is also a sense of depth by making the column near the observer slide faster and slightly larger. For added effect, the further back the column, the more out of focus it appears.

It is possible to implement all of these requirements in the Canvas. However, if we divide up these tasks (divide and conquer), the task becomes much easier. As we’ve seen in the symbol animation section of this article, a draw-driven SwiftUI view can be drawn to the Canvas with a draw() call. Therefore, not everything needs to be handled inside the Canvas.

Each column is implemented as a separate SwiftUI view. Overlaying characters and drawing with gradients are handled by views. When we use gradients on the canvas, the start/end points or any other geometric parameters are relative to the entire canvas. For a columnar gradient, it is easier to implement in a view because it will be relative to the origin of the view.

Each column takes a number of parameters: position (x, y, z), character, how many characters to remove from the top, and so on. These values are changed every time the TimelineView is updated.

Finally, the Canvas is responsible for parsing each view, drawing at its (x, y) position, and adding blur and zoom effects based on its Z value. I’ve added some comments to the code to help you navigate through it if you’re interested.

Canvas collapse

Unfortunately, WHILE writing this article, I encountered some crashes with Canvas. Fortunately, they have improved a lot with each beta release. I hope they will all be fixed by the time iOS15 is officially released. The message usually goes something like this.

-[MTLDebugRenderCommandEncoder validateCommonDrawErrors:]:5252: failed assertion `Draw Errors Validation

Fragment Function(primitive_gradient_fragment): argument small[0] from buffer(3) with offset(65460) and length(65536) has space for 76 bytes, but argument has a length(96).

I managed to solve these crashes using at least one of these methods:

  • Reduce drawing volume. In the case of digital rain, you can reduce the number of columns.
  • Use a simpler gradient. Originally, the digital rain column had three color gradients. When I reduced it to two, the crash disappeared.
  • Reduce the frequency of Canvas updates. Use a slower timeline view to prevent crashes.

I’m not saying you can’t use more than two color gradients, but it’s just one place to consider if you find yourself in a Canvas crash situation. If that doesn’t solve your problem, I suggest you start removing the drawing operations until the application doesn’t crash anymore. This can lead you to the cause of the crash. Once you know what the cause is, you can try doing it in different ways.

If you encounter this problem, I encourage you to report it to Apple. If you like, you can refer to my feedback: FB9363322.

conclusion

I hope this article helps you add a new tool to your SwiftUI animation toolbox. This concludes part 5 of the animated series. At least this year…… Who knows what WWDC 22 will bring!