GeometryEffect

GeometryEffect implements both the Animatable and ViewModifier protocols, so it can animate itself and also write code using the Modifier

If you are confused, where is GeometryEffect used? In fact, there are many places used, such as offset in the system can be used to achieve, the code is as follows:

public extension View {
    func offset(x: CGFloat, y: CGFloat) -> some View {
        return modifier(_OffsetEffect(offset: CGSize(width: x, height: y)))
    }

    func offset(_ offset: CGSize) -> some View {
        return modifier(_OffsetEffect(offset: offset))
    }
}

struct _OffsetEffect: GeometryEffect {
    var offset: CGSize
    
    var animatableData: CGSize.AnimatableData {
        get { CGSize.AnimatableData(offset.width, offset.height) }
        set { offset = CGSize(width: newValue.first, height: newValue.second) }
    }

    public func effectValue(size: CGSize) -> ProjectionTransform {
        return ProjectionTransform(CGAffineTransform(translationX: offset.width, y: offset.height))
    }
}
Copy the code

Animation Keyframes

SwiftUI does not provide any keyframe animation modifier directly, but we can use GeometryEffect to implement it, but before we do, we introduce a core content about view deformation called CGAffineTransform

For those of you who have taken linear algebra, deformation is essentially mapping a set of points through a deformation matrix to another set of points. Here we have matrix multiplication.

The main content of deformation is:

  • translation
  • The zoom
  • rotating

For example, take a look at the following image:

Since our mobile phone is A two-dimensional plane, the translation of A (10, 10) to A ‘(20, 20) only needs to add 10 to both x and y axes respectively, using mathematical expression:


In other words, the translation algorithm is simply the addition of matrices, and if we only think about translation, we can represent it with a two-dimensional vector. Let’s move on to the scaling example:

So let’s just say that we didn’t scale the rectangle we started with, so we scaled the rectangle by 3 times, which is essentially the same thing as scaling the identity matrix, and then multiplying it by the scaled identity matrix



Similarly, rotation can also be understood as rotation of the identity matrix first:


The formula for the sum difference of two angles used by Tuidao is:


We can calculate the values of x ‘and y’ according to formula 4 and 5:



Now it’s pretty obvious that, with a little bit of linear algebra, we can derive the rotation matrix:


Therefore, the rotation matrix is:


Note that, so far, we have only been talking about deformation in two dimensions, but the three dimensions can also be derived by this method, and there is no further explanation here.

I believe you must have a question? Matrix addition for displacement and matrix multiplication for scaling and rotation. Is it possible to use the same operation for all three deformations? The answer is yes, and it’s difficult to understand without a knowledge of linear algebra.

If we increase the dimensions of a point from 2 to 3, you can imagine that the x and y axes are on the screen, and we add a new z axis that points from the screen to our head, we used to have all our points on the screen, so z is equal to 0, and now we use the plane z=1, which is parallel to the screen, so we get:



If we look closely at the above formula, we can find the following correspondence:


To sum up:

  • Only rate displacement: x only need to set e, abCDF are 0, tx; Y just set f, abcde to 0
  • Only test rate scaling: x only need to set a, bcdef are 0, sx; Y only needs to set d, abceF is 0
  • Rotation only: x only needs to set a, c, bdef all to 0, y only needs to set b,d, abcef all to 0

Ok, so far we have unified the deformation operation. Both can be achieved by matrix multiplication:


Let’s go back to the following code:

    func effectValue(size: CGSize) -> ProjectionTransform {
        return ProjectionTransform(CGAffineTransform(a: 1, b: 0.c: skew, d: 1, tx: offset, ty: 0))}Copy the code

Looking back at Formula 11, it is not difficult to understand that in the case of x offset, X will tilt the distance of Yc, that is, x will tilt at a certain Angle with different y, as shown in the figure below:

Animation Feedback

Animation feedback means that the animation is in progress, we listen to the parameters that the animation is currently executing, and then do something else based on those parameters.

This sentence is not easy to understand, for example:

The card above has two spins:

  • 360 rotation
  • The card rotates 360 degrees along an axis

When the card is rotated 360 along an axis, we can monitor the current rotation Angle of the animation in effectValue. According to the current rotation Angle, we can actively control the content of the picture. In this case, when the Angle is between 90 and 270, the picture on the back is displayed. Once we listen for a rotation Angle of 90 or 270, we replace the displayed image.

The point here is that we can listen to the state of the current animation in the effectValue and then do additional logic based on that state. = =

So how did we do it? The core code is as follows:

struct FlipEffect: GeometryEffect {@Binding var flipped: Bool
    var angle: Double
    let axis: (CGFloat.CGFloat)
    
    var animatableData: Double {
        get {
            angle
        }
        set {
            angle = newValue
        }
    }
    
    func effectValue(size: CGSize) -> ProjectionTransform {
        DispatchQueue.main.async {
            self.flipped = (self.angle >= 90 && self.angle < 270)}let a = CGFloat(Angle.degrees(angle).radians)
        
        var  transform3d = CATransform3DIdentity
        transform3d.m34 = -1/max(size.width, size.height)
        transform3d = CATransform3DRotate(transform3d, a, self.axis.0.self.axis.1.0)
        transform3d = CATransform3DTranslate(transform3d, -size.width/2.0, -size.height/2.0.0)
        
        let affineTransform = ProjectionTransform(CGAffineTransform(translationX: size.width/2.0, y: size.height/2.0))
        
        return ProjectionTransform(transform3d).concatenating(affineTransform)
    }
}
Copy the code

Because GeometryEffect implements the Animatable protocol, the system dynamically evaluates parameters from animatableData, where the parameter is Angle. Angle is broken up into many different numbers, and the system calls the effectValue method for each different Angle.

DispatchQueue.main.async {
            self.flipped = (self.angle >= 90 && self.angle < 270)}Copy the code

We determine the value of flipped based on the current Angle, so flipped is frequently assigned. Our conclusion is that the system evaluates Angle based on the animation function, gets Angle in effectValue, and processes our own logic based on that value.

Angle is a Binding value: @binding var FLIPPED: Bool. Its value change is flipped upwards. Here’s the code:

struct RotatingCard: View {@State private var flipped = false
    @State private var animate3d = false
    @State private var rotate = false
    @State private var imgIndex = 0
    
    let images = ["1"."2"."3"."4"."5"]
    
    var body: some View {
        let binding = Binding<Bool> (get: { self.flipped }, set: { self.updateBinding($0)})return VStack {
            Spacer(a)Image(flipped ? "bg" : images[imgIndex]).resizable()
                .frame(width: 212, height: 320)
                .modifier(FlipEffect(flipped: binding, angle: animate3d ? 360 : 0, axis: (x: 1, y: 5)))
                .rotationEffect(Angle(degrees: rotate ? 0 : 360))
                .onAppear {
                    withAnimation(Animation.linear(duration: 4.0).repeatForever(autoreverses: false)) {
                        self.animate3d = true
                    }
                    
                    withAnimation(Animation.linear(duration: 8.0).repeatForever(autoreverses: false)) {
                        self.rotate = true}}Spacer()}}func updateBinding(_ value: Bool) {
        // If card was just flipped and at front, change the card
        ifflipped ! = value && ! flipped {self.imgIndex = self.imgIndex+1 < self.images.count ? self.imgIndex+1 : 0
        }
        
        flipped = value
    }
}
Copy the code

As you can see, depending on the flipped change at the critical point, we toggle iamgeIndex to flip the flipped image.

The purpose of this summary is to let us know that animation state can be obtained in an effectValue.

Make a View Follow a Path

The challenge in this section is to make a view move along a path. For simple graphics, we can use AnimatableData. For example, we can make a ball move along a circle by calculating the rotation Angle. Once the path becomes complex, we encounter challenges, such as the following effect:

Obviously, in order to be able to move the aircraft along the specified path in the same direction, the following two things need to be done:

  • Calculate the position of the aircraft in real time
  • Calculate the direction of rotation of the aircraft

To draw a path, you use bezier curves, and I’m not going to go into too much detail here, but you can draw any path you want with bezier curves. The code is as follows:

struct InfinityShape: Shape {
    func path(in rect: CGRect) -> Path {
        InfinityShape.createInfinityShape(in: rect)
    }
    
    static func createInfinityShape(in rect: CGRect) -> Path {
        let w = rect.width
        let h = rect.height
        let quarternW = w / 4.0
        let quarternH = h / 4.0
        
        var path = Path()
        
        path.move(to: CGPoint(x: quarternW, y: quarternH * 3))
        path.addCurve(to: CGPoint(x: quarternW, y: quarternH), control1: CGPoint(x: 0, y: quarternH * 3), control2: CGPoint(x: 0, y: quarternH))
        
        path.move(to: CGPoint(x: quarternW, y: quarternH))
        path.addCurve(to: CGPoint(x: quarternW * 3, y: quarternH * 3), control1: CGPoint(x: quarternW * 2, y: quarternH), control2: CGPoint(x: quarternW * 2, y: quarternH * 3))
        
        path.move(to: CGPoint(x: quarternW * 3, y: quarternH * 3))
        path.addCurve(to: CGPoint(x: quarternW * 3, y: quarternH), control1: CGPoint(x: w, y: quarternH * 3), control2: CGPoint(x: w, y: quarternH))
        
        path.move(to: CGPoint(x: quarternW * 3, y: quarternH))
        path.addCurve(to: CGPoint(x: quarternW, y: quarternH * 3), control1: CGPoint(x: quarternW * 2, y: quarternH), control2: CGPoint(x: quarternW * 2, y: quarternH * 3))
        
        return path
    }
}
Copy the code

And when you look at the code up here, you can refer to this graph that I drew down here,

The parameter is percent, and the value ranges from 0 to 1. How do we calculate the Point of a specific Point in the path? Path provides a method: trimmedPath(from:, to:), which returns a certain segment of path. As long as the value from: to is small enough, we can obtain a very small segment of path. We use the center of the segment of path as a Point, the code is as follows:

    /// Calculate the position of the current point
    func percentPoint(_ percent: CGFloat) -> CGPoint {
        let pct = percent > 1 ? 0 : (percent < 0 ? 1 : percent)
        let f = pct > 0.999 ? 0.999 : pct
        let t = pct > 0.999 ? 1 : pct + 0.001
        let tp = path.trimmedPath(from: f, to: t)
        return CGPoint(x: tp.boundingRect.midX, y: tp.boundingRect.midY)
    }
Copy the code

The second challenge is how to obtain the rotation Angle of a certain Point. With the above results, we can obtain a certain Point according to percent. Assuming that we want to obtain the direction of a certain Point P, we can obtain the previous Point of P according to the corresponding (percent -0.001) of P. We can calculate the direction. First look at the code:

    /// calculate the Angle of two points
    func calculateDirection(_ pt1: CGPoint, _ pt2: CGPoint) -> CGFloat {
        let a = pt2.x - pt1.x
        let b = pt2.y - pt1.y
        
        let angle = (a > 0)? atan(b / a) : atan(b / a) -CGFloat.pi
        
        return angle
    }
Copy the code

Let Angle = (a > 0)? Atan (b/a) : atan(b/a) -cgfloat. PI: atan(b/a) -cgfloat.

We assume that P0 is the previous point, P1, P2, P3, and P4 are the points to be calculated, and that there are at most four directions in a plane, each in a different quadrant.

Notice that in iOS, y gets bigger as you go down. The Angle is clockwise, so Angle 1 is negative in the figure above, and Angle 2 is positive. = =

For atan(), for example if atan(x) = 1.5, then atan(-x) = -1.5.

        let a = pt2.x - pt1.x
        let b = pt2.y - pt1.y
Copy the code
  • P0 -> P1: a > 0 b < 0 ATan (b/a) is negative, which is exactly Angle 1
  • P0 -> P2: a > 0, b > 0 atan(b/a) is positive, which is exactly Angle 2
  • P0 -> P3: a < 0, b > 0 atan(b/a) is negative, resulting in Angle 1. In order to obtain the Angle P0 -> P3, we need to subtract 180 degrees, which is PI
  • P0 -> P4: a < 0, b < 0 atan(b/a) is positive, and the result is Angle 2. In order to find the Angle P0 -> P4, we need to subtract 180 degrees, which is PI

It’s not hard to see that if a is greater than 0, then we get exactly the Angle we want, but in other cases we subtract 180 degrees from the Angle we want, and now you see what the code is?

The complete code is as follows:

struct FollowEffect: GeometryEffect {
    var pct: CGFloat
    let path: Path
    
    var animatableData: CGFloat {
        get {
            pct
        }
        set {
            pct = newValue
        }
    }
    
    func effectValue(size: CGSize) -> ProjectionTransform {
        let pt1 = percentPoint(pct - 0.01)
        let pt2 = percentPoint(pct)
        
        let angle = calculateDirection(pt1, pt2)
        let transform = CGAffineTransform(translationX: pt1.x, y: pt1.y).rotated(by: angle)
        
        return ProjectionTransform(transform)
    }
    
    /// calculate the Angle of two points
    func calculateDirection(_ pt1: CGPoint, _ pt2: CGPoint) -> CGFloat {
        let a = pt2.x - pt1.x
        let b = pt2.y - pt1.y
        
        let angle = (a > 0)? atan(b / a) : atan(b / a) -CGFloat.pi
        
        return angle
    }
    
    /// Calculate the position of the current point
    func percentPoint(_ percent: CGFloat) -> CGPoint {
        let pct = percent > 1 ? 0 : (percent < 0 ? 1 : percent)
        let f = pct > 0.999 ? 0.999 : pct
        let t = pct > 0.999 ? 1 : pct + 0.001
        let tp = path.trimmedPath(from: f, to: t)
        return CGPoint(x: tp.boundingRect.midX, y: tp.boundingRect.midY)
    }
}
Copy the code
struct Example9: View {@State private var flag = false
    
    var body: some View {
        GeometryReader { proxy in
            ZStack(alignment: .topLeading) {
                InfinityShape().stroke(Color.green, style: StrokeStyle(lineWidth: 4, lineCap: .round, lineJoin: .miter, miterLimit: 0, dash: [7.7], dashPhase: 0))
                    .frame(width: proxy.size.width, height: 300)
                
                // Animate movement of Image
                Image(systemName: "airplane").resizable().foregroundColor(Color.red)
                    .frame(width: 50, height: 50).offset(x: -25, y: -25)
                    .modifier(FollowEffect(pct: self.flag ? 1 : 0, path: InfinityShape.createInfinityShape(in: CGRect(x: 0, y: 0, width: proxy.size.width, height: 300))))
                    .onAppear {
                        withAnimation(Animation.linear(duration: 4.0).repeatForever(autoreverses: false)) {
                            self.flag.toggle()
                        }
                    }

                }.frame(alignment: .topLeading)
            }
            .padding(20)}}Copy the code

Ignored By Layout

Ignored By Layout? This concept is useful in certain situations. Let’s start with a demo:

As you can see, the green bar’s layout changes as the animation changes, while the orange bar’s layout does not change. Of course, although its layout changes in real time, the blue bar behind it does not automatically follow these changes.

The core code is as follows:

struct IgnoredByLayoutView: View {@State private var animate = false
    @State private var w: CGFloat = 50
    
    var body: some View {
        VStack {
            HStack {
                RoundedRectangle(cornerRadius: 5)
                    .foregroundColor(.green)
                    .frame(width: 200, height: 40)
                    .overlay(ShowSize())
                    .modifier(MyEffect(x: animate ? -10 : 10))
                
                RoundedRectangle(cornerRadius: 5)
                    .foregroundColor(.blue)
                    .frame(width: w, height: 40)}HStack {
                RoundedRectangle(cornerRadius: 5)
                    .foregroundColor(.orange)
                    .frame(width: 200, height: 40)
                    .overlay(ShowSize())
                    .modifier(MyEffect(x: animate ? -10 : 10).ignoredByLayout())
                
                RoundedRectangle(cornerRadius: 5)
                    .foregroundColor(.red)
                    .frame(width: w, height: 40)
            }
        }
        .onAppear {
            withAnimation(Animation.easeInOut(duration: 1.0).repeatForever()) {
                self.animate = true}}}}struct MyEffect: GeometryEffect {
    var x: CGFloat = 0
    
    var animatableData: CGFloat {
        get {
            x
        }
        set {
            x = newValue
        }
    }
    
    func effectValue(size: CGSize) -> ProjectionTransform {
        return ProjectionTransform(CGAffineTransform(translationX: x, y: 0))}}struct ShowSize: View {
    var body: some View {
        GeometryReader { proxy in
            Text("x = \(proxy.frame(in: .global).minX, specifier: "%.0f")")
                .foregroundColor(.white)
        }
    }
}
Copy the code

use.ignoredByLayout(), which allows us to perform animation in some special scenarios, but the layout of the view is not calculated in real time.

conclusion

GeometryEffect already complies with the Animatable protocol, so we need to implement animatableData in our custom Effect GeometryEffect, where the edge value is automatically calculated by the system based on our animation Settings, we use this value, The func effectValue(size: CGSize) -> ProjectionTransform function does the necessary calculations and returns a ProjectionTransform to tell the system about the view’s deformation.

Note: the above content referred to the website https://swiftui-lab.com/swiftui-animations-part2/, if any infringement, immediately deleted.