##記事
タイマーアプリを作成するためのポイントを複数記事に分けて掲載しています。
この記事では、カウントダウンタイマーを視覚的に楽しくするためのアニメーションの作成方法について掲載します。
##環境
- OS: macOS 10.15.7 (Catalina)
- エディタ: Xcode 12.1
- 言語: Swift
- 主な使用ライブラリ: SwiftUI
##Gitリポジトリ
以下のGitリポジトリのURLからサンプルコードをご覧いただけます。
https://github.com/msnsk/Qiita_Timer.git
##手順
- どのようなアニメーションにするか考える
- アニメーションの View を作成する
- アニメーションさせる図形を用意する
- 図形の色を指定し、常に変化させる
- 図形を動かすアニメーションを適用する
- MainView にアニメーションの View を配置する
###1. どのようなアニメーションにするか考える
現時点での MainView を見ると、TimerView / PickerView と ProgressBarView の間にやや隙間があるので、その空きスペースで図形をアニメーションさせて、視覚的に楽しくしようと思います。
具体的な動きとしては、小さめの円を二つ用意し、それを 12 時の方向にプログレスバー内側に接するような位置に配置し、数秒かけて、プログレスバーに沿ってぐるりと一周回る、というものにします。二つの円はそれぞれ時計回り、反時計回りに動きます。そして1周回るとまた 12 時の方向でぴったり重なります。
また2つの円の透明度を 0.5 にして、重なった時に色が混ざって透明度が 1 になるようにします。2つの円の色も、プログレスバーと同様に、常に変化させます。
###2. アニメーションの View を作成する
AnimationView という名前で新規ファイルを作成します。Animation 構造体のプロパティには、TimeManager クラスのインスタンスを用意します。そのプロパティの値を常に参照するので、頭に @EnvironmentObject プロパティラッパーをつけます。
また、アニメーションで図形を配置する際に、スクリーンサイズを基準に位置を指定するので、事前にスクリーンサイズのプロパティを用意しておきます(ここでは幅のほうしか使わないですが、デバイスを横向きにした時にレイアウトを調整する場合は高さも必要です)。
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. アニメーションさせる図形を用意する
これから作成していくアニメーションは2つの円を動かします。
Circle() コンポーネントを body{} 内に2つ追加します。これらを ZStack{} でレイヤー上に重ねます。どちらが上でも構いません。
.frame() モディファイアで、サイズを 20 に指定します。かなり小さめの円です。
.offset() モディファイアは、配置位置を垂直方向に上へずらします。ここで、先に用意していたスクリーンの幅を格納するプロパティ screenWidth を基準に、垂直方向(y)に上へずらします。上にずらす場合は、負の値になります。反対に下へずらしたい時は正の値になります。ずらす長さはスクリーン幅 x 0.38 としました。これで画面中央より上、だいたいプログレスバーの少し内側に円が配置されます。
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. 図形の色を指定し、常に変化させる
次に、2つの円の色相を指定するプロパティを用意します。初期値はそれぞれ 0.5、0.3とします。
@State var costomHueA: Double = 0.5
@State var costomHueB: Double = 0.3
.foregroundColor モディファイアにて、上記色相プロパティの値を使用しつつ、hue(色相)、saturation(彩度)、brightness(明度)、opacity(透明度)を指定する形で、円に色をつけます。まず、引数 hue には、上記プロパティを入れます。そして、opacity はどちらの円も 0.5 にして半透明にするため、彩度、明度ともに最大値の 1 を指定しています。
//1つ目のCircleに対して
.foregroundColor(Color(hue: self.costomHueA, saturation: 1.0, brightness: 1.0, opacity: 0.5))
//2つ目のCircleに対して
.foregroundColor(Color(hue: self.costomHueB, saturation: 1.0, brightness: 1.0, opacity: 0.5))
そして、常に色を変化させるには、プロパティの costomHueA / B の値を常に変更し続ける必要があります。これは ProgressBarView 作成時にもやりましたが、 onReceive モディファイアで、TimeManager クラスの timer プロパティ(Timerクラスのpublishメソッド)をトリガーにすれば実現できます。
body{} 内の2つの Circle コンポーネントを格納している ZStack{} に対して onReceive モディファイアにを作成します。
そして、Timer.publish メソッドが 0.05 秒ごとに発動すると同時に、costomHueA / B の値も + 0.05 されるようにします。 色を作成する Color 構造体の引数 hue は 0 ~ 1 の Double の値しかとらないので、costomHueA / B がそれぞれ 1.0 になったら、0.0 に戻るように、if文で追加します。
.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
}
}
この時点で、コード全体は以下のようになっています。
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. 図形を動かすアニメーションを適用する
2つの円に、アニメーションに関わるモディファイアを追加していきます。
rotationEffect モディファイアで円を回転させます。この回転の角度によって、先に追加済みの offset モディファイアでずれる円の位置も、画面中央を軸に同じ角度だけ変わります。
よって、2つの円を円形プログレスバーに沿って移動させたい場合、rotationEffect の引数 degrees の () 内に、アニメーション開始時と終了時の2パターンの角度を指定し、一定間隔で、その2つを切り替えることによって、切り替え前と後の間の角度の値の変化が生じて、その変化がアニメーションで再現されます。結果的に2つの円が行ったり来たりするアニメーションになります。
ここでは、360°一周ぐるりと移動するアニメーションにしたいので、0°と360°を指定します。その角度の切り替えのトリガーとして、プロパティ clockwise を bool 型で用意します。初期値は true にしておきます。
@State var clockwise = true
プロパティ clockwise が true だったら円の回転は 0°、false だったら回転は360° となっています。一方、もう一つの Circle コンポーネントは逆にして、 clockwise が true だったら 360°、false だったら 0° にします。これにより、2つの円がお互いに逆向きに回転する動きになります。
//1つ目のCircleに対して
.rotationEffect(.degrees(clockwise ? 0 : 360))
//2つ目のCircleに対して
.rotationEffect(.degrees(clockwise ? 360 : 0))
rotationEffect モディファイアの下に animation モディファイアを追加します。これにより、animation モディファイアより上のモディファイアに対してアニメーションが適用されます。
アニメーションの種類はいくつかありますが、最初ゆっくり、真ん中速く、最後もゆっくり、という動きにしたいので、ここでは easeInOut を animation モディファイアの引数として適用します。そして、easeInOut の引数 duration には 5 を入れます。これは5秒かけて一回のアニメーションを実行するという意味です。
.animation(.easeInOut(duration: 5))
ただし、timer プロパティの発動間隔は毎0.05秒とかなり短いので、別のcount プロパティを Double 型で作成します。この AnimationView 構造体の中でだけ常に参照するので、頭に @State プロパティラッパーをつけています。
@State var count: Double = 0
手順4で作成済みの onReceive モディファイアの中に、まず count プロパティが 0 のときにclockwise の値が toggle で切り替わるようにします。すなわち、count が 0 のときにアニメーションが開始するようにします。
.onReceive(timeManager.timer) { _ in
if self.count <= 0 {
self.clockwise.toggle()
}
//(costomHue関連のコード省略)
}
さらに、時間経過とともに count プロパティの値を変化させるコードを記述します。
Timer.publish メソッドが 0.05 秒ごとに発動するのと一緒に、count プロパティの値も 0.05 秒ずつインクリメントさせます。そして、count プロパティの値が 5 になったら、count プロパティの値をまた 0 に戻すようにします。
.onReceive(timeManager.timer) { _ in
if self.count <= 0 {
self.clockwise.toggle()
}
if self.count < 5.00 {
self.count += 0.05
} else {
self.count = 0
}
//(costomHue関連のコード省略)
}
これで 5 秒ごとに clockwise プロパティが toggle されて、アニメーションが発動します。アニメーション1回の所要時間も5秒にしているので、途切れることなく 5 秒毎にアニメーションが繰り返されるようになります。
今、コード全体は以下のようになっています。これで AnimationView の完成です。
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
}
}
}
}
Canvas で AnimationView を確認します。下の gif 画像のようになっているはずです。
###6. MainView にアニメーションの View を配置する
それでは完成した AnimationView を MainView に配置していきます。
アニメーションの表示条件として、タイマーのステータスが .stopped 以外(.running または .pause)の時のみにします。
また、先に作成済みの SettingView の設定項目の中に Effect Animation のオン/オフのトグルスイッチがあります。ですので、このトグルスイッチに連動する TimeManager クラスの isEffectAnimationOn が true の時のみアニメーションが表示されるようにします。
上記2つを条件として if 文で記述します。
if timeManager.isEffectAnimationOn && timeManager.timerStatus != .stopped {
AnimationView()
}
また、コード内の AnimationView の配置は、レイヤー上、 上プログレスバーより後ろにしたいので、ZStack{} の冒頭に記述します。
最終的に、MainView のコードは以下のようになります。
struct MainView: View {
@EnvironmentObject var timeManager: TimeManager
var body: some View {
ZStack {
if timeManager.isEffectAnimationOn && timeManager.timerStatus != .stopped {
AnimationView()
}
if timeManager.isProgressBarOn {
ProgressBarView()
}
//(他のView省略)
}
.onReceive(timeManager.timer) { _ in
//(省略)
}
}
}
Canvas で MainView を確認します。下の画像のようになります。
##さいごに
この「iOSアプリ開発:タイマーアプリ」シリーズは、ここまでの全10記事で完結となります。
アプリ内のボタンやテキスト、背景の色は全然指定していないので、色味に欠けるかもしれません。もしこのシリーズの記事をご参考にされる場合は、是非、そのあたりをご自身の手でカスタマイズしてみていただければと思います。