This is the fourth day of my participation in the First Challenge 2022

In the first part of this series, I introduced the Animatable protocol and how we used it to animate paths. Next, we use a new tool called GeometryEffect, which animates the transform matrix using the same protocol. If you haven’t read Part 1 and don’t know what the Animatable protocol is, you should read it first. Or if you are just interested in GeometryEffect and don’t care about animations, you can skip the first part and continue with this article.

GeometryEffect

GeometryEffect is a protocol that conforms to Animatable and ViewModifier. To qualify for the GeometryEffect protocol, you need to implement the following methods:

func effectValue(size: CGSize) -> ProjectionTransform
Copy the code

Let’s say your method is called SkewEffect. To apply it to a view, you use it like this:

Text("Hello").modifier(SkewEfect(skewValue: 0.5))
Copy the code

Text(“Hello”) will be converted to a matrix created by the skeweect. EffectValue () method. It’s that simple. Note that these changes affect the view, but not the layout of its ancestors or descendants.

Because GeometryEffect also conforms to Animatable, you can add an animatableData property and you have a movable effect.

You may not realize it, but you have been using GeometryEffect. If you have used. Offset (), you are actually using GeometryEffect. Let me tell you how it works:

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

Most animation frameworks have the concept of keyframes. It is a way of telling the animation engine to split an animation into chunks. SwiftUI doesn’t have these features, but we can simulate it. In the following example, we will create an effect that moves the view horizontally, but it will also tilt at the beginning and untilt at the end:

Tilt effects need to increase and decrease during the first and last 20% of the animation. In the middle, the tilt effect will remain stable. Ok, now we have a challenge, let’s see how to solve this problem.

We’ll first create an effect that tilts and moves our view without paying too much attention to the 20% requirement. If you don’t know much about transformation matrices, that’s fine. All you need to know is that the CGAffineTransform C parameter drives skew, while tx drives X offset.

struct SkewedOffset: GeometryEffect {
    var offset: CGFloat
    var skew: CGFloat
    
    var animatableData: AnimatablePair<CGFloat.CGFloat> {
        get { AnimatablePair(offset, skew) }
        set {
            offset = newValue.first
            skew = newValue.second
        }
    }
    
    func effectValue(size: CGSize) -> ProjectionTransform {
        return ProjectionTransform(CGAffineTransform(a: 1, b: 0, c: skew, d: 1, tx: offset, ty: 0))}}Copy the code

simulation

Okay, now for the fun part. To simulate keyframes, we will define an animatable parameter, which we will change from 0 to 1. When this parameter is 0.2, we reach the top 20% of the animation. When this parameter is 0.8 or greater, we are in the last 20% of the animation. Our code should take advantage of this to change the effect accordingly. Most importantly, we also tell the effect whether we move the view to the right or left, so it can tilt one way or the other:

struct SkewedOffset: GeometryEffect {
    var offset: CGFloat
    var pct: CGFloat
    let goingRight: Bool

    init(offset: CGFloat.pct: CGFloat.goingRight: Bool) {
        self.offset = offset
        self.pct = pct
        self.goingRight = goingRight
    }

    var animatableData: AnimatablePair<CGFloat.CGFloat> {
        get { return AnimatablePair<CGFloat.CGFloat>(offset, pct) }
        set {
            offset = newValue.first
            pct = newValue.second
        }
    }

    func effectValue(size: CGSize) -> ProjectionTransform {
        var skew: CGFloat

        if pct < 0.2 {
            skew = (pct * 5) * 0.5 * (goingRight ? -1 : 1)}else if pct > 0.8 {
            skew = ((1 - pct) * 5) * 0.5 * (goingRight ? -1 : 1)}else {
            skew = 0.5 * (goingRight ? -1 : 1)}return ProjectionTransform(CGAffineTransform(a: 1, b: 0, c: skew, d: 1, tx: offset, ty: 0))}}Copy the code

Now, just for fun, we’ll apply this effect to multiple views, but their animations will be staggered, using the.delay() animation modifier. The complete code is available in example 6 in the GIST file linked at the top of this page.

Animation feedback

In the next example, I’ll show you a simple technique that will make our view react to the progress of the effects animation.

We’re going to create an effect that lets us rotate in three dimensions. SwiftUI already has a modifier,.rotrotation3deffect (), but this modifier will be special. Every time our view is rotated enough to show us the other side, a Boolean binding will be updated.

By reacting to changes in binding variables, we will be able to replace the view in the process of rotating the animation. This creates the illusion that the view has two sides. Here’s an example:

Implement our effect

Let’s start creating our effects. You’ll notice that 3d rotation transformations may be slightly different from what you’re used to in core animation. In SwiftUI, the default anchor is in the front corner of the view, while in Core Animation it is in the center. Although the existing.rotrotingg3deffect () modifier lets you specify an anchor point, we’re creating our own effects. That means we have to deal with it ourselves. Since we can’t change the anchor point, we need to add some transition effects to the composition:

struct FlipEffect: GeometryEffect {
    
    var animatableData: Double {
        get { angle }
        set { angle = newValue }
    }
    
    @Binding var flipped: Bool
    var angle: Double
    let axis: (x: CGFloat, y: CGFloat)
    
    func effectValue(size: CGSize) -> ProjectionTransform {
        
        // We schedule the changes to be made after the view is drawn.
        Otherwise, we receive a runtime error indicating that we are changing
        // View changes state while drawing.
        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, axis.x, axis.y, 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

By looking at the geometry effect code, there is an interesting fact. We use the @bindingd property FLIPPED to report to the view which side is user-oriented.

In our view, we will use the value of FLIPPED to conditionally display one of the two views. However, in this specific example, we will use a more subtle technique. If you look closely at the video, you’ll see that the card keeps changing. The backs are always the same, but the heads change every time. So, it’s not as simple as showing one view for one side and another view for the other. Instead of focusing on the value of FLIPPED, we want to monitor the value of flipped. Then each full turn, we’ll play a different card.

We have an array of image names that we want to look at one by one. To do this, we will use a custom binding variable. This technique is best explained in 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 = ["diamonds-7"."clubs-8"."diamonds-6"."clubs-b"."hearts-2"."diamonds-b"]
    
    var body: some View {
        let binding = Binding<Bool>(get: { self.flipped }, set: { self.updateBinding($0)})return VStack {
            Spacer(a)Image(flipped ? "back" : images[imgIndex]).resizable()
                .frame(width: 265, height: 400)
                .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
        if flipped ! = value && !flipped {
            self.imgIndex = self.imgIndex+1 < self.images.count ? self.imgIndex+1 : 0
        }
        
        flipped = value
    }
}
Copy the code

The complete code can be found in example 7 in the GIST file linked at the top of this page.

= = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = =

As mentioned earlier, we might want to use two completely different views instead of changing the image name. This is also possible, here is an example:

Color.clear.overlay(ViewSwapper(showFront: flipped))
    .frame(width: 265, height: 400)
    .modifier(FlipEffect(flipped: $flipped, angle: animate3d ? 360 : 0, axis: (x: 1, y: 5)))
Copy the code
struct ViewSwapper: View {
    let showFront: Bool
    
    var body: some View {
        Group {
            if showFront {
                FrontView()}else {
                BackView()}}}}Copy the code

Let the view follow a path

Next, we will build a completely different GeometryEffect. In this example, our effect will move a view through an arbitrary path. There are two main challenges to this problem:

1. How to obtain the coordinates of a specific point in a path.

2. How to determine the direction of the view while moving through the path. In this particular case, how do we know where the nose of the plane is pointing (spoiler warning, a little trigonometry will do).

The animatable parameter for this effect will be PCT. It represents the position of the aircraft in the path. If we want the aircraft to perform a full turn, we will use values from 0 to 1. For a value of 0.25, it means that the aircraft has advanced a quarter of the way.

Find x and y positions in the path

To get the x and Y positions of the aircraft at a given PCT value, we will use the.trimmedPath() modifier of the Path structure. Given a starting and ending percentage, the method returns a CGRect. It contains the boundary of the segment’s path. Depending on our needs, we simply call it with very close starts and ends. It will return a very small rectangle whose center we will use as our X and Y positions.

func percentPoint(_ percent: CGFloat) -> CGPoint {
    // percent difference between points
    let diff: CGFloat = 0.001
    let comp: CGFloat = 1 - diff
    
    // handle limits
    let pct = percent > 1 ? 0 : (percent < 0 ? 1 : percent)
    
    let f = pct > comp ? comp : pct
    let t = pct > comp ? 1 : pct + diff
    let tp = path.trimmedPath(from: f, to: t)
    
    return CGPoint(x: tp.boundingRect.midX, y: tp.boundingRect.midY)
}
Copy the code

Looking for direction

To figure out the rotation of our plane, we’re going to use a little bit of trigonometry. Using the technique described above, we will get the X and Y positions of the two points: the current position and the position just before. By creating an imaginary line, we can calculate its Angle, which is the direction of the plane.

func calculateDirection(_ pt1: CGPoint._ pt2: CGPoint) -> CGFloat {
    let a = pt2.x - pt1.x
    let b = pt2.y - pt1.y
    
    let angle = a < 0 ? atan(Double(b / a)) : atan(Double(b / a)) - Double.pi
    
    return CGFloat(angle)
}
Copy the code

Bring it all together

Now that we know the tools we need to achieve our goal, we will achieve this effect:

struct FollowEffect: GeometryEffect {
    var pct: CGFloat = 0
    let path: Path
    var rotate = true
    
    var animatableData: CGFloat {
        get { return pct }
        set { pct = newValue }
    }
    
    func effectValue(size: CGSize) -> ProjectionTransform {
        if !rotate { // Skip rotation login
            let pt = percentPoint(pct)
            
            return ProjectionTransform(CGAffineTransform(translationX: pt.x, y: pt.y))
        } else {
            let pt1 = percentPoint(pct)
            let pt2 = percentPoint(pct - 0.01)
            
            let angle = calculateDirection(pt1, pt2)
            let transform = CGAffineTransform(translationX: pt1.x, y: pt1.y).rotated(by: angle)
            
            return ProjectionTransform(transform)
        }
    }
    
    func percentPoint(_ percent: CGFloat) -> CGPoint {
        // percent difference between points
        let diff: CGFloat = 0.001
        let comp: CGFloat = 1 - diff
        
        // handle limits
        let pct = percent > 1 ? 0 : (percent < 0 ? 1 : percent)
        
        let f = pct > comp ? comp : pct
        let t = pct > comp ? 1 : pct + diff
        let tp = path.trimmedPath(from: f, to: t)
        
        return CGPoint(x: tp.boundingRect.midX, y: tp.boundingRect.midY)
    }
    
    func calculateDirection(_ pt1: CGPoint._ pt2: CGPoint) -> CGFloat {
        let a = pt2.x - pt1.x
        let b = pt2.y - pt1.y
        
        let angle = a < 0 ? atan(Double(b / a)) : atan(Double(b / a)) - Double.pi
        
        return CGFloat(angle)
    }
}
Copy the code

The complete code is available as Example8 in the GIST file linked at the top of this page.

Ignored By Layout

Our final technique for GeometryEffect is square.ignoredByLayout (). Let’s see what the document says:

Returns an effect that produces the same geometry transform as this effect, but only applies the transform while rendering its view.

Returns an effect that produces the same geometric transformation as this effect, but applies it only when rendering its view.

Use this method to disable layout changes during transitions. The view ignores the transform returned by this method while the view is performing its layout calculations.

Use this method to disable layout changes during transformation. When the view performs the layout calculation, the view ignores the transformation returned by this method.

I’ll talk about the transition in a minute. In the meantime, let me show you an example that uses.ignoredByLayout() to some obvious effect. We’ll see how the GeometryReader reports different positions depending on how the effect is added (that is, yes or no.ignoredByLayout ()).

struct ContentView: View {
    @State private var animate = false
    
    var body: some View {
        VStack {
            RoundedRectangle(cornerRadius: 5)
                .foregroundColor(.green)
                .frame(width: 300, height: 50)
                .overlay(ShowSize())
                .modifier(MyEffect(x: animate ? -10 : 10))
            
            RoundedRectangle(cornerRadius: 5)
                .foregroundColor(.blue)
                .frame(width: 300, height: 50)
                .overlay(ShowSize())
                .modifier(MyEffect(x: animate ? 10 : -10).ignoredByLayout())
            
        }.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 = \(Int(proxy.frame(in: .global).minX))")
                .foregroundColor(.white)
        }
    }
}
Copy the code

What’s next?

The three examples we’ve done today have little in common, except that they all use the same protocol to achieve their goals. GeometryEffect is simple: It has only one method to implement, however, its possibilities are endless and we just need to use a little imagination.

Next, we’ll cover the final agreement in this series: AnimatableModifier. If geo Archive is powerful, expect to see all the great things you can do with AnimatableModifier. Here’s a quick preview of the entire collection:

Swiftui-lab.com/wp-content/…

The SwiftUI Lab Advanced SwiftUI Animations — Part 2: Geo Archive

The full sample code for this article can be found at:

Gist.github.com/swiftui-lab…

Image resources required for Example 8. Download it here:

Swiftui-lab.com/?smd_proces…