RxSwiftとRxCocoaを使ってストップウォッチを作る

# はじめに
皆様あけましておめでとうございます🙇‍♂️
新年早々ぐーたらしている [hayabusabusa](https://twitter.com/hayabusa0112) と申します。

去年からRxSwiftをしっかり使い始めて、ようやく慣れてきたので
今回はRxSwiftとRxCocoaを使ってストップウォッチのようなタイマーを作ってみます。

こんな感じのものです。

![Jan-03-2020 17-36-28.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/202306/cac62123-977a-bb7d-4bb8-6bc6e894ba84.gif)

また、作ったものは[QiitaRxTimerSample](https://github.com/hayabusabusa/QiitaRxTimerSample)にあげてあります。

# ストップウォッチの要件
以下の要件を満たす物を作成します。

- 開始ボタンでカウントダウンを開始
- 停止ボタンでカウントダウンを一時停止
- リセットボタンでカウントを初期化

# 下準備
まずは下準備としてストップウォッチのようなタイマーのベースとなる、
**一定間隔ごとにカウントをインクリメントする**部分を作ります。

RxSwiftで一定間隔ごとに処理を行いたい場合は`interval`や`timer`を使用すると思いますが、今回は`interval`を使用します。

```Swift
    Observable<Int>
        .interval(RxTimeInterval.seconds(1), scheduler: MainScheduler.instance)
        .map { String(format: "%02i:%02i:%02i", $0 / 3600, $0 / 60 % 60, $0 % 60) }
        .bind(to: timerLabel.rx.text)
        .disposed(by: disposeBag)
```

上のコードでは`interval`で1秒毎に流れてくるカウントを`String`かつそれっぽいフォーマットにマップしてラベルに表示するということをしています。

これをベースにして他の要件を満たす物を作っていきます。

# 開始と一時停止
ストップウォッチの開始と停止は開始ボタンと停止ボタンのタップによって切り替えられます。
よって開始と停止の状態を`Bool`として扱い、開始を`true`、停止を`false`とします。

新しく各ボタンタップ時に対応する`Bool`の値が流れる`isValidRelay`を追加して、各ボタンにバインドさせて値が流れるようにします。

```Swift
let isValidRelay: BehaviorRelay<Bool> = .init(value: false)

stopButton.rx.tap.map { false }
    .bind(to: isValidRelay)
    .disposed(by: disposeBag)
startButton.rx.tap.map { true }
    .bind(to: isValidRelay)
    .disposed(by: disposeBag)
```

スタートボタンを押すとタイマーを開始する`true`が`isValidRelay`に流れて、ストップボタンを押すとタイマーを停止させる`false`が流れるようになりました。

さらに追加した`isValidRelay`を元にして、下準備で作成した`Observable`と合わせます。

```Swift
isValidRelay
    .flatMapLatest { $0 ? Observable<Int>.interval(RxTimeInterval.seconds(1), scheduler: MainScheduler.instance) : Observable.empty() }
    .map { String(format: "%02i:%02i:%02i", $0 / 3600, $0 / 60 % 60, $0 % 60) }
    .bind(to: timerLabel.rx.text)
    .disposed(by: disposeBag)
```

`isValidRelay`に流れてきたストップウォッチの開始または停止の値を確認して、
カウントダウンを行う`Observable`を新しく生成もしくは`empty()`を流すのどちらかを行っています。

これでスタートボタンを押すとタイマーがスタートして、ストップを押すとタイマーが止まるようになりました。

しかし、実際に動かしてみると

1. ストップボタンを押した後にスタートボタンを押して再開する
2. スタートボタンを連続でタップする

の操作を行った時にカウントが最初からスタートしてしまいます。

![Jan-03-2020 18-11-21.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/202306/7db4d98a-362b-3e91-d7c5-0c626ccc3b91.gif)

1 は`flatMapLatest`で毎回新しくカウントダウンを行う`Observable`を生成(0からカウントし直しになる)して、その値を直接マップしてラベルに表示しているため発生しています。
2 は同じ`true`の値が流れてしまっても新しくカウントダウンを行う`Observable`が生成されてしまうため発生しています。

というわけでこの2点を解消するためにコードを修正します。

まず 1 を解消するためにラベルに表示する秒数を保持しておく`secondsRelay`を追加して、そこにインクリメントした値を1秒毎に流すようにします。

```Swift
let secondsRelay: BehaviorRelay<Int> = .init(value: 0)

isValidRelay
    .flatMapLatest { $0 ? Observable<Int>.interval(RxTimeInterval.seconds(1), scheduler: MainScheduler.instance) : Observable.empty() }
    .subscribe(onNext: { _ in secondsRelay.accept(secondsRelay.value + 1) })
    .disposed(by: disposeBag)
```


次に 2 を解消するために`isValidRelay`でカウントダウンの`Observable`を生成する前に`distinctUntilChanged`を追加して、同じ値が流れた場合は無視されるようにします。

```Swift
isValidRelay
    .distinctUntilChanged()
    .flatMapLatest { $0 ? Observable<Int>.interval(RxTimeInterval.seconds(1), scheduler: MainScheduler.instance) : Observable.empty() }
    .subscribe(onNext: { _ in secondsRelay.accept(secondsRelay.value + 1) })
    .disposed(by: disposeBag)
```

あとは`secondsRelay`をラベルに表示するようにバインドさせるだけです。

```Swift
secondsRelay
    .map { String(format: "%02i:%02i:%02i", $0 / 3600, $0 / 60 % 60, $0 % 60) }
    .bind(to: timerLabel.rx.text)
    .disposed(by: disposeBag)
```

![Jan-03-2020 16-59-09.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/202306/15e55c94-79bf-63c1-7811-2ec68b3c361f.gif)

これで開始と一時停止の要件を満たすことができました。

# カウントのリセット
カウントのリセットは簡単で、何らかのボタンタップしたときに先程追加した`secondsRelay`に`0`を流すだけです。

今回はナビゲーションバーにアイテムを追加して実装しました。

```Swift
let resetButton = UIBarButtonItem(title: "リセット", style: .plain, target: nil, action: nil)
resetButton.rx.tap.map { 0 }
    .bind(to: secondsRelay)
    .disposed(by: disposeBag)

navigationItem.rightBarButtonItem = resetButton
```

これでリセットも完成です。


# おわりに
RxSwiftではなくデフォルトの`Timer`を使って今回のようなストップウォッチを作ったことがありますが、今回の実装よりもごちゃっとしてしまった記憶があるので、RxSwiftを使うことでコード量を減らすことができたと思います。

次は今回作った物を改良して、バックグラウンドにも対応させた物を作ろうと思います。

間違っている箇所や改善できる部分があれば指摘していただけると嬉しいです🙇‍♂️
最後まで読んでいただきありがとうございました！

# 参考にさせていただいた記事
- [【iOS】RxSwiftでのタイマーとflatMapLatestについて](https://qiita.com/akihisasen/items/96fb48050d94393962a2)