Edited at
RxJavaDay 8

RxProperty でイケてる入力フォームをもっとスッキリ実装する

More than 1 year has passed since last update.

また来てしまいました...。こんにちわ、 RxProperty エヴァンジェリズムアドボケイトの @amay077 です。

興味深く読ませていただきました。

こちらの記事における ViewProperties の要点は次の箇所かと。



  1. ObservableField を公開することにより Android DataBinding を活用する

  2. RxJava の BehaviorSubject.onNext で View -> ViewProperties への値の更新通知

  3. RxJava のオペレータ(mapcombineLatest など)を使うことで、 「入力項目が valid か?」 を通知する仕組みが簡単に作れる

これらは、 RxProperty を使うともっとスッキリと実装できます!



  1. 先日も書いた 通り、 RxProperty は .valueObservableField に変換できます


  2. RxProperty<T> 自体は RxJava の Observable<T> からの派生であり、また最新の値を保持し設定もできます。 subscribe した時に保持されている値がすぐに notify されるかも選択できるので、 実質ほぼ BehaviorSubject<T> です


  3. RxProperty<T> は、バリデータも内蔵しており、 setValidator((T)->String?)「値 → エラー文字列への変換関数」 を渡してやるだけで validation ができます

  4. 「実行された時の処理」と「それが実行できるか?」がセットになった RxCommand というクラスがあり、これをボタンにバインドしてやるだけで、Button.enabledButton.onClick が連動します。


RxProperty で書いてみた

というわけで、元記事の FormProperties を、RxProperty を使って書いてみました。うずうずしてガマンできなかった :pray:

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 に減りました :thumbsup:

行数の削減というよりも、「値を保持する Subject」、「エラー通知用の Observable<bool>」、「データバインディング用の ObservableField」 をそれぞれ用意しなくてもすべて RxProperty<T> の宣言だけでできてしまう事が最大のメリットです。

今回のできあがり品はこちらです。

Untitled.gif

ソースも公開してるので是非動かして RxProperty の凄さを体験してみてくださいね。 12/8 にリリースされた RxProperty 4.0.0 にも超速で対応 :exclamation:





  1. 実は標準の .setValidator は引数が (T)->String になっていて null が返せないので、アプリ内で拡張関数を定義して使っていま、したが RxProperty v4.0.0 で対応してもらえました :tada:  



  2. MVVM だと、 ViewModel の中で View に依存する処理(画面遷移とか、Toast/DialogBoxの表示など)を行うのは抵抗がありますが、MVP ならまあやってもいいかもですね。今回は Toast の表示は Activity 側に任せることにして、 ViewProperties からは EventBus ライクに、 LiveData<String> で通知をするようにしてみました。