[Swift] Duration 型、Stopwatch クラス

# はじめに

みなさんは、[`Duration`](https://developer.apple.com/documentation/swift/duration/)型 をご存知でしょうか？
自分は、つい先日、初めて知りました（後述する Stopwatch クラスを作る際に、たまたま見つけた）。
iOS 16.0 から、macOS 13.0 からサポートされた 新しい型で、[`TimeInterval`](https://developer.apple.com/documentation/foundation/timeinterval/)型を**超高精度**にしたような型となっています。


# `TimeInterval`型とは

おさらいになりますが、

[`TimeInterval`](https://developer.apple.com/documentation/foundation/timeinterval/)型の実態は、単なる[`Double`](https://developer.apple.com/documentation/swift/double)型です。
つまり、浮動小数点の整数部と少数部で、**経過秒**を表現します。
例えば、整数部＝`3`、少数部＝`0.33`であれば、（浮動小数点そのまんまの）`3.33`秒を意味します。正負符号は重要な意味を持ちます。

また、[`Date`](https://developer.apple.com/documentation/foundation/date/)型と密接に関係していて、次のような演算が可能です。

- Date - Date = TimeInterval<br>（引き算演算子は標準には無く、Date.timeIntervalXXX を使う）
- Date + TimeInterval = Date
- Date - TimeInterval = Date


<br><br>これに対して、

# `Duration`型とは

[`Duration`](https://developer.apple.com/documentation/swift/duration/)型は **経過秒**を、整数部 Int64 と 少数部（$10^{-18}$単位の）UInt64 で保持します。
例を挙げると、`3.33`秒の場合、
整数部＝`3`、少数部＝`330_000_000_000_000_000`という値になります。
`Double`型と比べて高い精度であることが分かると思います。

そんな`Duration`型ですが、値の設定 / 取り出し が面白いと感じました。

```swift:設定例
// どれも 3.33秒を設定
let d0 = Duration(secondsComponent: 3, attosecondsComponent: Int64(0.33 * 1e18))
let d1 = Duration.seconds(3.33)
let d2 = Duration.seconds(3) + .milliseconds(330)
```

```swift:参照例
print(d0)
// 3.33 seconds
print(d1.components)
// (seconds: 3, attoseconds: 330000000000000000)
print(d2.formatted(.time(pattern: .hourMinuteSecond(padHourToLength: 2, fractionalSecondsLength: 3))))
//00:00:03.330
print(d0.formatted(.units(allowed: [.seconds, .milliseconds], width: .narrow)))
//3秒 330ms
 print(d1.formatted(.units(allowed: [.seconds, .milliseconds], width: .wide)))
//3秒 330ミリ秒
```

時分秒変換もやってくれます。

```swift
let d3 = Duration.seconds(195448.3635906)
print(d3.formatted(.units(allowed: [.days, .hours, .minutes, .seconds, .milliseconds], width: .abbreviated))) 
//2日 6時間 17分 28秒 364 ms
print(d3.formatted(.units(allowed: [.hours, .minutes, .seconds, .milliseconds], width: .wide))) 
//54時間 17分 28秒 364ミリ秒
print(d3.formatted(.units(allowed: [.minutes, .seconds, .milliseconds], width: .narrow)))  
//3,257分28秒364ms
```

詳しい内容は、[公式リファレンス](https://developer.apple.com/documentation/swift/duration/)を参照していただきたい。


<br><br><br><br>今回の本題は次の`Stopwatch`クラス です。

# `Stopwatch`クラス

C#には標準で[`Stopwatch`](https://learn.microsoft.com/en-us/dotnet/api/system.diagnostics.stopwatch)クラスが提供されているが、Swiftには存在しないので 自作しました。
使い方はほぼ同じです。

経過時間を、前述した`Duration`型で取得できる`elapsedDuration`プロパティも付けました。

```swift:Stopwatchクラス使用例
let stopwatch = Stopwatch()
stopwatch.start()
Thread.sleep(forTimeInterval: 10) // Sleep 10s
stopwatch.stop()

// Get the elapsed time as a Duration value.
let duration = stopwatch.elapsedDuration

// Format and display the duration value.
let elapsedTime = duration.formatted(.units(allowed: [.seconds, .milliseconds], width: .narrow))
print("Run Time: \(elapsedTime)")
//Run Time: 10秒5ms
```

_実装は極々簡単なため、内容の説明は省略します。_

```swift:Stopwatch.swift
import Foundation

class Stopwatch {
    private var _startDate: Date? = nil
    private var _elapsedDuration: TimeInterval = 0
    private(set) var isRunning: Bool = false
    init() { }
    func reset() {
        _startDate = nil
        _elapsedDuration = 0
        isRunning = false
    }
    static func startNow() -> Stopwatch {
        let stopwatch = Stopwatch()
        stopwatch.start()
        return stopwatch
    }
    func start() {
        guard !isRunning else { return }
        _startDate = .now
        isRunning = true
    }
    func restart() {
        _startDate = .now
        _elapsedDuration = 0
        isRunning = true
    }
    func stop() {
        guard isRunning else { return }
        if let startDate = _startDate {
            let elapsedPeriod = -startDate.timeIntervalSinceNow
            _elapsedDuration += elapsedPeriod
            _startDate = nil
        }
        isRunning = false
    }
    var elapsedSeconds: TimeInterval {
        var timeElapsed = _elapsedDuration
        if isRunning, let startDate = _startDate {
            let elapsedThisPeriod = -startDate.timeIntervalSinceNow
            timeElapsed += elapsedThisPeriod
        }
        return timeElapsed
    }
    var elapsedMilliseconds: TimeInterval { elapsedSeconds * 1e3 }
    var elapsedDuration: Duration { Duration.seconds(elapsedSeconds) }
}
extension Stopwatch: CustomStringConvertible {
    public var description: String {
        let startDate = _startDate == nil ? "nil" : "\(_startDate!)"
        return "Stopwatch { isRunning: \(isRunning), startDate: \(startDate), elapsedDuration: \(_elapsedDuration), elapsedSeconds: \(elapsedSeconds), elapsedDuration: \(elapsedDuration) }"
    }
}
```

# ストップウォッチアプリ

上記の`Stopwatch`クラスを使う例として、ストップウォッチアプリを示します。

| <img width="215" alt="output.gif" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/283293/d05cfff4-5527-ad8d-6ea0-44d87d4d9435.gif" /> |
|:-:|
| ↑うまく表示されていない場合は、[<img width="50" alt="scr.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/283293/ea9e81b3-999f-1964-9500-2abc1e9c1b21.png" />](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/283293/d05cfff4-5527-ad8d-6ea0-44d87d4d9435.gif) クリック |





<dl>
  <dt>・Start</dt><dd>計測 開始 / 再開始</dd>
  <dt>・Stop</dt><dd>計測 停止</dd>
  <dt>・Reset</dt><dd>計測値リセット</dd>
  <dt>・Lap</dt><dd>ラップタイム</dd>
</dl>

<br><br>コードは以下のとおり。


```swift
import SwiftUI

struct ContentView: View {
    let stopwatch = Stopwatch()
    let timer = Timer.publish(every: 0.001, on: .main, in: .common).autoconnect()
    @State var elapsed = ""
    @State var isRunning = false
    @State var laps = [Duration]()
    var body: some View {
        //let _ = Self._printChanges()
        ScrollViewReader { reader in
            VStack {
                Text(elapsed)
                    .font(Font(UIFont.monospacedDigitSystemFont(ofSize: 48, weight: .regular)))
                Spacer()
                ScrollView(.vertical, showsIndicators: false) {
                    ForEach(Array(laps.reversed().enumerated()), id: \.0) { index, lap in
                        HStack {
                            Text("\(laps.count - index)")
                            Text(lap.formatted(.time(pattern: .hourMinuteSecond(padHourToLength: 2, fractionalSecondsLength: 3))))
                                .font(Font(UIFont.monospacedDigitSystemFont(ofSize: 48, weight: .regular)))
                                .foregroundColor(Color.blue)
                                .id(index)
                        }
                    }
                }
                HStack {
                    Button(action: {
                        if stopwatch.isRunning {
                            stopwatch.stop()
                            laps.append(stopwatch.elapsedDuration)
                        } else {
                            stopwatch.start()
                        }
                        isRunning = stopwatch.isRunning
                    }, label: {
                        Text(isRunning ? "Stop" : "Start")
                            .font(.title)
                            .foregroundColor(isRunning ? Color.red : Color.blue)
                            .frame(width: 130, height: 50, alignment: .center)
                            .overlay(
                                RoundedRectangle(cornerRadius: 10)
                                    .stroke(isRunning ? Color.red : Color.blue, lineWidth: 2)
                            )
                            .background(RoundedRectangle(cornerRadius: 10).fill(Color.white))
                            .padding(20)
                    })
                    Button(action: {
                        if stopwatch.isRunning {
                            laps.append(stopwatch.elapsedDuration)
                            reader.scrollTo(0)
                        } else {
                            stopwatch.reset()
                            laps.removeAll()
                        }
                    }, label: {
                        Text(isRunning ? "Lap" : "Reset")
                            .font(.title)
                            .foregroundColor(isRunning ? Color.blue : Color.red)
                            .frame(width: 130, height: 50, alignment: .center)
                            .overlay(
                                RoundedRectangle(cornerRadius: 10)
                                    .stroke(isRunning ? Color.blue : Color.red, lineWidth: 2)
                            )
                            .background(RoundedRectangle(cornerRadius: 10).fill(Color.white))
                            .padding(20)
                    })
                }
            }
            .onReceive(timer) { _ in
                elapsed = stopwatch.elapsedDuration.formatted(.time(pattern: .hourMinuteSecond(padHourToLength: 2, fractionalSecondsLength: 3)))
            }
            .padding()
        }
    }
}
```

:::note info
stopwatch が NOT isRunning の状態のときも、1ミリ秒ごとに（無駄な）描画が 行われているのでは?と、気になったが、
確認すると、一切無駄な再描画は行われていなかった。さすが SwiftUI 。
:::

<br><br>**以上**