preface
In this article, we’ll delve into some advanced techniques for creating SwiftUI animation. I’ll talk extensively about the Animatable protocol, its reliable partner animatableData, the powerful but often ignored GeometryEffect, and the completely ignored but all-powerful AnimatableModifier protocol.
These are topics that are completely ignored by official documentation and barely mentioned in SwiftUI’s posts and articles. Still, they give us tools to create some pretty good animations.
Before we dive into these hidden gems, I’d like to do a very quick summary of some basic SwiftUI animation concepts. Just so we can speak the same language. Bear with me.
Explicit animation vs. implicit animation
In SwiftUI, there are two types of animation. Explicit and implicit. Implicit animations are those you specify with the.animation() modifier. Whenever the animatable parameter on the view changes, SwiftUI animates from the old value to the new value. Some animatable parameters include size, offset, color, scale, etc.
Explicit animation is done using withAnimation{… } specifies the animation closure. Only parameters that depend on changing values in the withAnimation closure are animated. Let’s try some examples:
The following example uses implicit animation to change the size and opacity of an image:
struct Example1: View {
@State private var half = false
@State private var dim = false
var body: some View {
Image("tower")
.scaleEffect(half ? 0.5 : 1.0)
.opacity(dim ? 0.2 : 1.0)
.animation(.easeInOut(duration: 1.0))
.onTapGesture {
self.dim.toggle()
self.half.toggle()
}
}
}
Copy the code
The following example uses explicit animation. Here, both zoom and opacity change, but only opacity is set to the animation because it is the only parameter that changes in the withAnimation closure:
struct Example2: View {
@State private var half = false
@State private var dim = false
var body: some View {
Image("tower")
.scaleEffect(half ? 0.5 : 1.0)
.opacity(dim ? 0.5 : 1.0)
.onTapGesture {
self.half.toggle()
withAnimation(.easeInOut(duration: 1.0)) {
self.dim.toggle()
}
}
}
}
Copy the code
Note that implicit animation can be used to create the same effect by changing the order before and after modifiers:
struct Example2: View {
@State private var half = false
@State private var dim = false
var body: some View {
Image("tower")
.opacity(dim ? 0.2 : 1.0)
.animation(.easeInOut(duration: 1.0))
.scaleEffect(half ? 0.5 : 1.0)
.onTapGesture {
self.dim.toggle()
self.half.toggle()
}
}
}
Copy the code
If you need to disable animation, use.animation(nil).
How does animation work
Behind all SwiftUI animation, there is a protocol called Animatable. We’ll discuss the details later, but mainly, it has a computed property whose type complies with the VectorArithmetic protocol. This allows the frame to interpolate at will.
When you animate a view, SwiftUI actually regenerates the view multiple times and changes the animation parameters each time. In this way, it will gradually go from the origin value to the final value.
Suppose we create a linear animation for the opacity of a view. We’re going from 0.3 to 0.8. The framework will regenerate the view several times, changing the opacity in small increments. Since opacity is represented by Double, and Double complies with the VectorArithmetic ‘protocol, SwiftUI can interpolate the desired opacity value. Somewhere in the framework code, there may be a similar algorithm.
let from:Double = 0.3
let to:Double = 0.8
for i in 0..<6 {
let pct = Double(i) / 5
var difference = to - from
difference.scale(by: pct)
let currentOpacity = from + difference
print("currentOpacity = \(currentOpacity)")}Copy the code
The code creates incremental changes from start to finish:
CurrentOpacity = 0.3 currentOpacity = 0.4 currentOpacity = 0.5 currentOpacity = 0.6 currentOpacity = 0.7 currentOpacity = 0.8Copy the code
Why careAnimatable
?
You may ask why I need to care about all these little details. SwiftUI has animated opacity without me having to worry about it all. Yes, that’s true, but as long as SwiftUI knows how to interpolate values from origin to destination. For opacity, this is a straightforward process and SwiftUI knows how to do it. However, as we shall see, this is not always the case.
Some big exceptions come to mind: Paths, transformation matrices, and arbitrary view changes (for example, text in text view, gradient color or pause in gradient view, and so on). In this case, the framework doesn’t know what to do. We will discuss transformation matrices and view changes in the second and third parts of this article. For now, let’s focus on shapes.
Animate the shape path
Imagine that you have a shape and use paths to draw a regular polygon. Our implementation will of course let you figure out how many sides the polygon will have.
PolygonShape(sides: 3).stroke(Color.blue, lineWidth: 3)
PolygonShape(sides: 4).stroke(Color.purple, lineWidth: 4)
Copy the code
Here is an implementation of our PolygonShape. Notice I’m using a little bit of trigonometry. This is not important to understand the topic of this article, but if you want to learn more about it, I wrote another article that covers the basics. You can read more about trigonometric Formulas for SwiftUI.
struct PolygonShape: Shape {
var sides: Int
func path(in rect: CGRect) -> Path {
// hypotenuse
let h = Double(min(rect.size.width, rect.size.height)) / 2.0
// center
let c = CGPoint(x: rect.size.width / 2.0, y: rect.size.height / 2.0)
var path = Path(a)for i in 0..<sides {
let angle = (Double(i) * (360.0 / Double(sides))) * Double.pi / 180
// Calculate vertex position
let pt = CGPoint(x: c.x + CGFloat(cos(angle) * h), y: c.y + CGFloat(sin(angle) * h))
if i = = 0 {
path.move(to: pt) // move to first vertex
} else {
path.addLine(to: pt) // draw line to next vertex
}
}
path.closeSubpath()
return path
}
}
Copy the code
We can go one step further and try animating the shape’s sides argument using the same method as opacity:
PolygonShape(sides: isSquare ? 4 : 3)
.stroke(Color.blue, lineWidth: 3)
.animation(.easeInOut(duration: duration))
Copy the code
How do you think SwiftUI will turn triangles into squares? You might have guessed. It won’t. Of course, the frame doesn’t know how to animate it. You can use.animation() as much as you like, but the shape jumps from triangle to square without any animation. The reason is simple: you only taught SwiftUI how to draw a 3-sided polygon, or a 4-sided polygon, but your code doesn’t know how to draw a 3.379-sided polygon!
So, in order for animation to happen, we need two things:
-
We need code that changes the shape so that it knows how to draw a polygon with non-integer edges.
-
Let the frame generate the shape multiple times and let the animatable parameters change a little bit. That is, we want the shape to be drawn multiple times, each time with a different edge number: 3, 3.1, 3.15, 3.2, 3.25, up to 4.
Once we get these two bits in place, we’ll be able to animate between any number of edges:
Create animatable data (animatableData
)
To make the shape animatable, we need SwiftUI to render the view multiple times, using all edge values from the origin to the target number. Fortunately, Shape already meets the requirements of the Animatable protocol. This means that there is a calculated property (animatableData) that we can use to handle this task. However, its default implementation is set to EmptyAnimatableData. So it doesn’t do anything.
To solve our problem, we will first change the type of the edge’s property, from Int to Double. So we can have decimal numbers. We’ll discuss how to keep this property Int and still perform the animation later. But for now, to keep things simple, we’ll just use Double.
struct PolygonShape: Shape {
var sides: Double
.
}
Copy the code
Then we need to create our computational property animatableData. In this case, it’s pretty simple.
struct PolygonShape: Shape {
var sides: Double
var animatableData: Double {
get { return sides }
set { sides = newValue }
}
.
}
Copy the code
Let’s draw the edges with decimals
Finally, we need to teach SwiftUI how to draw a polygon with non-integer edges. We’ll change our code a little bit. As the fractional part grows, the new edge will go from zero to full length. The other vertices will reposition smoothly accordingly. It sounds complicated, but it’s a minimal change.
func path(in rect: CGRect) -> Path {
// hypotenuse
let h = Double(min(rect.size.width, rect.size.height)) / 2.0
// center
let c = CGPoint(x: rect.size.width / 2.0, y: rect.size.height / 2.0)
var path = Path(a)let extra: Int = Double(sides) ! = Double(Int(sides)) ? 1 : 0
for i in 0..<Int(sides) + extra {
let angle = (Double(i) * (360.0 / Double(sides))) * Double.pi / 180
// Calculate vertex
let pt = CGPoint(x: c.x + CGFloat(cos(angle) * h), y: c.y + CGFloat(sin(angle) * h))
if i = = 0 {
path.move(to: pt) // move to first vertex
} else {
path.addLine(to: pt) // draw line to next vertex
}
}
path.closeSubpath()
return path
}
Copy the code
The complete code is available as Example1 in the GIST file linked at the top of this article.
As mentioned earlier, it might seem strange to users of our shape that the argument to the edge is a Double. One should expect an edge to be an Int argument. Fortunately, we can change our code again to hide this fact in our shape implementation:
struct PolygonShape: Shape {
var sides: Int
private var sidesAsDouble: Double
var animatableData: Double {
get { return sidesAsDouble }
set { sidesAsDouble = newValue }
}
init(sides: Int) {
self.sides = sides
self.sidesAsDouble = Double(sides)
}
.
}
Copy the code
With these changes, we use Double internally, but Int externally. Now it looks more elegant. Don’t forget to modify the drawing code so that it uses sidesAsDouble instead of sides. The complete code can be found in Example2 in the gist file linked to at the top of this article.
Set animation with multiple parameters
Many times, we find ourselves needing to animate more than one parameter. A single Double is not enough. At these times, we can use AnimatablePair
. Here, the first and second are the types conforming to VectorArithmetic. For example AnimatablePair
.
To demonstrate the use of AnimatablePair, we will modify our example. Now our polygon shape will have two parameters: edges and proportions. Both will be represented by Double.
struct PolygonShape: Shape {
var sides: Double
var scale: Double
var animatableData: AnimatablePair<Double.Double> {
get { AnimatablePair(sides, scale) }
set {
sides = newValue.first
scale = newValue.second
}
}
.
}
Copy the code
The complete code can be found in Example3 in the gist file linked to at the top of this article. Example4 in the same file has a more complex path. It’s basically the same shape, but with an added line connecting each vertex.
More than two animatable parameters
If you browse SwiftUI’s declaration file, you’ll see that the framework uses AnimatablePair quite extensively. For example. CGSize, CGPoint, CGRect. Although these types do not fit VectorArithmetic, they can be animated because they do fit Animatable.
They all use AnimatablePair in one way or another:
extension CGPoint : Animatable {
public typealias AnimatableData = AnimatablePair<CGFloat.CGFloat>
public var animatableData: CGPoint.AnimatableData
}
extension CGSize : Animatable {
public typealias AnimatableData = AnimatablePair<CGFloat.CGFloat>
public var animatableData: CGSize.AnimatableData
}
extension CGRect : Animatable {
public typealias AnimatableData = AnimatablePair<CGPoint.AnimatableData.CGSize.AnimatableData>
public var animatableData: CGRect.AnimatableData
}
Copy the code
If you pay close attention to CGRect, you’ll see that it actually uses:
AnimatablePair<AnimatablePair<CGFloat.CGFloat>, AnimatablePair<CGFloat.CGFloat>>
Copy the code
This means that the rectangle’s x, y, width, and height values are accessible via first.first, first.second, second.first, and second.second.
Animate your own type (throughVectorArithmetic
)
The following types implement Animatable by default: Angle, CGPoint, CGRect, CGSize, EdgeInsets, StrokeStyle, and UnitPoint. The following types correspond to VectorArithmetic. AnimatablePair, CGFloat, Double, EmptyAnimatableData and Float. You can use any of them to animate your shapes.
Existing types provide enough flexibility to animate just about anything. However, if you find yourself with a complex type that you want to animate, there is nothing to stop you from adding your own implementation of the VectorArithmetic protocol. In fact, we’ll do that in the next example.
To illustrate this, we will create an analog clock shape. It moves its pointer based on a custom animatable parameter type: ClockTime.
We’ll use it like this:
ClockShape(clockTime: show ? ClockTime(9.51.15) : ClockTime(9.55.00))
.stroke(Color.blue, lineWidth: 3)
.animation(.easeInOut(duration: duration))
Copy the code
First, we start creating our custom type ClockTime. It contains three properties (hour, minute, and second), several useful initializers, and some properties and methods to aid in computation.
struct ClockTime {
var hours: Int // Hour needle should jump by integer numbers
var minutes: Int // Minute needle should jump by integer numbers
var seconds: Double // Second needle should move smoothly
// Initializer with hour, minute and seconds
init(_ h: Int._ m: Int._ s: Double) {
self.hours = h
self.minutes = m
self.seconds = s
}
// Initializer with total of seconds
init(_ seconds: Double) {
let h = Int(seconds) / 3600
let m = (Int(seconds) - (h * 3600)) / 60
let s = seconds - Double((h * 3600) + (m * 60))
self.hours = h
self.minutes = m
self.seconds = s
}
// compute number of seconds
var asSeconds: Double {
return Double(self.hours * 3600 + self.minutes * 60) + self.seconds
}
// show as string
func asString(a) -> String {
return String(format: "%2i".self.hours) + ":" + String(format: "%02i".self.minutes) + ":" + String(format: "%02f".self.seconds)
}
}
Copy the code
Now, to comply with the VectorArithmetic protocol, we need to write the following methods and computed properties:
extension ClockTime: VectorArithmetic {
static var zero: ClockTime {
return ClockTime(0.0.0)}var magnitudeSquared: Double { return asSeconds * asSeconds }
static func - = (lhs: inout ClockTime.rhs: ClockTime) {
lhs = lhs - rhs
}
static func - (lhs: ClockTime.rhs: ClockTime) -> ClockTime {
return ClockTime(lhs.asSeconds - rhs.asSeconds)
}
static func + = (lhs: inout ClockTime.rhs: ClockTime) {
lhs = lhs + rhs
}
static func + (lhs: ClockTime.rhs: ClockTime) -> ClockTime {
return ClockTime(lhs.asSeconds + rhs.asSeconds)
}
mutating func scale(by rhs: Double) {
var s = Double(self.asSeconds)
s.scale(by: rhs)
let ct = ClockTime(s)
self.hours = ct.hours
self.minutes = ct.minutes
self.seconds = ct.seconds
}
}
Copy the code
The only thing to do is write out the shape to position the needle properly. The complete code for the clock shape can be found in Example5 in the GIST file linked at the top of this article.
SwiftUI + Metal
If you find yourself writing complex animations, you may start to see your device suffer while trying to keep up with all the drawing. If so, you will definitely benefit from enabling the use of metal. Here is an example of how everything can be different with Metal enabled.
When running on the emulator, you probably won’t feel the difference. On a real device, however, you’ll find. Video demo from iPad 6 (2016). The complete code is in the gist file, named Example6.
Fortunately, enabling Metal is very easy. You just need to add the.drawingGroup() modifier:
FlowerView().drawingGroup()
Copy the code
According to WWDC 2019, Session 237 (Building Custom Views with SwiftUI) : Drawing groups are a special way of rendering, but only for things like graphics. It basically tils the SwiftUI view into a single NSView/UIView and renders it in Metal. Jump to WWDC video at 37:27 for more details.
If you want to give it a try, but your shapes aren’t complex enough for the device to struggle with, add some gradients and shadows and you’ll see the difference immediately.
What’s next?
In the second part of this article, we learn how to use the GeometryEffect protocol. It will open the door to new ways to change our views and animations. As with Paths, SwiftUI has no built-in knowledge of how to transform between two different transformation matrices. GeometryEffect will help us do this.
Currently, SwiftUI does not have keyframes. We’ll see how we can simulate one with a basic animation.
In the third part of this article, we will introduce AnimatableModifier, a very powerful tool that allows us to animate anything in a view that can change, even text! In Part 3 of this series, we’ll look at some examples of animation. For some examples of animation in this three-part series, see the video below:
Swiftui-lab.com/wp-content/…
Image resources for example 8 can be downloaded here: swiftui-lab.com/?smd_proces…
This article has been published in the public account “Swift Community”, if reprinted changbai, please add wechat: Fzhanfei, remarks reprinted Changbai