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!