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

また来てしまいました...。こんにちわ、 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> で通知をするようにしてみました。