SwiftUIでカウントダウンする

# はじめに
SwiftUIでカウントダウンするTextを実装してみました。

# 環境

|Name|Version|
|:--:|:--:|
|Xcode|14.1|
|Minimum Deployments|14.0|

# ドキュメント

Textのイニシャライザで、特定のスタイルを使用してローカライズされた日付と時刻を表示するインスタンスを作成することができます。

https://developer.apple.com/documentation/swiftui/text/init(_:style:)

# 実装方針
以下の実装をします。
1. カウントダウンの**基準となる時刻**を定義します。
1. **基準となる時刻**に引数で指定する`day`, `hour`, `minute`, `second`を加算して、カウントダウンが終了するDateを計算します。
1. カウントダウンするテキストを実装します。
1. カウントダウンが終了したことを検知して、アラートを表示します。

## 基準となる時刻を定義する
基準は「現在時刻」としたいですが、`Date()`を基準とすると、デバイスの時刻を変更されていた場合に期待通りに動作しません。
KronosというNTPクライアントライブラリで、NTPサーバと同期して、デバイスの設定に依存しない正確な「現在時刻」をカウントダウンの基準とします。

https://github.com/MobileNativeFoundation/Kronos

NTPサーバといえば、NICTの公開NTPサービスですね。（日本であれば、こちらで困らないかと思います。）

参考：

https://jjy.nict.go.jp/tsp/PubNtp/index.html

https://zenn.dev/treastrain/articles/4e053360a68e2c

Kronosを扱うためのObservableObjectを実装します。

```swift: NTPHolder.swift
import SwiftUI
import Kronos

final class NTPHolder: ObservableObject {
    
    init() {
        Clock.sync(from: "ntp.nict.jp", completion: { date, offset in
#if DEBUG
            print("A closure that will be called after _all_ the NTP calls are finished.")
            print("date", date, "offset", offset)
#endif
        })
    }
    
    func now() -> Date? {
        return Clock.now
    }
}
```

実装したNTPHolderをenvironmentObjectに指定します。

```swift: App.swift
import SwiftUI

@main
struct App: SwiftUI.App {
    var body: some Scene {
        WindowGroup {
            ContentView().environmentObject(NTPHolder())
        }
    }
}
```

## 基準時に加算する時間を定義するモデルの実装
あと●●分~~秒といったカウントダウンの残り時間を定義します。

```swift: Countdown.swift
import Foundation

struct Countdown {
    let day: Int?
    let hour: Int?
    let minute: Int?
    let second: Int?

    init(day: Int? = nil, hour: Int? = nil, minute: Int? = nil, second: Int? = nil) {
        self.day = day
        self.hour = hour
        self.minute = minute
        self.second = second
    }
}
```

基準となる時刻にpropertiesのday, hour, minute, secondを加算して、カウントダウンが終了するDateを計算します。
先ほどのNTPサーバから取得した現在時刻を引数で渡してあげて、それを基準とします。

`Calendar.current`を使用すると西暦・和暦などがデバイスの設定に依存してしまうので、　`Calendar(identifier: .gregorian)`を使用します。
また、localeもデバイスの設定に依存しないように、`Locale(identifier: "en_US_POSIX")`を使用しています。

カウントダウンが終了するDateを計算したら、 `DispatchQueue.main.asyncAfter`で**カウントダウン終了のタイミングでフラグを更新する**ようにします。


```swift: Countdown.swift
import Foundation

struct Countdown {
    // ...（省略）

    func deadline(
        now: Date,
        completion: @escaping () -> Void
    ) -> Date {
        let timeZone = TimeZone(identifier: "Asia/Tokyo")!

        // デバイスの設定に依存しないカレンダーを定義
        let calendar = {
            var calendar = Calendar(identifier: .gregorian)
            calendar.timeZone = timeZone
            calendar.locale = Locale(identifier: "en_US_POSIX")
            return calendar
        }()
        let dateComponents = DateComponents(
            calendar: calendar,
            timeZone: timeZone,
            day: day,
            hour: hour,
            minute: minute,
            second: second
        )

        // 引数で受け取った「現在時刻」にday, hour, minute, secondを加算
        guard let date = calendar.date(byAdding: dateComponents, to: now) else {
            assertionFailure("deadline date is nil.")
            completion()
            return Date()
        }

        // カウントダウン終了時にcompletion()を実行
        DispatchQueue.main.asyncAfter(deadline: .now() + date.timeIntervalSince(now)) {
            completion()
        }
        return date
    }
}
```

## カウントダウンするテキストの実装
[冒頭](#ドキュメント)でドキュメントを紹介した`Text`のイニシャライザ`init(_ date: Date, style: Text.DateStyle)`を使用します。
ただ、これは、Dateへの残り時間が0になったこと（カウントダウンが終了したこと）を検知してくれず、`0:00`になっても停止してくれません。
停止してくれず、`0:01`, `0:02`...と加算していってしまいます。

そのため、Countdownモデルのロジックで、カウントダウン終了後にフラグを更新できるようにしています。
**カウントダウン中か否か**をフラグで判定して、**カウントダウン中でない場合は、固定のTextを表示**するようにします。

```swift: CountdownText.swift
import SwiftUI

struct CountdownText: View {

    @EnvironmentObject var ntpHolder: NTPHolder

    private let countdown: Countdown
    @Binding var isRunning: Bool

    init(countdown: Countdown, isRunning: Binding<Bool>) {
        self.countdown = countdown
        _isRunning = isRunning
    }

    var body: some View {
        if let now = ntpHolder.now() {
            // カウントダウン中か判定
            if isRunning {
                Text(countdown.deadline(now: now) { self.isRunning = false }, style: .timer)
                    .font(Font.body.monospacedDigit())
            } else {
                // 固定のテキストを表示
                Text("0:00")
                    .font(Font.body.monospacedDigit())
    #if DEBUG
                    .onAppear {
                        let _ = print("カウントが0になりました")
                    }
    #endif
            }
        } else {
            Text("NTPサーバ同期待ち")
        }
    }
}

struct CountdownText_Previews: PreviewProvider {
    static var previews: some View {
        CountdownText(countdown: .init(second: 10), isRunning: .constant(true))
            .environmentObject(NTPHolder())
    }
}

```

Textのイニシャライザで`.timer`というText.DateStyleを指定することで、カウントダウンを実現できましたが、フォーマットなどは指定ができませんでした。
「あとxx時間●●分~~秒」のような日本語のフォーマットを柔軟に定義できれば良いなと思いましたが、それは難しそうでした。

## カウントダウンが終了したことを検知して、アラートを表示
最後に、CountdownTextを使用するViewを実装していきます。
カウントダウンが終了したら、アラートを表示するように実装してみました。

```swift: ContentView.swift
import SwiftUI

struct ContentView: View {

    private let current = Date()
    @State private var isRunningCountdown = true
    @State private var isShownAlert = false

    var body: some View {
        CountdownText(countdown: .init(second: 10), isRunning: $isRunningCountdown)
            .onChange(of: isRunningCountdown, perform: { newValue in
                isShownAlert = !isRunningCountdown
            })
            .alert(isPresented: $isShownAlert) {
                .init(title: Text("The time is up."), message: Text("時間切れです。"), dismissButton: .default(Text("OK")))
            }
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}
```

## 動作
|フォアグラウンドの場合|途中バックグラウンドに遷移する場合|
|:--:|:--:|
|![result.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/103695/a8f15538-ce90-cc4c-7a5f-7665035da2a4.gif)|![result2.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/103695/97ec76de-6f9f-8cbc-36d7-46a3038f8b27.gif)|

# おわりに
一応それっぽい実装はできましたが、カウントダウンするテキストのフォーマットをもう少し柔軟にできるといいのになーと思います:innocent: