iOS app development: Timer app (10. Create animation)

スクリーンショット 2020-10-28 11.35.01.png

article

The points for creating a timer app are posted in multiple articles. In this article, I'll show you how to create an animation to make the countdown timer visually pleasing.

environment

Git repository

You can see the sample code from the URL of the Git repository below. https://github.com/msnsk/Qiita_Timer.git

procedure

  1. Think about what kind of animation you want
  2. Create an animated View
  3. Prepare a shape to animate
  4. Specify the color of the shape and change it constantly
  5. Apply animation to move shapes
  6. Place the animated View on the MainView

1. Think about what kind of animation you want

Looking at the MainView at the moment, there is a slight gap between the TimerView / PickerView and the ProgressBarView, so I'd like to animate the shape in that empty space to make it visually pleasing.

As a concrete movement, prepare two small circles, place them at a position that touches the inside of the progress bar at 12 o'clock, and take a few seconds to go around along the progress bar. I will make it. The two circles move clockwise and counterclockwise, respectively. And after one lap, it will overlap exactly at 12 o'clock again.

Also, set the transparency of the two circles to 0.5 so that the colors mix and the transparency becomes 1 when they overlap. The color of the two circles, like the progress bar, is constantly changing.

2. Create an animated View

Create a new file with the name AnimationView. For the properties of the Animation structure, prepare an instance of the TimeManager class. Prefix it with the @EnvironmentObject property wrapper because it always references the value of that property.

Also, when arranging the shape in the animation, the position is specified based on the screen size, so prepare the screen size property in advance (here, only the width is used, but the device is turned sideways. If you want to adjust the layout when you do, you also need the height).

AnimationView.swift


import SwiftUI

struct AnimationView: View {
    @EnvironmentObject var timeManager: TimeManager
    
    let screenWidth = UIScreen.main.bounds.width
    let screenHeight = UIScreen.main.bounds.height
    
    var body: some View {

    }
}

3. Prepare a shape to animate

The animation you are about to create will move two circles.

Add two Circle () components inside the body {}. Layer these on a layer with ZStack {}. It doesn't matter which one is on top.

In the .frame () modifier, specify a size of 20. It's a fairly small circle.

The .offset () modifier shifts the placement position vertically upwards. Now, move it up in the vertical direction (y) based on the property screenWidth that stores the width of the screen prepared earlier. If you move it up, it will be a negative value. On the contrary, when you want to shift it downward, it becomes a positive value. The length of the shift is the screen width x 0.38. This will place a circle above the center of the screen, roughly inside the progress bar.

AnimationView.swift


struct AnimationView: View {
    @EnvironmentObject var timeManager: TimeManager
    
    let screenWidth = UIScreen.main.bounds.width
    let screenHeight = UIScreen.main.bounds.height
    
    var body: some View {
        ZStack{
            Circle()
                .frame(width: 20, height: 20)
                .offset(y: -self.screenWidth * 0.38)
            
            Circle()
                .frame(width: 20, height: 20)
                .offset(y: -self.screenWidth * 0.38)
        }
    }
}

4. Specify the color of the shape and change it constantly

Next, prepare a property that specifies the hue of the two circles. The initial values are 0.5 and 0.3, respectively.

    @State var costomHueA: Double = 0.5
    @State var costomHueB: Double = 0.3

In the .foregroundColor modifier, use the above hue property values to color the circle by specifying hue, saturation, brightness, and opacity. First, put the above properties in the argument hue. And since opacity makes both circles 0.5 and translucent, we specify a maximum of 1 for both saturation and brightness.

//For the first Circle
.foregroundColor(Color(hue: self.costomHueA, saturation: 1.0, brightness: 1.0, opacity: 0.5))
//For the second Circle
.foregroundColor(Color(hue: self.costomHueB, saturation: 1.0, brightness: 1.0, opacity: 0.5))

And, in order to constantly change the color, it is necessary to constantly change the value of costomHueA / B of the property. I did this when I created the ProgressBarView, but I can do this with the onReceive modifier, triggered by the timer property of the TimeManager class (the publish method of the Timer class).

Create an onReceive modifier for the ZStack {} that contains the two Circle components in the body {}.

Then, at the same time that the Timer.publish method is activated every 0.05 seconds, the value of costomHueA / B is also increased by +0.05. Since the argument hue of the Color structure that creates the color takes only a value of Double from 0 to 1, add it with an if statement so that when costomHueA / B becomes 1.0 respectively, it returns to 0.0.

.onReceive(timeManager.timer) { _ in
    self.costomHueA += 0.005
    if self.costomHueA >= 1.0 {
        self.costomHueA = 0.0
    }
    self.costomHueB += 0.005
    if self.costomHueB >= 1.0 {
        self.costomHueB = 0.0
    }
}

At this point, the whole code looks like this:

AnimationView.swift


struct AnimationView: View {
    @EnvironmentObject var timeManager: TimeManager
    @State var costomHueA: Double = 0.5
    @State var costomHueB: Double = 0.3

    let screenWidth = UIScreen.main.bounds.width
    let screenHeight = UIScreen.main.bounds.height
    
    var body: some View {
        ZStack{
            Circle()
                .frame(width: 20, height: 20)
                .offset(y: -self.screenWidth * 0.38)
                .foregroundColor(Color(hue: self.costomHueA, saturation: 1.0, brightness: 1.0, opacity: 0.5))
         
            Circle()
                .frame(width: 20, height: 20)
                .offset(y: -self.screenWidth * 0.38)
                .foregroundColor(Color(hue: self.costomHueB, saturation: 1.0, brightness: 1.0, opacity: 0.5))
        }
        .onReceive(timeManager.timer) { _ in
            self.costomHueA += 0.005
            if self.costomHueA >= 1.0 {
                self.costomHueA = 0.0
            }
            self.costomHueB += 0.005
            if self.costomHueB >= 1.0 {
                self.costomHueB = 0.0
            }
        }
    }
}

5. Apply animation to move shapes

We will add modifiers related to animation to the two circles.

rotate the circle with the rotationEffect modifier. Depending on the angle of this rotation, the position of the circle shifted by the offset modifier added earlier will also change by the same angle around the center of the screen.

Therefore, if you want to move two circles along a circular progress bar, specify the angles of the two patterns at the start and end of the animation in () of the argument degrees of rotationEffect, and move the two at regular intervals. Switching causes a change in the angle value between before and after the switch, and the change is reproduced in the animation. The result is an animation of two circles moving back and forth.

Here, we want to make an animation that moves around 360 °, so specify 0 ° and 360 °. Prepare the property clockwise as a bool type as a trigger to switch the angle. Leave the initial value as true.

@State var clockwise = true

If the property clockwise is true, the rotation of the circle is 0 °, and if it is false, the rotation is 360 °. On the other hand, the other Circle component is reversed so that if clockwise is true it is 360 ° and if it is false it is 0 °. This causes the two circles to rotate in opposite directions.

//For the first Circle
.rotationEffect(.degrees(clockwise ? 0 : 360))
//For the second Circle
.rotationEffect(.degrees(clockwise ? 360 : 0))

Add an animation modifier under the rotationEffect modifier. This will apply the animation to modifiers above the animation modifier.

There are several types of animation, but since we want the movement to be slow at the beginning, fast in the middle, and slow at the end, we apply easeInOut as an argument to the animation modifier. Then, put 5 in the argument duration of easeInOut. This means running one animation over 5 seconds.

.animation(.easeInOut(duration: 5))

However, the timer property is activated at a fairly short interval of 0.05 seconds per second, so create another count property of type Double. It's always referenced only within this AnimationView struct, so it's prefixed with the @State property wrapper.

@State var count: Double = 0

In the onReceive modifier created in step 4, first make sure that the value of clockwise is toggled when the count property is 0. That is, make sure the animation starts when count is 0.

.onReceive(timeManager.timer) { _ in
    if self.count <= 0 {
        self.clockwise.toggle()
    }

    //(Omitted code related to costomHue)
}

In addition, write code that changes the value of the count property over time.

As the Timer.publish method fires every 0.05 seconds, it also increments the value of the count property by 0.05 seconds. Then, when the value of the count property reaches 5, the value of the count property should be returned to 0 again.

.onReceive(timeManager.timer) { _ in
    if self.count <= 0 {
        self.clockwise.toggle()
    }
    if self.count < 5.00 {
        self.count += 0.05
    } else {
        self.count = 0
    }
    //(Omitted code related to costomHue)
}

This will toggle the clockwise property every 5 seconds and trigger the animation. Since the time required for each animation is also set to 5 seconds, the animation will be repeated every 5 seconds without interruption.

Now the whole code looks like this: This completes the AnimationView.

AnimationView.swift


struct AnimationView: View {
    @EnvironmentObject var timeManager: TimeManager
    @State var costomHueA: Double = 0.5
    @State var costomHueB: Double = 0.3
    @State var clockwise = true

    let screenWidth = UIScreen.main.bounds.width
    let screenHeight = UIScreen.main.bounds.height
    
    var body: some View {
        ZStack{
            Circle()
                .frame(width: 20, height: 20)
                .offset(y: -self.screenWidth * 0.38)
                .foregroundColor(Color(hue: self.costomHueA, saturation: 1.0, brightness: 1.0, opacity: 0.5))
                .rotationEffect(.degrees(clockwise ? 0 : 360))
                .animation(.easeInOut(duration: 5))
            
            Circle()
                .frame(width: 20, height: 20)
                .offset(y: -self.screenWidth * 0.38)
                .foregroundColor(Color(hue: self.costomHueB, saturation: 1.0, brightness: 1.0, opacity: 0.5))
                .rotationEffect(.degrees(clockwise ? 360 : 0))
                .animation(.easeInOut(duration: 5))
        }
        .onReceive(timeManager.timer) { _ in
            if self.count <= 0 {
                self.clockwise.toggle()
            }
            if self.count < 5.00 {
                self.count += 0.05
            } else {
                self.count = 0
            }
            
            self.costomHueA += 0.005
            if self.costomHueA >= 1.0 {
                self.costomHueA = 0.0
            }
            self.costomHueB += 0.005
            if self.costomHueB >= 1.0 {
                self.costomHueB = 0.0
            }
        }
    }
}

Check the AnimationView on Canvas. It should look like the gif image below. 5p2wx-8k3l6.gif

6. Place the animated View on the MainView

Now let's place the completed AnimationView in the MainView.

The animation display condition is set only when the timer status is other than .stopped (.running or .pause).

In addition, there is an on / off toggle switch for Effect Animation in the setting items of the SettingView created earlier. Therefore, the animation should be displayed only when isEffectAnimationOn of the TimeManager class linked to this toggle switch is true.

Describe with an if statement under the above two conditions.

if timeManager.isEffectAnimationOn && timeManager.timerStatus != .stopped {
    AnimationView()
}

Also, I want to place the AnimationView in the code on the layer, after the upper progress bar, so I will write it at the beginning of ZStack {}.

Finally, the code for MainView looks like this:

MainView


struct MainView: View {
    @EnvironmentObject var timeManager: TimeManager
    
    var body: some View {
        ZStack {
            if timeManager.isEffectAnimationOn && timeManager.timerStatus != .stopped {
                AnimationView()
            }
            
            if timeManager.isProgressBarOn {
                ProgressBarView()
            }
            
            //(Other views omitted)
        }
        .onReceive(timeManager.timer) { _ in
            //(abridgement)
        }
    }
}

Check the MainView in Canvas. It should look like the image below. スクリーンショット 2020-10-28 11.44.42.png

finally

This "iOS App Development: Timer App" series is completed with all 10 articles so far.

I haven't specified any buttons, text, or background colors in the app, so it may lack color. If you would like to refer to the articles in this series, please try to customize it by yourself.

Recommended Posts

iOS app development: Timer app (10. Create animation)
iOS app development: Timer app (2. Timer display)
iOS app development: Timer app (summary)
iOS app development: Timer app (1. Timer time setting)
iOS app development: Timer app (3. Start / Stop button, Reset button)
iOS app development: Timer app (7. Implementation of alarm sound selection)
iOS app development: Timer app (5. Implementation of alarm and vibration)
iOS app development: Timer app (9. Customize the color of the progress bar)
iOS App Development Skill Roadmap (Introduction)
Error Treatment Techniques Hit in iOS App Development ~ Xcode Edition ~
iOS engineer starts Android development
ROS app development on Android
ATDD development on iOS (basic)
NoCode Glide's first app development