##内容
タイマーアプリを作成するためのポイントを複数記事に分けて掲載しています。
この記事では、カウントダウンタイマーの残り時間を示す円形のプログレスバーの作成について掲載します。
##環境
- OS: macOS 10.15.7 (Catalina)
- エディタ: Xcode 12.1
- 言語: Swift
- 主な使用ライブラリ: SwiftUI
##Gitリポジトリ
以下のGitリポジトリのURLからサンプルコードをご覧いただけます。
https://github.com/msnsk/Qiita_Timer.git
##手順
- プログレスバーの View を作成する
- プログレスバーの背景用の円を作成する
- プログレスバー用の円を作成する
- プログレスバーの長さを経過時間に連動させる
- MainView にプログレスバーを配置する
- プログレスバーの動きを滑らかにする
###1. プログレスバーの View を作成する
ProgressBar.swift という名前で新規ファイルを作成します。この View でも TimeManager クラスからプロパティの値を参照しますので、var の前に @EnvironmentObject プロパティラッパーをつけて TimeManager クラスのインスタンスを作成しておきます。
import SwiftUI
struct ProgressBarView: View {
@EnvironmentObject var timeManager: TimeManager
var body: some View {
Text("Hello, World!")
}
}
###2. プログレスバーの背景用の円を作成する
プログレスバーは、時間経過とともに短くなっていく円と、その背景となる円の2つが必要です。背景の円は、時間が経過してもずっと同じ大きさで長さが変化しません。まず先に、背景の円から作成していきます。
body{} の中に円を配置します。SwiftUI には Circle() という図形のコンポーネントが用意されていますので、これを利用します。
struct ProgressBarView: View {
@EnvironmentObject var timeManager: TimeManager
var body: some View {
Circle()
}
}
図形は、輪郭線と面で構成されていますので、中空の円にするには、面を表示せず、輪郭線だけ表示し、その輪郭線の太さや長さ、色を調整します。
Circle() のモディファイアを追加して望んだ形状にしていきます。
.stroke モディファイアで、引数に Color() を入れて、さらにその引数を .darkGray にして、背景らしいグレーの色にします。
.stroke モディファイアで、style 引数の lineWidth を 20 にしてプログレスバーの太さを指定します。
.scaledToFit モディファイアで、円のサイズをスクリーンサイズいっぱいに合わせ、 .padding モディファイアでスクリーン端との余白を調整します。
struct ProgressBarView: View {
@EnvironmentObject var timeManager: TimeManager
var body: some View {
Circle()
.stroke(Color(.darkGray), style: StrokeStyle(lineWidth: 20))
.scaledToFit()
.padding(25)
}
}
###3. プログレスバー用の円を作成する
これから作成するプログレスバー用の円は、手順2で作成した背景用の円とレイヤー状に重なるので、プログレスバー用の Circle() コンポーネントをもう一つ追加したら、 ZStack{} で2つの円を囲います。
struct ProgressBarView: View {
@EnvironmentObject var timeManager: TimeManager
var body: some View {
ZStack {
//背景用の円
Circle()
.stroke(Color(.darkGray), style: StrokeStyle(lineWidth: 20))
.scaledToFit()
.padding(15)
//プログレスバー用の円
Circle()
}
}
}
背景用の円と同様に、プログレスバー用の円もモディファイアを追加して形状を調整していきます。
.stroke モディファイアで、引数にColor() を入れ、プログレスバーの色をひとまず .cyan を指定しました。
.stroke モディファイアで、style 引数の StrokeStyle に細かな指定を入れます。lineWidth で幅を 20 に指定、lineCap で .round を指定して線の端の角に丸みを出し、lineJoin で .round を指定して線の端を線幅の1/2の長さで超えて丸みを出します。
.rotationEffect モディファイアを追加し、引数に Angle(degrees: -90) と入れます。これにより、デフォルトの円の輪郭線の開始位置が3時の方向であるのを、12時の方向に変更できます。
その他は背景用の円と同様です。
struct ProgressBarView: View {
@EnvironmentObject var timeManager: TimeManager
var body: some View {
ZStack {
//背景用の円
Circle()
.stroke(Color(.darkGray), style: StrokeStyle(lineWidth: 20))
.scaledToFit()
.padding(15)
//プログレスバー用の円
Circle()
.stroke(Color(.cyan), style: StrokeStyle(lineWidth: 20, lineCap: .round, lineJoin: .bevel))
.scaledToFit()
//輪郭線の開始位置を12時の方向にする
.rotationEffect(Angle(degrees: -90))
.padding(15)
}
}
}
###4. プログレスバーの長さを経過時間に連動させる
プログレスバーの円に、さらにモディファイアを追加して、カウントダウンタイマーの経過時間に連動してプログレスバーが短くなっていくようにします。
.trim モディファイアを追加します。これにより、プログレスバーを必要な長さにトリムすることができます。
開始位置はいつでも12時の方向で固定ですので、引数 from には 0 を入れます。時間経過とともにプログレスバーは短くなっていく必要があるので、終端位置を常に残り時間と連動させる必要があります。また、引数 from も to も入れる値は 0 ~ 1 の間である必要があります。
ここで少し算数です。TimeManager クラスには、Picker で設定した最大時間を格納するプロパティ maxValue と 残り時間を格納するプロパティ duration が用意してあります。この2つの値を使って、カウントダウン開始時に最大値 1 カウントダウン終了時に最小値 0 になる計算式は duration / maxValue です。
引数 to の値のデータ型は CGFloat する必要がありますので、最終的に引数 to に入れる値は以下になります。
CGFloat(self.timeManager.duration / self.timeManager.maxValue)
よって、コードは以下のようになります。
struct ProgressBarView: View {
@EnvironmentObject var timeManager: TimeManager
var body: some View {
ZStack {
//背景用の円
Circle()
//(モディファイア省略)
//プログレスバー用の円
Circle()
.trim(from: 0, to: CGFloat(self.timeManager.duration / self.timeManager.maxValue))
.stroke(Color(.cyan), style: StrokeStyle(lineWidth: 20, lineCap: .round, lineJoin: .round))
.scaledToFit()
//輪郭線の開始位置を12時の方向にする
.rotationEffect(Angle(degrees: -90))
.padding(15)
}
}
}
Canvas で ProgressBarView を確認します。以下はプレビュー用のコードです。
struct ProgressBarView_Previews: PreviewProvider {
static var previews: some View {
ProgressBarView()
.environmentObject(TimeManager())
.previewLayout(.sizeThatFits)
}
}
###5. MainView にプログレスバーを配置する
MainView の body{} 内一番外側の ZStack{} の一番上に ProgressBarView のインスタンスを追加します。ZStack のコード上一番上ということは、UI コンポーネントのレイヤー階層では一番後ろになります。イメージとしては、残り時間の表示や時間設定のPickerのほうが、画面上、手前にある状態です。
また、先に 設定画面である SettingView の項目に、プログレスバーの表示/非表示のトグルスイッチを用意していますので、その設定と連動している TimeManager クラスの isProgressBaron プロパティが true の場合だけプログレスバーを表示するように if 文で記述します。
struct MainView: View {
@EnvironmentObject var timeManager: TimeManager
var body: some View {
ZStack {
if timeManager.isProgressBarOn {
ProgressBarView()
}
if timeManager.timerStatus == .stopped {
PickerView()
} else {
TimerView()
}
VStack {
Spacer()
ZStack {
ButtonsView()
.padding(.bottom)
SettingButtonView()
.padding(.bottom)
.sheet(isPresented: $timeManager.isSetting) {
SettingView()
.environmentObject(self.timeManager)
}
}
}
}
.onReceive(timeManager.timer) { _ in
//(onReceive の中の記述省略)
}
}
}
Canvas で MainView を確認します。下の画像のようになっているはずです。
###6. プログレスバーの動きを滑らかにする
プログレスバーは実装できましたが、Xcode Canvas や Simulator で MainView の実際の動きを確認してみると、タイマーの設定時間が短いほど、プログレスバーが1秒毎に短くなる(カクカクした)動きがよくわかると思います。プログレスバーとして失敗ではありませんが、視覚的に滑らかなほうが洗練された印象を受けますので、ここを少し拘って修正していきます。
このカクカクした動きの原因は2つありますので、それぞれ修正していきます。
まず1つめは、TimeManager クラスの timer プロパティです。このプロパティには、Timer クラスの publish メソッドが格納されていますが、その引数 every の値が 1 になっています。これは1秒毎に発動するという意味になります。この値を 0.05 くらいに変更しておきます。検証の結果 0.01 を切るあたりから、現実の時間経過とタイマーアプリの残り時間の更新に誤差が出てくるので、0.05 くらいが限界かと思います。
TimeManager クラスで以下のように更新します。
class TimeManager: ObservableObject {
//(他のプロパティ省略)
//1秒ごとに発動するTimerクラスのpublishメソッド
var timer = Timer.publish(every: 0.05, on: .main, in: .common).autoconnect()
//(メソッド省略)
2つ目は、MainView の onReceive モディファイア内の記述です。このモディファイアが先に修正した TimeManager クラスの timer プロパティをトリガーにして、クロージャ {} 内のコードを実行しています。トリガーを 0.05 秒更新にしたので、onReceive モディファイアのクロージャ {} 内に記述された TimeManager クラスの duration プロパティ(残り時間)の更新もまた 0.05 秒ずつマイナスされる必要があります。
MainView で以下のように更新します。
struct MainView: View {
@EnvironmentObject var timeManager: TimeManager
var body: some View {
ZStack {
//(省略)
}
//指定した時間(1秒)ごとに発動するtimerをトリガーにしてクロージャ内のコードを実行
.onReceive(timeManager.timer) { _ in
//タイマーステータスが.running以外の場合何も実行しない
guard self.timeManager.timerStatus == .running else { return }
//残り時間が0より大きい場合
if self.timeManager.duration > 0 {
//残り時間から -0.05 する
self.timeManager.duration -= 0.05 //ここを更新!
//残り時間が0以下の場合
} else {
//タイマーステータスを.stoppedに変更する
self.timeManager.timerStatus = .stopped
//アラーム音を鳴らす
AudioServicesPlayAlertSoundWithCompletion(self.timeManager.soundID, nil)
//バイブレーションを作動させる
AudioServicesPlayAlertSoundWithCompletion(SystemSoundID(kSystemSoundID_Vibrate)) {}
}
}
}
}
これで、例えばタイマーを5秒に設定してもプログレスバーが比較的滑らかな動きをしてくれます。
次回は、少しおまけ的要素ですが、プログレスバーの色をより美しく表示していきます。