また来てしまいました...。こんにちわ、 RxProperty エヴァンジェリズムアドボケイトの @amay077 です。
興味深く読ませていただきました。
こちらの記事における ViewProperties
の要点は次の箇所かと。
-
ObservableField
を公開することにより Android DataBinding を活用する - RxJava の
BehaviorSubject.onNext
で View -> ViewProperties への値の更新通知 - RxJava のオペレータ(
map
やcombineLatest
など)を使うことで、 「入力項目が valid か?」 を通知する仕組みが簡単に作れる
これらは、 RxProperty を使うともっとスッキリと実装できます!
-
先日も書いた 通り、 RxProperty は
.value
でObservableField
に変換できます -
RxProperty<T>
自体は RxJava のObservable<T>
からの派生であり、また最新の値を保持し設定もできます。subscribe
した時に保持されている値がすぐに notify されるかも選択できるので、 実質ほぼBehaviorSubject<T>
です。 -
RxProperty<T>
は、バリデータも内蔵しており、setValidator((T)->String?)
に 「値 → エラー文字列への変換関数」 を渡してやるだけで validation ができます。 - 「実行された時の処理」と「それが実行できるか?」がセットになった
RxCommand
というクラスがあり、これをボタンにバインドしてやるだけで、Button.enabled
とButton.onClick
が連動します。
RxProperty で書いてみた
というわけで、元記事の FormProperties
を、RxProperty を使って書いてみました。うずうずしてガマンできなかった 。
class FormProperties {
enum class Gender(val id :Int) {
MAN(0), WOMAN(1), OTHER(2), NOT_SET(9)
}
private val disposables = CompositeDisposable()
/** ニックネーム */
val nickname = RxProperty<String>("")
.setValidator {
if (it.length < 2 || it.length > 10)
// エラーの場合はその説明を、エラーなしの場合は null を返却
"ニックネームは2文字以上10文字以下にしてください" else null }
/** 誕生日(Rawデータ) */
val birthday = RxProperty<Calendar>(Calendar.getInstance())
.setValidator {
if (it >= Calendar.getInstance().apply { add(Calendar.YEAR, -18 ) }) "18歳以上が必要です" else null
}
/** 誕生日(表示用文字列) */
val birthdayText = birthday.map {
SimpleDateFormat("yyyy/MM/dd", Locale.JAPAN).format(it.time)
}.toReadOnlyRxProperty()
/** 性別(Rawデータ) */
val gender = RxProperty<Gender>(Gender.NOT_SET)
.setValidator { if (it == Gender.NOT_SET) "性別を何か選択してください" else null }
/** 性別(表示用文字列) */
val genderTextResId = gender.map {
when (it) {
Gender.MAN -> R.string.male
Gender.WOMAN -> R.string.female
Gender.OTHER -> R.string.other
else -> R.string.empty
}
}.toReadOnlyRxProperty()
/** 利用規約同意 */
val isAgreed = RxProperty<Boolean>(false)
/** Toast を通知するためだけの LiveData */
private val _toast = MutableLiveData<String>()
val toast : LiveData<String> = _toast
/** 登録ボタンが実行できるか */
private val canRegistration : Observable<Boolean> = Observable
.combineLatest(listOf(
nickname.onHasErrorsChanged().map { !it },
gender.onHasErrorsChanged().map { !it },
birthday.onHasErrorsChanged().map { !it },
isAgreed),
{ anyList -> anyList.map { it as Boolean }.all { it }})
/** 登録ボタンを押したときのコマンド */
// canRegistration が true の時だけ実行可能なコマンド
val register = canRegistration.toRxCommand<NoParameter>()
.apply { this.subscribe {
// RxCommand の subscribe が呼ばれた時 = ボタンが押された時
// とりあえずトースト投げる
_toast.postValue("RegistrationCompleteActivity へ移動するよ")
}.addTo(disposables) }
fun dispose() {
disposables.clear()
}
}
要点をいくつか
基本的なところ
/** ニックネーム */
val nickname = RxProperty<String>("")
.setValidator {
if (it.length < 2 || it.length > 10)
// エラーの場合はその説明を、エラーなしの場合は null を返却
"ニックネームは2文字以上10文字以下にしてください" else null }
これはニックネームを入力する EditText がバインドするプロパティです。
Android DataBinding の場合は、レイアウトXMLで android:text="@={prop.nickname.value}"
なんて書きます。
.setValidator()
でバリデータを設定しています。ここでは入力値が 2文字未満または10文字より長い場合はエラーメッセージを返し、そうでない場合はエラーがない事を示す null
を返します。1
このエラー値もデータバインドできるようになっていて、 android:text="@{props.nickname.error}"
と書いてバインドできます。
表示用に値を変換
/** 誕生日(表示用文字列) */
val birthdayText = birthday.map {
SimpleDateFormat("yyyy/MM/dd", Locale.JAPAN).format(it.time)
}.toReadOnlyRxProperty()
RxProperty<Calendar>
型である birthday
プロパティを Binding や View 側で文字列に変換するのもできるのですが、せっかくなので Rx ライクにいきましょう。 .map {}
でよしなに変換してやるだけです。最後に .toReadOnlyRxProperty()
としているのは、このプロパティは読み取り専用、つまり OneWay Bind しか許可しないことを示しています。
コマンド
/** 登録ボタンが実行できるか */
private val canRegistration : Observable<Boolean> = Observable
.combineLatest(listOf(
nickname.onHasErrorsChanged().map { !it },
gender.onHasErrorsChanged().map { !it },
birthday.onHasErrorsChanged().map { !it },
isAgreed),
{ anyList -> anyList.map { it as Boolean }.all { it }})
/** 登録ボタンを押したときのコマンド */
// canRegistration が true の時だけ実行可能なコマンド
val register = canRegistration.toRxCommand<Nothing>()
.apply { this.subscribe {
// RxCommand の subscribe が呼ばれた時 = ボタンが押された時
// とりあえずトースト投げる
_toast.postValue("RegistrationCompleteActivity へ移動するよ")
}.addTo(disposables) }
登録ボタンは、「ニックネーム」、「性別」、「誕生日」がすべて valid であり、さらに 「利用規約に同意」 が true
である場合にだけ押すことができる仕様です。
それを定義しているのが canRegistration : Observable<Boolean>
です。 「valid かどうか?」 は、 nickname.onHasErrorsChanged().map { !it }
のように、「エラーがあるか?」を「エラーがないか?」に反転するだけで表せます。これらを元記事のように Observable.combineLatest
でまとめてあげて「入力項目が全て true
なら登録ボタンは押せる」となります。
登録ボタンが押されたときの処理は、 register : RxCommand<T>
の .subscribe
に書きます。ここでは Kotlin の便利な .apply
関数を使って、プロパティの定義とともに書けますね。
実際のボタンが押された処理は、「xxxへ移動するよ」というトーストを表示させるために LiveData に通知を送っています。Activity 側で LiveData を observe
して Toast.show
を呼んでいます。2
ボタンを RxCommand にバインドするには、レイアウトXMLに app:rxCommandOnClick="@{props.register}"
と書きます。これだけで、登録ボタンは、入力項目が全てvalidになるまでは disabled になります。
まとめ
ViewProperties
を RxProperty を使って書き直してみたところ、行数は 88 から 68 に減りました 。
行数の削減というよりも、「値を保持する Subject」、「エラー通知用の Observable<bool>
」、「データバインディング用の ObservableField」 をそれぞれ用意しなくてもすべて RxProperty<T>
の宣言だけでできてしまう事が最大のメリットです。
今回のできあがり品はこちらです。
ソースも公開してるので是非動かして RxProperty の凄さを体験してみてくださいね。 12/8 にリリースされた RxProperty 4.0.0 にも超速で対応
-
実は標準の
.setValidator
は引数が(T)->String
になっていてnull
が返せないので、アプリ内で拡張関数を定義して使っていま、したが RxProperty v4.0.0 で対応してもらえました ↩ -
MVVM だと、 ViewModel の中で View に依存する処理(画面遷移とか、Toast/DialogBoxの表示など)を行うのは抵抗がありますが、MVP ならまあやってもいいかもですね。今回は Toast の表示は Activity 側に任せることにして、
ViewProperties
からは EventBus ライクに、LiveData<String>
で通知をするようにしてみました。 ↩