はじめに
SwiftUIでカウントダウンするTextを実装してみました。
環境
Name | Version |
---|---|
Xcode | 14.1 |
Minimum Deployments | 14.0 |
ドキュメント
Textのイニシャライザで、特定のスタイルを使用してローカライズされた日付と時刻を表示するインスタンスを作成することができます。
実装方針
以下の実装をします。
- カウントダウンの基準となる時刻を定義します。
-
基準となる時刻に引数で指定する
day
,hour
,minute
,second
を加算して、カウントダウンが終了するDateを計算します。 - カウントダウンするテキストを実装します。
- カウントダウンが終了したことを検知して、アラートを表示します。
基準となる時刻を定義する
基準は「現在時刻」としたいですが、Date()
を基準とすると、デバイスの時刻を変更されていた場合に期待通りに動作しません。
KronosというNTPクライアントライブラリで、NTPサーバと同期して、デバイスの設定に依存しない正確な「現在時刻」をカウントダウンの基準とします。
NTPサーバといえば、NICTの公開NTPサービスですね。(日本であれば、こちらで困らないかと思います。)
参考:
Kronosを扱うためのObservableObjectを実装します。
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に指定します。
import SwiftUI
@main
struct App: SwiftUI.App {
var body: some Scene {
WindowGroup {
ContentView().environmentObject(NTPHolder())
}
}
}
基準時に加算する時間を定義するモデルの実装
あと●●分~~秒といったカウントダウンの残り時間を定義します。
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
でカウントダウン終了のタイミングでフラグを更新するようにします。
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を表示するようにします。
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を実装していきます。
カウントダウンが終了したら、アラートを表示するように実装してみました。
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()
}
}
動作
フォアグラウンドの場合 | 途中バックグラウンドに遷移する場合 |
---|---|
![]() |
![]() |
おわりに
一応それっぽい実装はできましたが、カウントダウンするテキストのフォーマットをもう少し柔軟にできるといいのになーと思います