はじめに
皆様あけましておめでとうございます🙇♂️
新年早々ぐーたらしている 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を使うことでコード量を減らすことができたと思います。
次は今回作った物を改良して、バックグラウンドにも対応させた物を作ろうと思います。
間違っている箇所や改善できる部分があれば指摘していただけると嬉しいです🙇♂️
最後まで読んでいただきありがとうございました!