はじめに
皆様あけましておめでとうございます🙇♂️
新年早々ぐーたらしている hayabusabusa と申します。
去年からRxSwiftをしっかり使い始めて、ようやく慣れてきたので
今回はRxSwiftとRxCocoaを使ってストップウォッチのようなタイマーを作ってみます。
こんな感じのものです。
また、作ったものはQiitaRxTimerSampleにあげてあります。
ストップウォッチの要件
以下の要件を満たす物を作成します。
- 開始ボタンでカウントダウンを開始
- 停止ボタンでカウントダウンを一時停止
- リセットボタンでカウントを初期化
下準備
まずは下準備としてストップウォッチのようなタイマーのベースとなる、
一定間隔ごとにカウントをインクリメントする部分を作ります。
RxSwiftで一定間隔ごとに処理を行いたい場合はintervalやtimerを使用すると思いますが、今回はintervalを使用します。
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を追加して、各ボタンにバインドさせて値が流れるようにします。
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と合わせます。
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 はflatMapLatestで毎回新しくカウントダウンを行うObservableを生成(0からカウントし直しになる)して、その値を直接マップしてラベルに表示しているため発生しています。
2 は同じtrueの値が流れてしまっても新しくカウントダウンを行うObservableが生成されてしまうため発生しています。
というわけでこの2点を解消するためにコードを修正します。
まず 1 を解消するためにラベルに表示する秒数を保持しておくsecondsRelayを追加して、そこにインクリメントした値を1秒毎に流すようにします。
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を追加して、同じ値が流れた場合は無視されるようにします。
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をラベルに表示するようにバインドさせるだけです。
secondsRelay
.map { String(format: "%02i:%02i:%02i", $0 / 3600, $0 / 60 % 60, $0 % 60) }
.bind(to: timerLabel.rx.text)
.disposed(by: disposeBag)
これで開始と一時停止の要件を満たすことができました。
カウントのリセット
カウントのリセットは簡単で、何らかのボタンタップしたときに先程追加したsecondsRelayに0を流すだけです。
今回はナビゲーションバーにアイテムを追加して実装しました。
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を使うことでコード量を減らすことができたと思います。
次は今回作った物を改良して、バックグラウンドにも対応させた物を作ろうと思います。
間違っている箇所や改善できる部分があれば指摘していただけると嬉しいです🙇♂️
最後まで読んでいただきありがとうございました!


