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.
You can see the sample code from the URL of the Git repository below. https://github.com/msnsk/Qiita_Timer.git
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.
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 {
}
}
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)
}
}
}
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
}
}
}
}
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.
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.
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