##内容
タイマーアプリを作成するためのポイントを複数記事に分けて掲載しています。
この記事では、カウントダウンタイマーのメイン操作となるスタート、一時停止、リセット機能を実装するための手順を掲載します。
##環境
- OS: macOS 10.15.7 (Catalina)
- エディタ: Xcode 12.1
- 言語: Swift
- 主な使用ライブラリ: SwiftUI
##Gitリポジトリ
以下のGitリポジトリのURLからサンプルコードをご覧いただけます。
https://github.com/msnsk/Qiita_Timer.git
##手順
- TimeManager にタイマーのステータスを示すプロパティを作成する
- TimeManager にタイマーのステータスを変更するメソッドを作成する
- ButtonsView を作成する
- ButtonsView にスタート/ストップボタンを作成する
- ButtonsView にリセットボタンを作成する
- MainView に ButtonsView を配置する
###1. TimeManager にタイマーのステータスを示すプロパティを作成する
タイマーのステータスを表すプロパティを TimeManager クラスに作成しておきます。ステータスは以下の3つとします。
- running:タイマーがカウントダウン中の状態
- pause:タイマーが一時停止中の状態、再開可能
- stopped:タイマーがカウントダウン終了している状態
まず下ごしらえとして、Data.swift ファイルにタイマーのステータスを表す新しい enum を作成します。
//(他のenum省略)
enum TimerStatus {
case running
case pause
case stopped
}
作成した enum をデータ型として TimeManager クラスにプロパティを作成します。タイマーは使用者がスタートボタンをタップするまでは作動していない状態にする必要があるので、このプロパティのデフォルトの値は.stopped にしておきます。
class TimeManager: ObservableObject {
//(他のプロパティ省略)
//タイマーのステータス
@Published var timerStatus: TimerStatus = .stopped
//(メソッド省略)
###2. TimeManager にタイマーのステータスを変更するメソッドを作成する
まだボタンを作成していませんが、先にそれぞれのボタンをタップしたときに、タイマーをスタートしたり、一時停止したり、完全に終了させるメソッドをそれぞれ作成します。言い換えると、これらのメソッドにより先に作成したタイマーステータスのプロパティの値が都度変更される形になります。
class TimeManager: ObservableObject {
//(プロパティ省略)
//(他のメソッド省略)
//スタートボタンをタップしたときに発動するメソッド
func start() {
//タイマーステータスを.runningにする
timerStatus = .running
}
//一時停止ボタンをタップしたときに発動するメソッド
func pause() {
//タイマーステータスを.pauseにする
timerStatus = .pause
}
//リセットボタンをタップしたときに発動するメソッド
func reset() {
//タイマーステータスを.stoppedにする
timerStatus = .stopped
//残り時間がまだ0でなくても強制的に0にする
duration = 0
}
}
###3. ButtonsView を作成する
ButtonsView.swift という名前のファイルを新たに作成します。同名の struct が生成されます。
ボタン操作と手順1で作成した TimeManager クラスの timerStatus プロパティを連携する必要があるため、この View でも TimeManager クラスのインスタンスを作成しておきます。例によって、@EnvironmentObject のプロパティラッパーもつけておきます。
import SwiftUI
@EnvironmentObject var timeManager: TimeManager
struct ButtonsView: View {
var body: some View {
}
}
###4. ButtonsView にスタート/ストップボタンを作成する
スタートボタンとストップボタンは画面上同じ場所に表示することにします。
タイマーステータスによって、どちらのボタンを画面上に表示するかを条件分岐させます。
- .running の時は一時停止ボタンを表示
- .pause または .stopped の時はスタートボタンを表示
ボタンアイコンには、Apple 純正SF Symbols から以下2つを採用します(オーディオの再生、一時停止によく使われるアイコンです)。
- play.circle.fill: スタートボタン
- pause.circle.fill: 一時停止ボタン
残り時間の表示が 0 のときは、スタートも一時停止もできないことを示すため、透明度の .opacity モディファイアで条件分岐を作っておきます。
ボタンをタップしたときのアクションとして、PickerView が表示(そして時間設定)されている場合、Start ボタンをタップするとタイマーがセットされるように、setTimer メソッドを指定します。PickerView が表示されている時というのは、タイマーステータスが .stopped の時なので、これを onTapGesture モディファイアの中に if 文で記述します。
ボタンをタップしてこの setTimer メソッドが実行されると、残り時間の duration プロパティ、最大時間の maxValue プロパティに設定した時間が代入されます。この duration が 0 以外の状態、かつタイマーステータスが .running ではない状態の時 start メソッドも実行されるように onTapGesture モディファイアの中に if 文で記述します。
別の条件としてタイマーステータスが .running の時のみ一時停止できるように、先の if 文に今度はif else 文で続けて記述していきます。
struct ButtonsView: View {
@EnvironmentObject var timeManager: TimeManager
var body: some View {
//running: 一時停止ボタン/pause or stopped: スタートボタン
Image(systemName: self.timeManager.timerStatus == .running ? "pause.circle.fill" : "play.circle.fill")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 75, height: 75)
//ボタンの右側とスクリーンの端にスペースをとる
.padding(.trailing)
//Pickerの時間、分、秒がいずれも0だったらボタンの透明度を0.1に、そうでなければ1(不透明)に
.opacity(self.timeManager.hourSelection == 0 && self.timeManager.minSelection == 0 && self.timeManager.secSelection == 0 ? 0.1 : 1)
//ボタンをタップした時のアクション
.onTapGesture {
if timeManager.timerStatus == .stopped {
self.timeManager.setTimer()
}
//残り時間が0以外かつタイマーステータスが.running以外の場合
if timeManager.duration != 0 && timeManager.timerStatus != .running {
self.timeManager.start()
//タイマーステータスが.runningの場合
} else if timeManager.timerStatus == .running {
self.timeManager.pause()
}
}
}
}
###5. ButtonsView にリセットボタンを作成する
ButtonsView の中に、さらにリセットボタンを作成します。
このボタンをタップすると、手順2で作成し たTimeManager クラスの reset メソッドが発動します。
ボタンのアイコンには、SF Symbols の "stop.circle.fill" を採用しました。
タイマーステータスが .stopped 以外の場合にresetメソッドが発動するように、onTapGesture モディファイアの中に if 文で記述します。
ボタンのレイアウトについて、リセットボタンを画面の左、先に作成したスタート/一時停止ボタンを画面の右に配置したいので、HStack の中に両方のボタンを配置します。
デフォルトのままだと、どちらのボタンも配置が画面中央になるため、ボタンとボタンの間に Spacer を入れ、ボタンがスクリーン両端にそれぞれ寄るようにします。
ボタンをスクリーン端に寄せすぎると見栄えが悪いので、padding で調整しています。
struct ButtonsView: View {
@EnvironmentObject var timeManager: TimeManager
var body: some View {
//HStackで画面の左にリセットボタン、右にスタート/一時停止ボタン
HStack {
//リセットボタン
Image(systemName: "stop.circle.fill")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 75, height: 75)
//ボタンの左側とスクリーンの端にスペースをとる
.padding(.leading)
//タイマーステータスが終了なら透明度を0.1に、そうでなければ不透明に
.opacity(self.timeManager.timerStatus == .stopped ? 0.1 : 1)
//ボタンをタップしたときのアクション
.onTapGesture {
//タイマーステータスが.stopped以外の場合
if timeManager.timerStatus != .stopped {
self.timeManager.reset()
}
//ボタンとボタンの間隔をあける
Spacer()
//running: 一時停止ボタン/pause or stopped: スタートボタン
Image(systemName: self.timeManager.timerStatus == .running ? "pause.circle.fill" : "play.circle.fill")
//(モディファイア省略)
}
}
}
Canvas で ButtonsView がどのように表示されるか確認します。以下はプレビュー用のコードです。
struct ButtonsView_Previews: PreviewProvider {
static var previews: some View {
ButtonsView()
.environmentObject(TimeManager())
.previewLayout(.sizeThatFits)
}
}
下の画像のようになっています。タイマーステータスの初期値が .stopped なので、ボタンはグレーアウトしてタップしても反応がない状態として表示されています。
###6. MainView に ButtonsView を配置する
MainView にはすでに PickerView と TimerView が追加してありました。
タイマーステータスが.stopped かどうかで、どちらのViewが表示されるか変わるように if-else 文で記述しますします。
PickerView と TimerView の2つは、このタイマーアプリのもっとも重要なコンポーネントのため、画面中央に配置しておきます(配置を指定しなければデフォルトで水平、垂直方向で中央になります)。
iPhone など iOS デバイスの指での操作を考えると、今回追加したい ButtonsView は PickerView / TimerView より下、それもスクリーン下端に寄せる形で配置したいので、ZStack で PickerView / TimerView とはレイヤーを分ける形にし、VStack で ButtonsView の上に Spacer を配置することでButtonsViewを下端へ寄せます。ただしスクリーン端ギリギリだと見栄えが良くないため、padding(.bottom)モディファイアで微調整しておきます。
struct MainView: View {
@EnvironmentObject var timeManager: TimeManager
var body: some View {
ZStack {
if timeManager.timerStatus == .stopped {
PickerView()
} else {
TimerView()
}
VStack {
Spacer()
ButtonsView()
.padding(.bottom)
}
}
}
}
Canvas で MainViewの表示を確認します。プレビュー用のコードです。
struct MainView_Previews: PreviewProvider {
static var previews: some View {
Group {
MainView().environmentObject(TimeManager())
}
}
}
タイマーステータスの初期値が .stopped なので、if 文の条件分岐により、PickerView のみ表示され、ZStack で重なっている TimerView は非表示です。ButtonsView は VStack 内で Spacer に押されて、スクリーン下端に配置されています。
次回は残り時間のカウントダウン表示を実装していきます。