• SwiftUI: Animating Color Changes
  • Jean-marc Boullianne
  • The Nuggets translation Project
  • Permanent link to this article: github.com/xitu/gold-m…
  • Translator: chaingangway
  • Proofread by: LSvih

Cool color switching animations with SwiftUI

It’s time to get hot, old friends! In this article we will learn how to animate color switching using Paths and AnimatableData in SwiftUI.

How do these quick switching animations work? Let’s see what follows.

basis

The key to animation is to create a structure in SwiftUI that implements the Shape protocol. We’ll call it SplashShape. In the Shape protocol, there is a method called path(in Rect: CGRect) -> path, which can be used to set the appearance of a graph. We will use this method to implement the various animations in this article.

Create a SplashShape structure

Next we create a structure called SplashStruct, which inherits from the Shape protocol.

import SwiftUI

struct SplashShape: Shape {
    
    func path(in rect: CGRect) -> Path {
        return Path()}}Copy the code

Let’s start by creating two animation types: leftToRight and rightToLeft, which look like this:

Splash animation

We create an enumeration called SplashAnimation to define the animation type, making it easier to extend new animations later (verify this at the end of this article!). .

import SwiftUI

struct SplashShape: Shape {
    
    public enum SplashAnimation {
        case leftToRight
        case rightToleft
    }
    
    func path(in rect: CGRect) -> Path {
        return Path()}}Copy the code

In the path() method, we can select the animation we want to use and return the path of the animation. But first, we must create variables to store the animation type and record the animation process.

import SwiftUI

struct SplashShape: Shape {
    
    public enum SplashAnimation {
        case leftToRight
        case rightToleft
    }
    
    var progress: CGFloat
    var animationType: SplashAnimation
    
    func path(in rect: CGRect) -> Path {
        return Path()}}Copy the code

Progress, which ranges from 0 to 1, represents the progress of the entire animation. This will come in handy when we write the path() method.

Write the path() method

As mentioned earlier, in order to return the correct Path, we need to be clear about what kind of animation we are using. Write the switch statement in the path() method and use the animationType we defined earlier.

func path(in rect: CGRect) -> Path {
   switch animationType {
       case .leftToRight:
           return Path(a)case .rightToLeft:
           return Path()}}Copy the code

Now this method only returns empty Paths. We need to create ways to generate real animation.

Implement animation methods

Underneath the path() method, create two new methods, leftToRight() and rightToLeft(), each representing an animation type. Within each method body, we create a rectangular Path that transforms over time based on the value of the progress variable.

func leftToRight(rect: CGRect) -> Path {
    var path = Path()
    path.move(to: CGPoint(x: 0, y: 0)) // Top Left
    path.addLine(to: CGPoint(x: rect.width * progress, y: 0)) // Top Right
    path.addLine(to: CGPoint(x: rect.width * progress, y: rect.height)) // Bottom Right
    path.addLine(to: CGPoint(x: 0, y: rect.height)) // Bottom Left
    path.closeSubpath() // Close the Path
    return path
}

func rightToLeft(rect: CGRect) -> Path {
    var path = Path()
    path.move(to: CGPoint(x: rect.width, y: 0))
    path.addLine(to: CGPoint(x: rect.width - (rect.width * progress), y: 0))
    path.addLine(to: CGPoint(x: rect.width - (rect.width * progress), y: rect.height))
    path.addLine(to: CGPoint(x: rect.width, y: rect.height))
    path.closeSubpath()
    return path
}
Copy the code

Then call the above two new methods in the path() method.

func path(in rect: CGRect) -> Path {
   switch animationType {
       case .leftToRight:
           return leftToRight(rect: rect)
       case .rightToLeft:
           return rightToLeft(rect: rect)
   }
}
Copy the code

Animation data

To ensure that Swift knows how to animate Shape when changing the progress variable, we need to specify a variable that responds to the animation. Under the progress and animationType variables, define the animatableData. This is a variable based on the Animatable protocol that informs SwiftUI to animate the view when data changes.

var progress: CGFloat
var animationType: SplashAnimation

var animatableData: CGFloat {
    get { return progress }
    set { self.progress = newValue}
}
Copy the code

Animation occurs when color switches

So far, we have created a Shape that will change over time. Next, we need to add it to the view and animate it automatically when the view color changes. That’s when we introduce the SplashView. We will create a SplashView to automatically update the Progress variable of the SplashShape. When SplashView receives the new Color, it triggers the animation.

First, we create the SplashView structure.

import SwiftUI

struct SplashView: View {

    var body: some View {
        // SplashShape Here}}Copy the code

SplashShape needs to use the SplashAnimation enumeration as an argument, so we’ll pass it to the SplashView as an argument. In addition, we want to set the animation when the view’s background Color changes, so we also want to pass the Color parameter. These details will be explained in our initialization method.

ColorStore is a custom ObservableObject. It listens for Color changes in the SplashView structure so that we can initialize the SplashShape animation and eventually change the background Color. We’ll show how it works later.

struct SplashView: View {
    
    var animationType: SplashShape.SplashAnimation
    @State private var prevColor: Color // Stores background color
    @ObservedObject var colorStore: ColorStore // Send new color updates

    
    init(animationType: SplashShape.SplashAnimation, color: Color) {
        self.animationType = animationType
        self._prevColor = State<Color>(initialValue: color)
        self.colorStore = ColorStore(color: color)
    }

    var body: some View {
        // SplashShape Here}}class ColorStore: ObservableObject {@Published var color: Color
    
    init(color: Color) {
        self.color = color
    }
}
Copy the code

Build SplashView body

Inside the body, we need to return a Rectangle that matches the current color of the SplashView. We then use the previously defined ColorStore so that we can receive updated color values to drive the animation.

var body: some View {
    Rectangle()
        .foregroundColor(self.prevColor) // Current Color
        .onReceive(self.colorStore.$color) { color in
            // Animate Color Update Here}}Copy the code

When the color changes, we need to record the changing color and progress in the SplashView. To do this, we define the layers variable.

@State var layers: [(Color,CGFloat)] = [] // New Color & Progress
Copy the code

Now back inside the body variable, we add the new received Colors to the layers variable. When we add, we set the progress to 0. Then, within half a second of the animation, we set the progress to 1.

var body: some View {
    Rectangle()
        .foregroundColor(self.prevColor) // Current Color
        .onReceive(self.colorStore.$color) { color in
            // Animate Color Update Here
            self.layers.append((color, 0))
            
            withAnimation(.easeInOut(duration: 0.5)) {
                self.layers[self.layers.count-1].1 = 1.0}}}Copy the code

Now in this code, the updated color is added to the Layers variable, but the color is not displayed. To display the colors, we need to add an overlay layer for each Rectangle layer inside the Body variable.

var body: some View {
    Rectangle()
        .foregroundColor(self.prevColor)
        .overlay(
            ZStack {
                ForEach(layers.indices, id: \.self) { x in
                    SplashShape(progress: self.layers[x].1, animationType: self.animationType)
                        .foregroundColor(self.layers[x].0)
                }

            }

            , alignment: .leading)
        .onReceive(self.colorStore.$color) { color in
            // Animate color update here
            self.layers.append((color, 0))

            withAnimation(.easeInOut(duration: 0.5)) {
                self.layers[self.layers.count-1].1 = 1.0}}}Copy the code

The test results

You can run the following code in the emulator. What this code means is that when you click the button in the ContentView, it calculates the index to select the color in the SplashView and also triggers an update within the ColorStore. So, when the SplashShape layer is added to the SplashView, the animation is triggered.

import SwiftUI

struct ContentView: View {
    var colors: [Color] = [.blue, .red, .green, .orange]
    @State var index: Int = 0
    
    @State var progress: CGFloat = 0
    var body: some View {
        VStack {
           
            SplashView(animationType: .leftToRight, color: self.colors[self.index])
                .frame(width: 200, height: 100, alignment: .center)
                .cornerRadius(10)
                .shadow(color: Color.black.opacity(0.2), radius: 10, x: 0, y: 4)
            
            Button(action: {
                self.index = (self.index + 1) % self.colors.count{})Text("Change Color")
            }
            .padding(.top, 20)}}}Copy the code

It’s not finished yet!

We still have one feature left unimplemented. Now we continue to add layers to the SplashView without removing them. Therefore, we need to clear these layers when the animation is finished.

Inside the onReceive() method of the body variable of the SplashView structure, make the following changes:

.onReceive(self.colorStore.$color) { color in
    self.layers.append((color, 0))

    withAnimation(.easeInOut(duration: 0.5)) {
        self.layers[self.layers.count-1].1 = 1.0
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
            self.prevColor = self.layers[0].0 // Finalizes background color of SplashView
            self.layers.remove(at: 0) // removes itself from layers array}}}Copy the code

This line of code allows us to remove the values used in the Layers array and ensure that SplashView displays the correct background color based on the newly updated values.

Show results!

Have you completed the examples for this tutorial? You can send me a screenshot or link to show your work. TrailingClosure.com will feature your work. You can contact us on Twitter @TrailingClosure or email us at [email protected].

Making the source code

You can view the source code for this tutorial on my Github! In addition to the examples shown, the complete source code for SplashShape and SplashView is included. . But wait, there’s more!

Easter eggs!

If you are familiar with my previous tutorials, you should know that I love Easter eggs 😉. At the beginning of this article, I said I would implement more animations. And now it’s finally here… The drums… .

Splash animation 🥳

Hahaha!! Remember? I said I would add more animation categories.

enum SplashAnimation {
    case leftToRight
    case rightToLeft
    case topToBottom
    case bottomToTop
    case angle(Angle)
    case circle
}

func path(in rect: CGRect) -> Path {

    switch self.animationType {
        case .leftToRight:
            return leftToRight(rect: rect)
        case .rightToLeft:
            return rightToLeft(rect: rect)
        case .topToBottom:
            return topToBottom(rect: rect)
        case .bottomToTop:
            return bottomToTop(rect: rect)
        case .angle(let splashAngle):
            return angle(rect: rect, angle: splashAngle)
        case .circle:
            return circle(rect: rect)
    }

}
Copy the code

You must be thinking… “Wow, that’s a lot of eggs…” . Don’t fret. All we need to do is add a few methods to SplashShape’s path() method.

Let’s animate it one by one…

TopToBottom and bottomToTop animations

These methods are very similar to leftToRight and rightToLeft in that they create a path from the bottom or top of the Shape and transform it over time using the progress variable.

func topToBottom(rect: CGRect) -> Path {
    var path = Path()
    path.move(to: CGPoint(x: 0, y: 0))
    path.addLine(to: CGPoint(x: rect.width, y: 0))
    path.addLine(to: CGPoint(x: rect.width, y: rect.height * progress))
    path.addLine(to: CGPoint(x: 0, y: rect.height * progress))
    path.closeSubpath()
    return path
}

func bottomToTop(rect: CGRect) -> Path {
    var path = Path()
    path.move(to: CGPoint(x: 0, y: rect.height))
    path.addLine(to: CGPoint(x: rect.width, y: rect.height))
    path.addLine(to: CGPoint(x: rect.width, y: rect.height - (rect.height * progress)))
    path.addLine(to: CGPoint(x: 0, y: rect.height - (rect.height * progress)))
    path.closeSubpath()
    return path
}
Copy the code

Circle the animation

If you remember elementary school geometry, you should know the Pythagorean Theorem. a^2 + b^2 = c^2

A and B can be regarded as the height and width of the rectangle, from which we can find C, the radius of the circle needed to cover the whole rectangle. We build the circle’s path from this and transform it over time using the progress variable.

func circle(rect: CGRect) -> Path {
    let a: CGFloat = rect.height / 2.0
    let b: CGFloat = rect.width / 2.0

    let c = pow(pow(a, 2) + pow(b, 2), 0.5) // a^2 + b^2 = c^2 --> Solved for 'c'
    // c = radius of final circle

    let radius = c * progress
    // Build Circle Path
    var path = Path()
    path.addArc(center: CGPoint(x: rect.midX, y: rect.midY), radius: radius, startAngle: Angle(degrees: 0), endAngle: Angle(degrees: 360), clockwise: true)
    return path

}
Copy the code

Angle of animation

This is a little bit of an animation. You need to use the tangent line to calculate the slope of the Angle, and then create a line based on that slope. As you move this line over the rectangle, draw a right triangle based on it. See the figure below. Colored lines represent the state of covering the entire rectangle as the line moves over time.

The method is as follows:

func angle(rect: CGRect, angle: Angle) -> Path {
        
    var cAngle = Angle(degrees: angle.degrees.truncatingRemainder(dividingBy: 90))

    // Return Path Using Other Animations (topToBottom, leftToRight, etc) if angle is 0, 90, 180, 270
    if angle.degrees == 0 || cAngle.degrees == 0 { return leftToRight(rect: rect)}
    else if angle.degrees == 90 || cAngle.degrees == 90 { return topToBottom(rect: rect)}
    else if angle.degrees == 180 || cAngle.degrees == 180 { return rightToLeft(rect: rect)}
    else if angle.degrees == 270 || cAngle.degrees == 270 { return bottomToTop(rect: rect)}


    // Calculate Slope of Line and inverse slope
    let m = CGFloat(tan(cAngle.radians))
    let m_1 = pow(m, -1) * -1
    let h = rect.height
    let w = rect.width

    // tan (angle) = slope of line
    // y = mx + b ---> b = y - mx ~ 'b' = y intercept
    let b = h - (m_1 * w) // b = y - (m * x)

    // X and Y coordinate calculation
    var x = b * m * progress
    var y = b * progress

    // Triangle Offset Calculation
    let xOffset = (angle.degrees > 90 && angle.degrees < 270)? rect.width :0
    let yOffset = (angle.degrees > 180 && angle.degrees < 360)? rect.height :0

    // Modify which side the triangle is drawn from depending on the angle
    if angle.degrees > 90 && angle.degrees < 180 { x *= -1 }
    else if angle.degrees > 180 && angle.degrees < 270 { x *= -1; y *= -1 }
    else if angle.degrees > 270 && angle.degrees < 360 { y *= -1 }

    // Build Triangle Path
    var path = Path()
    path.move(to: CGPoint(x: xOffset, y: yOffset))
    path.addLine(to: CGPoint(x: xOffset + x, y: yOffset))
    path.addLine(to: CGPoint(x: xOffset, y: yOffset + y))
    path.closeSubpath()
    return path

}
Copy the code

Please support me!

You can subscribe using this link. If you’re not reading this on TrailingClosure.com, stop by later!

If you find any mistakes in your translation or other areas that need to be improved, you are welcome to the Nuggets Translation Program to revise and PR your translation, and you can also get the corresponding reward points. The permanent link to this article at the beginning of this article is the MarkDown link to this article on GitHub.


The Nuggets Translation Project is a community that translates quality Internet technical articles from English sharing articles on nuggets. The content covers Android, iOS, front-end, back-end, blockchain, products, design, artificial intelligence and other fields. If you want to see more high-quality translation, please continue to pay attention to the Translation plan of Digging Gold, the official Weibo, Zhihu column.