I believe that everyone has a basic understanding of SwiftUI. It is relatively easier to write animation in SwiftUI. Next, I will use 3 articles to guide you to take in the charm of SwiftUI animation.
1. Explicit and implicit animation
There are two types of animation in SwiftUI, explicit and implicit.
Implicit animation refers to a view that uses animation()modifier. The system will automatically animate the view when its animatable parameters change, such as size,offset, color, scale, and so on.
Explicit animation refers to withAnimation {… } arguments specified in the closure, and all views that depend on those arguments, will animate.
Let’s take a look at an example. The following animation uses implicit animation:
The code is as follows:
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
From the above code, we can see that the animation depends on the half and dim parameters. We did not directly tell the view to animate these two parameters. The system will automatically animate the changes from the old values to the new values.
Let’s make a simple change to the code:
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
Animation (.easeinout (duration: We put self.dim.toggle() in the closure, which explicitly tells the system that the transparency of the view should be animated. All views that depend on dim will be animated when their dim changes. The effect is as follows:
If you look closely at the animation process above, you can see that only transparency specifies the animation, and scaling does not perform the animation. This means that we explicitly tell the system dim to animate, and it only performs the animation for Dim, which is very good.
At this point, I have a question, how do I implement this animation with implicit animation? Also very simple, first look at the code:
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
The order in which the animationModifier is applied to a View is important. In the above code, it only applies to the content in front of it, but this order can be adjusted at will. If we want to disable certain animations using implicit animation, we just need.animation(nil).
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(nil)
.scaleEffect(half ? 0.5 : 1.0)
.animation(.easeInOut(duration: 1.0))
.onTapGesture {
self.dim.toggle()
self.half.toggle()
}
}
}
Copy the code
2.How Do Animations Work
The principle behind SwiftUI animation lies in the Animatable protocol, which requires us to implement a computational property animatableData that complies with the VectorArithmetic protocol, The purpose of VectorArithmetic is to enable the system to insert many values into the animation data that needs to be changed, and the calculation of these values depends on the time function of the animation.
In essence, performing animation in SwiftUI means that the system renders the View many times, each time changing the parameters a little bit. Of course, this parameter means that you need the original value of the animation to the final value.
For example, if the transparency is linearly changed from 0.3 to 0.8, since 0.3 is the Double type, and the VectorArithmetic protocol is realized, the system can insert many pairs of intermediate values between 0.3 and 0.8, and the calculation of these values depends on the time function and animation duration. In this case, it is linear, and the system’s algorithm for interpolation looks something like this:
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
currentOpacity = 0.3
currentOpacity = 0.4
currentOpacity = 0.5
currentOpacity = 0.6
currentOpacity = 0.7
currentOpacity = 0.8
Copy the code
Essentially, the system generates a View for each of these inserted values, plays these Views for duration, and that’s what we see.
As for the time function, let’s take the picture below as an example. This is the scale of a picture, that is, the scaling effect. It can be seen that under different functions, the value inserted by the system is different, and the scaled picture is also different according to the inserted value.
3. Why Do I Care About Animatable?
So why should we care so much about the Animatable protocol? Such as opacity and scale, these systems automatically perform animations and do not need us to care about them.
Yes, like these basic effects, the system knows how to animate, but in normal development, we have to do animation is often not so simple, such as path change, gradient change, etc., these examples will be introduced in the next article, the core idea is animatableData. Just keep reading.
4. Animating Shape Paths
In this section, we will use Animatable to draw regular polygons, like the following:
In the figure above, the examples of regular trigrams and regular quadrilaterals are shown only, and we will extend this to arbitrary N-sided forms in a moment.
Before we begin, let’s briefly introduce a little trigonometric knowledge needed to implement this function. I won’t do a detailed introduction here. For more details, you can click here.
The fundamental theorem is that in a circle, we can draw any n-regular polygon. This is important. Before drawing a regular polygon, we need to determine the radius of the outer circle of the regular polygon, as shown below:
With this basic concept in mind, we are ready to implement it:
- We already know where the dot is, usually the center of the graph
- Radius is easy to calculate, and the background for drawing a regular edge is usually a square or a rectangle, so it’s good to take half of the shortest edge as the radius
- The Angle formed from each vertex to the dot is easy to calculate
With a dot, Angle, radius, we can determine the point at each point, so we can easily draw the path of a regular polygon. The first vertex in our examples is right to the center of the circle, not the corresponding position in the figure above.
Let’s write the above ideas in code as follows:
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
Now you should be able to see how this code works, right? It’s also easy to use:
PolygonShape(sides: isSquare ? 4 : 3)
.stroke(Color.blue, lineWidth: 3)
.animation(.easeInOut(duration: duration))
Copy the code
When we change siders, do you think it’s so easy to specify animations? Still too young, the practical effect is:
The reason is simple, the system doesn’t know how it should animate, right? It only knows that as siders change, the graphics are redrawn, and in order to solve the problem we need to do two things:
- Need to put
Int
The type ofsiders
toDouble
Type so that many values can be inserted in the middle when their value changes - through
animatableData
Tells the system which values to interpolate
Fortunately, Shape already complies with the Animatable protocol, so the code looks like this:
struct PolygonShape: Shape {
var sides: Double
var animatableData: Double {
get { return sides }
set { sides = newValue }
}
...
}
Copy the code
Suppose the siders change from 3 to 4, the system divides the siders into 3.1, 3.2, 3.3… 3.9,4.0, how should we draw paths according to these values?
Take a look at the core code:
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
let extra: Int = Double(sides) ! = Double(Int(sides)) ? The 1:0 line of code guarantees that a number greater than 3 like 3.4 can draw four vertices.
for i in 0..
Let Angle = (Double(I) * (360.0 / Double(sides))) * double.pi / 180, the siders are always the same regardless of how many sides they are. The Angle of rotation is the same each time.
SwiftUI Angle rotation is clockwise with a horizontal X axis of 0 degrees. Let’s take a look at some screenshots below:
The above chart shows the paths drawn by the siders as 3.2, and we add some notes:
I’m going to plot it in order 1 > 2 > 3 > 4, Angle 1, Angle 2, Angle 3 are the same thing, I’m going to plot this, I’m going to loop for 4 times, and if you think about it, it’s normal that Angle 1, Angle 2, Angle 3 don’t add up to 360 degrees.
Obviously, assume that as the siders increase a bit to 3.4, angles 1, 2 and 3 become smaller due to (360.0 / Double(sides)), and the line between 1 and 4 increases a bit. The diagram below:
Well, we have analyzed very detailed, if you still do not understand the place, you can leave a message. You just need to add a little bit of code to get moving.”
struct Example1PolygonShape: Shape {
var sides: Double
var animatableData: Double {
get { return sides }
set { sides = newValue }
}
func path(in rect: CGRect) -> Path{... }}Copy the code
Let’s extend that a little bit and say, what if I want to execute two animations at the same time? It’s very simple. AnimatableData requires only that set and GET implement the values of the VectorArithmetic protocol. This is the case with Double. If we execute two types of animation at the same time, we need to use AnimatablePair
.
Obviously, it encapsulates two arguments, and our code looks like this:
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 method of drawing the path also needs to be changed a little bit, using scale to calculate the radius:
func path(in rect: CGRect) -> Path {
let h = Double(min(rect.size.width, rect.size.height) / 2.0) * scale
...
}
Copy the code
As simple as that, look at the effect again:
Perhaps you now have a new question: what if we execute more than two animations at the same time? The answer is equally simple,
AnimatablePair<AnimatablePair<CGFloat.CGFloat>, AnimatablePair<CGFloat.CGFloat>>
Copy the code
Based on this method, n values can be extended. In the system, CGPoint,CGSize, and CGRect can perform animation because they all implement the Animatable protocol.
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
At the end of this section, let’s look at another cool effect:
To achieve the above effect is also very simple is to use the above method to draw the graph, and then let each vertex with other vertexlines, the core code is the function drawVertexLines. The code is as follows:
func path(in rect: CGRect) -> Path{... drawVertexLines(path: &path, vertexs: vertex, n:0)
return path
}
func drawVertexLines(path: inout Path, vertexs: [CGPoint], n: Int) {
if vertexs.count - n < 3 {
return
}
for i in (n+2).. <min(n + vertexs.count - 1, vertexs.count) {
path.move(to: vertexs[n])
path.addLine(to: vertexs[i])
}
drawVertexLines(path: &path, vertexs: vertexs, n: n+1)}Copy the code
5.Making Your Own Type Animatable (with VectorArithmetic)
In these sections, we have used the data structures provided by SwiftUI, which are sufficient in most cases, but we want to build something more complex on top of it.
For example, we want to use our custom struct to do animation, as long as we talk about animation, can not leave a value from a value to another value change, our example is a clock animation, let’s take a look at the final effect:
To describe a moment in time, we need three attributes, hour, minute, and second, so we need to encapsulate them in a structure that interpolates directly between the two changing structures when it is time to switch.
Tip: Angle, CGPoint, CGRect, CGSize, EdgeInsets, StrokeStyle, and UnitPoint all implement the Animatable protocol, AnimatablePair, CGFloat, Double, EmptyAnimatableData, and Float, all implement the VectorArithmetic protocol.
Let’s start by writing the ClockTime structure as follows:
struct ClockTime {
var hours: Int
var minutes: Int
var seconds: Double
init(_ h: Int._ m: Int._ s: Double) {
self.hours = h
self.minutes = m
self.seconds = s
}
init(_ seconds: Double) {
let hours = Int(seconds) / 3600
let minutes = (Int(seconds) - (hours * 3600)) / 60
let seconds = seconds - Double(hours * 3600) - Double(minutes * 60)
self.hours = hours
self.minutes = minutes
self.seconds = seconds
}
func asSeconds(a) -> Double {
return Double(self.hours * 3600) + Double(self.minutes * 60) + self.seconds
}
func asString(a) -> String {
return String(format: "%2i".self.hours) +
":" +
String(format: "%02i".self.minutes) +
":" +
String(format: "% 02.0 f".self.seconds)
}
}
Copy the code
The code here is very simple, just initialization and some functions. You should understand that if you add and subtract from ClockTime, you are actually adding and subtracting the total number of seconds of two times.
We let ClockTime implement the VectorArithmetic protocol:
extension ClockTime: VectorArithmetic {
static func - (lhs: ClockTime, rhs: ClockTime) -> ClockTime {
return ClockTime(lhs.asSeconds() - rhs.asSeconds())
}
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 time = ClockTime(s)
self.hours = time.hours
self.minutes = time.minutes
self.seconds = time.seconds
}
var magnitudeSquared: Double {
1
}
static var zero: ClockTime {
ClockTime(0.0.0)}}Copy the code
In fact, the code similar to the above is basically a fixed writing method, but we can find some new ideas. In the internal interpolation of SwiftUI system, the method in VectorArithmetic protocol is used.
On the graph drawing aspect, or the above set, the code is as follows:
struct ClockShape: Shape {
var time: ClockTime
var animatableData: ClockTime {
get {
time
}
set {
time = newValue
}
}
func path(in rect: CGRect) -> Path {
var path = Path(a)let radius = min(rect.size.width / 2.0, rect.size.height / 2.0)
let center = CGPoint(x: rect.size.width / 2.0, y: rect.size.height / 2.0)
let hHypotenuse = Double(radius) * 0.5
let mHypotenuse = Double(radius) * 0.6
let sHypotenuse = Double(radius) * 0.8
let hAngle: Angle = .degrees(Double(time.hours) / 12 * 360 - 90)
let mAngle: Angle = .degrees(Double(time.minutes) / 60 * 360 - 90)
let sAngle: Angle = .degrees(Double(time.seconds) / 60 * 360 - 90)
let hoursNeedle = CGPoint(x: center.x + CGFloat(hHypotenuse * cos(hAngle.radians)), y: center.y + CGFloat(hHypotenuse * sin(hAngle.radians)))
let minutesNeedle = CGPoint(x: center.x + CGFloat(mHypotenuse * cos(mAngle.radians)), y: center.y + CGFloat(mHypotenuse * sin(mAngle.radians)))
let secondsNeedle = CGPoint(x: center.x + CGFloat(sHypotenuse * cos(sAngle.radians)), y: center.y + CGFloat(sHypotenuse * sin(sAngle.radians)))
/ / / circle
path.addArc(center: center, radius: radius,
startAngle: .degrees(0), endAngle: .degrees(360),
clockwise: true)
/// dial scale
let numberLength: CGFloat = 5.0
let numberPadding: CGFloat = 12.0
let centerToNumber: CGFloat = radius - numberLength - numberPadding
for i in 0..<12 {
let angle: Angle = .degrees(360.0 / 12.0 * Double(i))
let inPt = CGPoint(x: center.x + centerToNumber * CGFloat(cos(angle.radians)), y: center.y - centerToNumber * CGFloat(sin(angle.radians)))
let outPt = CGPoint(x: center.x + (centerToNumber + numberLength) * CGFloat(cos(angle.radians)), y: center.y - (centerToNumber + numberLength) * CGFloat(sin(angle.radians)))
path.move(to: inPt)
path.addLine(to: outPt)
}
/ / / hour
path.move(to: center)
path.addLine(to: hoursNeedle)
path = path.strokedPath(StrokeStyle(lineWidth: 3, lineCap: .round))
/ / / the minute hand
path.move(to: center)
path.addLine(to: minutesNeedle)
path = path.strokedPath(StrokeStyle(lineWidth: 3, lineCap: .round))
/ / / second hand
path.move(to: center)
path.addLine(to: secondsNeedle)
path = path.strokedPath(StrokeStyle(lineWidth: 1, lineCap: .round))
return path
}
}
Copy the code
6.SwiftUI + Metal
If we want to implement a particularly complex animation in SwiftUI and run it on a real machine, we may find that the animation is not necessarily smooth, which is a good case to start Metal. It is very simple to start Metal, and the code is as follows:
FlowerView().drawingGroup()
Copy the code
According to WWDC 2019, Session 237 (Building Custom Views with SwiftUI): A drawing group is a special way of rendering but only for things like graphics. It will basically flatten the SwiftUI view into a single NSView/UIView and render it with metal. Jump the WWDC video to 37:27 for a little more detail.
You can see the effect in the picture above. After turning on Metal, it is much smoother. As for the code, we will not paste here, you can go to the original author’s website to see, the figure is rotated, but the petals do not do extra rotation, but control the width of the petals to achieve, which helps you understand the code.
conclusion
Interpolation is the essence of SwiftUI animation, and any object that implements the Animatable protocol will know how to animate, which is a core idea.
* Note: The above content referred to the website https://swiftui-lab.com/swiftui-animations-part1/, if any infringement, immediately deleted.