[SwiftUI]カウントダウンを一時停止機能を含め実装する

目次

概要

Swiftでカウントダウンを行う場合は、Timer.scheduledTimerメソッドがあるが、
今回はTimer.publishを使用する。
そして、「一時停止」と「一時停止解除」は意外となかったため、ここでメモしておく。

ソースコード

import SwiftUI

@main
struct TestApp: App {
    @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
    
    var body: some Scene {
        WindowGroup {
            TimerView()
        }
    }
}

処理部分

import Foundation
import Combine

class TimerViewModel: NSObject, ObservableObject {
    // カウントダウン処理を行う処理の頻度
    private var countDownProcessInterval: TimeInterval = 0.001
    // タイマー
    private var timer: AnyCancellable?
    // タイムリミットの時刻
    private var futureDate: Date = Date()
    
    // 残り時間
    @Published var timeLimit: Double
    // カウントダウンのON/OFF
    @Published var isCountDown: Bool = false
    
    init(defaultTimeLimit: Int) {
        self.timeLimit = Double(defaultTimeLimit)
    }
    
    // 残り時間の表示を更新する
    func updateTime() {
        let remin = Calendar.current.dateComponents([.second, .nanosecond], from: Date(), to: futureDate)
        let second = Double(remin.second ?? 0)
        let nanosecond = Double(remin.nanosecond ?? 0)
        // nanosecondは9桁の整数のため、10の9乗で割る。そのあとsecondに足す。
        self.timeLimit = second + nanosecond/pow(10, 9)
    }
    
    // タイマーを開始する
    func startTimer() {
        self.isCountDown = true
        // countDownProcessIntervalの頻度でupdateTimeメソッドの処理を行う
        self.timer = Timer.publish(every: countDownProcessInterval,
                                   on: .main,
                                   in: .common).autoconnect()
            .sink(receiveValue: { _ in
                self.updateTime()
            })
        // 現在時刻+残り時間をタイムリミットに設定する(こうすることで、タイマーの一時停止ができる)
        self.futureDate = Calendar.current.date(byAdding: .second, value: Int(self.timeLimit), to: Date()) ?? Date()
        let nanosecond = self.timeLimit.truncatingRemainder(dividingBy: 1)
        self.futureDate = self.futureDate.addingTimeInterval(nanosecond)
    }
    
    // タイマーを一時停止する(処理上はカウントダウン処理を取り消す)
    func pauseTimer() {
        self.timer?.cancel()
    }
    
    // タイマーを止める
    func stopTimer() {
        self.timer = nil
    }
}

View表示部分

import SwiftUI

struct TimerView: View {
    
    @ObservedObject var viewModel: TimerViewModel
    
    init() {
        self.viewModel = TimerViewModel(defaultTimeLimit: 60)
    }
    
    var body: some View {
        VStack (alignment: .center) {
            Spacer()
            Text("\(String(format: "%.3f", self.viewModel.timeLimit))")
                .frame(width: 200, alignment: Alignment.leading)
                .font(.system(size: 60))
                .onChange(of: self.viewModel.isCountDown) { isCountDown in
                    if isCountDown {
                        self.viewModel.startTimer()
                    } else {
                        self.viewModel.pauseTimer()
                    }
                }
            Spacer()
            Toggle("開始/停止", isOn: self.$viewModel.isCountDown)
                .frame(width: 180)
            Spacer()
            Button {
                self.viewModel.timeLimit = 60
            } label: {
                Text("リセット")
            }
            Spacer()
        }
    }
}

デモ動画

詳細

ここでのポイントは以下だと思う
・UI表示(ここはタイマーの処理とは関係ない)
・指定した時間ごとに特定の処理を行う(Timer.publishメソッドの使用)
・一時停止(self.timer?.cancel()の使用)
・タイマーをリセット(変数timeLimitの初期化処理)

ここまでは調べればすぐに見つかるところだと思う。
問題は以下。
・一時停止
・一時停止の解除

一時停止

一時停止は、単純にタイマーを止める処理を行なっている
self.timer?.cancelメソッドを使用しているが、これは通常ならタイマーの処理を終了する。
でも、ここで残り時間の表示をself.timer?.cancelメソッドを実行したままにしておけば、
いわば「永遠に一時停止している状態になる」
ここから一時停止を解除するのが肝

一時停止の解除

このままタイマーを再開しても、ただタイマーを再開させるだけではfutureDateがタイマー開始から60秒の時刻なので、
残り時間がマイナスになってしまう。
そこで、futureDateをタイマーの一時解除を行った時刻から残り時間を足した時刻に設定することで、一時停止解除機能を実装している。

// 現在時刻+残り時間をタイムリミットに設定する(こうすることで、タイマーの一時停止ができる)
self.futureDate = Calendar.current.date(byAdding: .second, value: Int(self.timeLimit), to: Date()) ?? Date()
let nanosecond = self.timeLimit.truncatingRemainder(dividingBy: 1)
self.futureDate = self.futureDate.addingTimeInterval(nanosecond)

こうすることで、一時停止の解除できる。

余談

また、本筋ではないが、futureDateの時刻を設定する際は
Calendar.current.date(byAdding:, value:, to: )メソッドを使用する。
日本語にすると、「byAdding」の単位で「value」の値だけ「to」進めた時刻を求める
今回の例はこうなる。
「second(秒)の単位でself.timeLimit(残り時間)だけ進めた時刻をDate()(現在時刻)から進めた時刻を求める」
Date()(現在時刻)からself.timeLimit(残り時間)秒だけ進めた時刻を取得する。

また、nanosecondとあるが、これは小数点以下の残り時間をtruncatingRemainder(dividingBy:)メソッドで求めている。
[数字].truncatingRemainder(dividingBy: [割る数])は、日本語に訳すと、
「[数字]を[割る数]で割ったときのあまりを取得する」
つまり、self.timeLimitを1で割ることで小数点以下の数を値を取得している。

参考ページ:
note Timer.publish処理