SwiftUI Advanced Animation Canvas

SwiftUI Advanced Animation Canvas

Preface

This fifth installment of our Advanced SwiftUI Animations series will explore the Canvas view. Technically it’s not an animatable view, but when combined with the TimelineView from part four it opens up a lot of interesting possibilities, as shown in this digital rain example.

I had to delay this post for a few weeks because the Canvas view was a bit unstable. We are still in beta, so this is to be expected. However, the crashes produced by the view prevented some of the examples here from being shared. While not all issues have been fixed, every example now runs smoothly. At the end of the post, I will point out some of the workarounds I found.

A Simple Canvas

In short, the canvas is a SwiftUI view that gets its drawing instructions from a rendering closure. Unlike most closures in the SwiftUI API, it is not a view builder. This means we can use the Swift language without any restrictions.

The closure receives two parameters: context and size. The context uses a new SwiftUI type GraphicsContext, which contains many methods and properties that allow us to draw 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 ))
}
}
}

The Canvas initializer has other parameters (opacity, color mode, and rendersAsynchronously). See Apple's documentation for more information.

Graphics Context - GraphicsContext

GraphicsContext has a lot of methods and properties, but I’m not going to list them all in this post as a reference. It’s a long list and can be a bit overwhelming. However, I did have to go through all of them when I was updating the Companion for SwiftUI app. That gave me an overall idea. I’ll try to categorize what’s available 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.

Paths

The first thing to do when drawing a path is to create it. Since the first version of SwiftUI, paths can be created and modified in a variety of ways. Some of the available initializers are:

 let path = Path ( roundedRect : rect , cornerSize : CGSize ( width : 10 , height : 50 ), style : . continuous ) 
 let cgPath = CGPath ( ellipseIn : rect , transform : nil )
let path = Path ( cgPath )
 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 )
}

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

 let path = Circle (). path ( in : rect )

Of course, this also works for custom shapes:

 let path = MyCustomShape (). path ( in : rect )

Fill Path

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

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

Shading indicates how the shape is filled (with a color, gradient, tiled image, etc.). If you need to indicate which style to use, use the FillStyle type (i.e. the EvenOdd/Opposite properties).

Path 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 )

You can specify a shading (color, gradient, etc.) to indicate how the path should be drawn. If you need to specify dashes, line caps, joins, etc., use a style. Alternatively, you can specify just the line width.

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

Image & Text

Images and text are drawn using the context’s draw() method, which comes in two versions:

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

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

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

Before one of these elements can be drawn, they must be resolved. By resolving, SwiftUI will take the environment into account (e.g. color scheme, display resolution, etc.). Additionally, resolving these elements exposes some interesting properties that may be further used in our drawing logic. For example, the resolved text will tell us the final size of the specified font. Or we can also change the shadow of the resolved element before drawing it. To learn more about the available properties and methods, check out ResolvedImage and ResolvedText.

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

Resolving is optional, and the draw() method also accepts Image and Text (instead of ResolvedImage and ResolvedText). In this case, draw() will automatically resolve them. This is convenient if you don't have any use for the resolved properties and methods.

In this example, the text is resolved. We use its size to calculate the gradient and apply this gradient using 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 )
}
}
}

Symbols

When talking about Canvas, Symbols are just any SwiftUI. Not to be confused with SF Symbols, which are completely different things. Canvas views have a way to reference a SwiftUI view, resolve it to a Symbol, and then draw it.

The view to be resolved is passed in the ViewBuilder closure, as shown in the example below. In order to reference a view, it needs to be tagged with a unique hashable identifier. Note that a resolved 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 )
}
}
}

ViewBuilder can also use a ForEach. The same example can 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 )
}
}
}
}

Animated Symbols

I was pleasantly surprised when I tested what would happen if the view was resolved as a symbol to be animated. Guess what, the canvas would keep redrawing it to keep the animation going.

 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 ()
}
}
}
}

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) graphics context states from a stack. Canvas GraphicsContext works a little differently, and if you want to make a temporary change to the context, you have a few options.

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

All examples below will use the following CGPoint extension:

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

Here are three ways to achieve the same result:

1. By sorting the corresponding operations

Where possible, you can choose to order the drawing operations in a way that works for you. In this case, drawing the blurred house last would solve the problem. Otherwise, all drawing operations would continue to be blurred as long as you add the blur filter.

Sometimes this may not work, and even if it does, it may turn into hard to read code. If this is the case, check 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 )
}
}
}

2. By copying the context

Since the graphics context is a value type, you can simply create a copy. All changes made on the copy will not affect the original context. Once you are 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 )
}
}

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 that 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 )
}
}
}

Reusing CoreGraphics Code

If you already have drawing code that uses CoreGraphics, you can use that. The Canvas context has a withCGContext method that can save you in situations like this:

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

Animate the canvas

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

The rest of the 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 a simulated clock for a given date. By placing the Canvas inside a TimelineView and using the timeline to update the date, we get an animated clock. A portion of the following screenshot is sped up to show how the minute and hour hands move, otherwise it would not be easy to see the effect:

When we create animations with our Canvas, we typically use .animation with the Timeline schedule. This updates as fast as possible, redrawing our Canvas a few times per second. However, where possible, we should use the minimumInterval parameter to limit the number of updates per second. This will be less demanding on the CPU. For example, in this case, there is no visually noticeable difference between using .animation and .animation(minimumInterval: 0.06) . However, on my test hardware, CPU usage dropped from 30% to 14%. Using higher minimum intervals may start to become visually noticeable, so you may need to do some trial and error to find the best value.

To further improve performance, you should consider if there are parts of your Canvas that don't need to be constantly redrawn. In our case, only the clock hands are moving, and everything else remains static. Therefore, it would be wise to split it into two overlapping canvases. One that draws everything except the clock hands (outside the timeline view), and the other that only draws the clock hands, inside the timeline view. By implementing this change, the CPU dropped from 16% to 6%.

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

By carefully analyzing our canvas and making a few changes, we managed to improve CPU usage by 5 times (from 30% to 6%). By the way, if you can live with the second hand updating every second, you will further reduce CPU usage to less than 1%. You should test to find the effect that works best for you.

Divide and conquer

Once we understand Canvas, we might be tempted to use it to draw everything. However, sometimes the best choice is to choose what to do and where to do it. The Matrix Digital Rain animation below is a great example.

Let's break down what's going on. We have a column of characters that appears, grows in number, slowly slides down, and finally decreases in number until it disappears. Each column is drawn with a gradient. There's also a sense of depth, by making the columns closer to the viewer slide faster and slightly larger. To add to the effect, the further back a column is, the more out of focus (blurred) it appears.

It is entirely possible to implement all these requirements in Canvas. However, if we split these tasks (divide and conquer), the task becomes much easier. As we have seen in the animation of symbols part of this article, an animated SwiftUI view can be drawn to Canvas with a single draw() call. Therefore, not everything has to be handled in Canvas.

Each column is implemented as a separate SwiftUI view. Overlaying characters and drawing with the gradient is handled by the view. When we use gradients on a 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 it in the view because it will be relative to the view's origin.

Each column has a number of parameters: position (x, y, z), character, how many characters to remove from the top, etc. These values ​​are modified every time the TimelineView is updated.

Finally, the Canvas is responsible for resolving each view, drawing it at their (x, y) position, and adding blur and scale effects based on their z value. I've added some comments to the code to help you navigate it if you're interested.

Canvas crash

Unfortunately, I encountered some crash issues with Canvas while writing this article. Fortunately, they are greatly improved in each beta. I hope that they will all be resolved by the time iOS 15 is officially released. This message usually goes 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 fix these crashes using at least one of these methods:

  • Reduce the amount of plotting. In the Digital Rain example, you could reduce the number of columns.
  • Use simpler gradients. Originally, the digital rain column had a gradient of three colors. When I reduced it to two, the crash went away.
  • Update the Canvas less frequently. Use a slower timeline view to prevent crashes.

I'm not saying you can't use gradients with more than two colors, but it's just one place you might consider if you find yourself in a situation where the Canvas is crashing. If that doesn't solve your problem, I suggest you start removing drawing operations until the app no ​​longer crashes. This can lead you to what's causing the crash. Once you know what's causing it, you can try doing it differently.

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

Summarize

I hope this post helped you add a new tool to your SwiftUI animation toolbox. That concludes the fifth part of the animation series. At least for this year… and who knows what WWDC’22 will bring!

Translated from Advanced SwiftUI Animations – Part 5: Canvas.

<<:  Ten product details analysis to show you how big manufacturers design!

>>:  Android launches another "killer" feature that can reclaim 60% of storage space

Recommend

Hawking: Soon humans will be no match for artificial intelligence

Famous physicist Stephen Hawking recently appeared...

Subvert your cognition! Is it true that a vegetative person can tickle himself?

Recently, a saying has been circulating on a cert...

An excellent Android application starts with building a project

[[146519]] 1. Project Structure The MVP model is ...

How do K12 online education products achieve user growth?

This article mainly discusses the response strate...

Worth collecting! Develop this good habit and you will benefit from it for life!

Every parent hopes that their children will have ...