Advanced SwiftUI animation - Part 2: GeometryEffect

Posted by stevepatd on Mon, 21 Feb 2022 05:02:11 +0100

In the first part of this series, I introduced the Animatable protocol and how we can use it to animate paths. Next, we will use a new tool: GeometryEffect to animate the transformation matrix with the same protocol. If you haven't read the first part and don't know what Animatable protocol is, you should read it first. Or if you're just interested in geometry effect and don't care about animation, you can skip the first part and continue reading this article.

GeometryEffect

GeometryEffect is a protocol that conforms to Animatable and ViewModifier. In order to comply with the GeometryEffect protocol, you need to implement the following methods:

func effectValue(size: CGSize) -> ProjectionTransform

Suppose your method is called SkewEffect. In order to apply it to a view, you will use it as follows:

Text("Hello").modifier(SkewEfect(skewValue: 0.5))

Text("Hello") will be converted to skeweffect The matrix created by the effectvalue () method. That's it. Note that these changes affect the view, but not the layout of its ancestors or descendants.

Because GeometryEffect is also Animatable, you can add an animatableData attribute, and then you have a movable effect.

You may not realize that you may have been using GeometryEffect. If you've ever used it 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))
    }
}

Animation Keyframes

Most animation frames have the concept of keyframes. It is a way to tell the animation engine to divide the animation into several blocks. Although SwiftUI does not have these functions, we can simulate it. In the following example, we will create an effect of moving the view horizontally, but it will also tilt at the beginning and UN tilt at the end:

The tilt effect needs to increase and decrease during the first and last 20% of the animation. In the middle, the tilt effect will remain stable. Well, now we have a challenge. Let's see how to solve this problem.

We will first create an effect that tilts and moves our view without paying too much attention to the 20% requirement. It doesn't matter if you don't know much about transformation matrices. Just know that the CGAffineTransform c parameter drives the tilt and tx drives the 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))
    }
}

simulation

Well, now is the interesting 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 enter the last 20% of the animation. Our code should use this to change the corresponding effect. Most importantly, we also tell the effect whether we move the view to the right or left, so it can tilt to one side 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))
    }
}

Now, just for fun, we will apply this effect to multiple views, but their animation will be staggered and used delay() animation modifier. The complete code can be obtained from example 6 in the gist file linked at the top of this page.

Animation feedback

In the next example, I will show you a simple technique that will make our view respond to the progress of effect animation.

We'll create an effect that lets us rotate in three dimensions. Although SwiftUI already has a modifier, that is Rotation3defect(), but this modifier will be special. Whenever 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 faces. Here is an example:

Implement our results

Let's start creating our effects. You will notice that 3D rotation transformation may be slightly different from your habits in Core Animation. In SwiftUI, the default anchor point is in the front corner of the view, while in Core Animation, it is in the center. Although existing The rotrotrotingg3effect () modifier allows you to specify an anchor, but we're building our own effect. That means we have to deal with it ourselves. Since we cannot change the anchor point, we need to add some conversion effects to the combination:

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 arranged the modification after the drawing of the view.
        // Otherwise, we will receive a runtime error indicating that we are changing
        // Changes state while the view is being drawn.
        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)
    }
}

By looking at the geometric effect code, there is an interesting fact. We use the @ Bindingd attribute flipped to report to the view which side is user oriented.

In our view, we will use the flipped value to conditionally display one of the two views. However, in this specific example, we will use a more technique. If you watch the video carefully, you will find that the card is changing all the time. The back is always the same, but the front changes every time. Therefore, this is not simply showing one view for one side and another view for the other side. We are not based on the flipped value, but to monitor the change of the flipped value. Then for each complete round, we will use different cards.

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

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

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

As mentioned earlier, we may 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)))
struct ViewSwapper: View {
    let showFront: Bool
    
    var body: some View {
        Group {
            if showFront {
                FrontView()
            } else {
                BackView()
            }
        }
    }
}

Let the view follow a path

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

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

2. How to determine the direction of the view when moving through the path. In this particular case, how do we know where the nose of the aircraft is pointing (spoiler warning, just a little trigonometric function).

The animatable parameter for this effect will be pct. It represents the position of the aircraft in the path. If we want the plane to make a complete 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 the x and y positions in the path

In order to obtain the x and y positions of the aircraft under a given pct value, we will use the Path structure .trimmedPath() Modifier. Given a starting and ending percentage, the method returns a CGRect. It contains the boundary of the path. According to our requirements, we only need to call it with a very close starting point and ending point. It will return a very small rectangle, and we will use its center 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)
}

Find direction

In order to obtain the rotation angle of our plane, we will use a trigonometric function. Using the technique described above, we will get the X and Y positions of two points: the current position and the previous position. By creating an imaginary line, we can calculate its angle, which is the direction of the aircraft.

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

Combine all the contents together

Now that we know the tools we need to achieve our goals, 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)
    }
}

The complete code can be provided in the form of Example8 in the gist file linked at the top of this page.

Ignored By Layout

Our last trick 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 conversion. When the view performs layout calculations, the view ignores the transformations returned by this method.

I'll introduce the transition soon. At the same time, let me introduce an example to use ignoredByLayout() has some obvious effects. We'll see how GeometryReader reports different locations, depending on how the effect is added (that is, with or without. 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)
        }
    }
}

What's next?

The three examples we have done today have little in common, but they all use the same protocol to achieve their goals. GeometryEffect is simple: it has only one method to implement, however, its possibilities are infinite, and we only need to use a little imagination.

Next, we will introduce the last protocol in this series: animatable modifier. If GeometryEffect is powerful, wait and see all the wonderful things you can do with animatable modifier. Here is a quick preview of the entire series:

https://swiftui-lab.com/wp-content/uploads/2019/08/animations.mp4

Translated from The SwiftUI Lab of Advanced SwiftUI Animations – Part 2: GeometryEffect

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

https://gist.github.com/swiftui-lab/e5901123101ffad6d39020cc7a810798

Example 8 required picture resources. Download here:

https://swiftui-lab.com/?smd_process_download=1&download_id=916

Topics: Swift swiftui