[TextInputLayoutxDatabindingリアルタイムフォームバリデーションを実装する

落ちてるコードは基本的にユーザーのボタン押下をトリガーにバリデーションをそれぞれのviewに対して走らせるようなものしかなかったので、うまく書けないか試してみました

## やりたいこと
- ユーザー入力を検知してバリデーションをリアルタイムで走らせ、エラーメッセージの表示/非表示を行う
- 上記結果に応じてボタンのenabledもリアルタイムで切り替え,バリデーションが全部OKになってないとボタンを押せないようにする


## 完成形

<img src="https://qiita-image-store.s3.amazonaws.com/0/95489/39734dee-7482-9780-2aec-169f82e19b52.gif" width=400>


## カスタムTextInputLayoutの実装
- まずバリデーションの責務を負うカスタムのTextInputLayoutを作成します
- `validate`メソッドが呼ばれた際に子要素のEditTextのtextを取得して、`attrs.xml`で定義してレイアウトで指定しておいた`validationType`に応じたバリデーションを行います
- Enumにabstractメソッドを持たせることでポリモフィズムできます
- メールアドレスの正規表現は[こちら](https://gist.github.com/ironic-name/f8e8479c76e80d470cacd91001e7b45b)から拝借しました

``` kotlin
class ValidationTextInputLayout @JvmOverloads constructor(context: Context,
                                                          attrs: AttributeSet? = null,
                                                          defStyleAttr: Int = 0) : TextInputLayout(context, attrs, defStyleAttr) {
    init {
        init(context, attrs)
    }

    private var required: Boolean = false
    private val requiredText: String = context.getString(R.string.validation_required)
    private var errorText: String? = ""
    lateinit var validateType: ValidationType

    private fun init(context: Context, attrs: AttributeSet?) {
        attrs?.let {
            val typedArray = context.obtainStyledAttributes(it,
                    R.styleable.ValidationTextInputLayout)
            validateType = ValidationType.valueOf(typedArray.getInt(R.styleable.ValidationTextInputLayout_validationType, ValidationType.NULL.index))
            errorText = typedArray.getString(R.styleable.ValidationTextInputLayout_errorText)
            required = typedArray.getBoolean(R.styleable.ValidationTextInputLayout_required, false)
            typedArray.recycle()
        }
    }

    fun validate(): Boolean {
        val text = editText?.text?.toString()
        val validationResult = ValidationType.validate(text, required, validateType, requiredText,
                context.getString(validateType.defaultErrorText), errorText)

        return when (validationResult) {
            ValidationResult.Success -> {
                error = null
                true
            }
            is ValidationResult.Failed -> {
                error = validationResult.errorText
                false
            }
        }
    }


    enum class ValidationType(val index: Int, @StringRes val defaultErrorText: Int) {
        EMAIL(0, R.string.validation_mail) {
            override fun validate(value: String): Boolean {
                return Pattern.compile(
                        "^(([\\w-]+\\.)+[\\w-]+|([a-zA-Z]|[\\w-]{2,}))@"
                                + "((([0-1]?[0-9]{1,2}|25[0-5]|2[0-4][0-9])\\.([0-1]?"
                                + "[0-9]{1,2}|25[0-5]|2[0-4][0-9])\\."
                                + "([0-1]?[0-9]{1,2}|25[0-5]|2[0-4][0-9])\\.([0-1]?"
                                + "[0-9]{1,2}|25[0-5]|2[0-4][0-9]))|"
                                + "([a-zA-Z]+[\\w-]+\\.)+[a-zA-Z]{2,4})$"
                ).matcher(value).matches()
            }
        },
        PASSWORD(1, R.string.validation_password) {
            override fun validate(value: String): Boolean {
                return Pattern.compile("^(?=.*[0-9])(?=.*[a-z])(?=\\S+\$).{6,10}").matcher(value).matches()
            }
        },
        NULL(-1, -1) {
            override fun validate(value: String): Boolean {
                return true
            }
        };

        abstract fun validate(value: String): Boolean

        companion object {
            fun valueOf(index: Int): ValidationType {
                return values().associateBy(ValidationType::index)[index] ?: NULL
            }

            fun validate(text: String?, required: Boolean, validateType: ValidationType, requiredText: String, defaultErrorText: String, errorText: String?): ValidationResult {
                if (!required && text.isNullOrBlank()) {
                    return ValidationResult.Success
                }

                // 必須&&未入力
                if (required && text.isNullOrBlank()) {
                    return ValidationResult.Failed(requiredText)
                }

                // 各種バリデーション
                if (!validateType.validate(text!!)) {
                    val error = if (errorText.isNullOrBlank()) defaultErrorText else errorText
                    return ValidationResult.Failed(error!!)
                }

                return ValidationResult.Success
            }
        }

    }

    sealed class ValidationResult {
        object Success : ValidationResult()
        data class Failed(val errorText: String) : ValidationResult()

    }
}
```

## ValidationTextInputLayoutのWrapperクラスの実装
- 上記のクラスはEditText単一のviewなのでそれらをまとめて保持するWrapperクラスを作成します
- このクラスは直下の子要素から`ValidationTextInputLayout`を探してリストで保持し、`valid`メソッドが叩かれたタイミングでそれらの`ValidationTextInputLayout#validate`メソッドを呼び出して、すべて検証OKであればtrue/NGだったらfalseを返します
- 子要素はviewが生成されたあとじゃないと取得できないので初期化ブロックではなく、`onMesure`か`onLayout`で行います。これからのメソッドは初期化時以外もよばれるので`isEmpty`判定いれてます

``` kotlin
class ValidationTextInputLayoutWrapperLayout @JvmOverloads constructor(context: Context,
                                                                       attrs: AttributeSet? = null,
                                                                       defStyleAttr: Int = 0) : ConstraintLayout(context, attrs, defStyleAttr) {

    private var validationTextInputLayouts: ArrayList<ValidationTextInputLayout> = arrayListOf()


    override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
        if (validationTextInputLayouts.isEmpty()) {
            for (i in 0..childCount) {
                val view = getChildAt(i)
                if (view is ValidationTextInputLayout) {
                    validationTextInputLayouts.add(view)
                }
            }
        }
        super.onLayout(changed, left, top, right, bottom)
    }

    fun valid(): Boolean {
        return validationTextInputLayouts.filter {
            !it.validate()
        }.isEmpty()
    }
}
```

# ViewModelの実装

- ViewModelはボタンのenabledを切り替えるbooleanのみ持ちます。
- databindingしたいのでObservableフィールドにしました

``` kotlin
class SampleViewModel : ViewModel() {

    var isValid: ObservableBoolean = ObservableBoolean(false)


    fun register(email: String, password: String) {
        // リクエスト
    }

}
```


## Activityの実装

``` kotlin
class SampleActivity : BaseActivity() {

    @Inject
    lateinit var viewModelFactory: ViewModelFactory


    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        val viewModel = ViewModelProviders.of(this, viewModelFactory).get(SampleViewModel::class.java)
        val binding = DataBindingUtil.setContentView<ActivitySampleBinding>(this, R.layout.activity_new_trip)
        binding.viewModel = viewModel

        listOf<EditText>(new_trip_edittext_email_inner,
                new_trip_edittext_password_inner).forEach { editText ->
            editText.afterTextChanged {
                viewModel.isValid.set(validation_form.valid())
            }
        }

        submit_button.setOnClickListener {
            viewModel.register(new_trip_edittext_email_inner.toString(), new_trip_edittext_password_inner.toString())
        }
    }

}
```

- Activityでは変更を検知したいEditTextに`onTextChangedListener`をセットします
- 変更を検知するたびに`ValidationTextInputLayoutWrapperLayout#valid`を呼び出します
- `ValidationTextInputLayout`に`app:required="true"`や`app:validationType="password"`を設定してバリデーションロジックを指定します
          
## XMLの定義
``` xml
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    tools:context=".ui.trip.NewTripActivity">

    <data>

        <variable
            name="viewModel"
            type="SampleViewModel" />
    </data>

    <fun.triplan.ui.common.ValidationTextInputLayoutWrapperLayout
        android:id="@+id/validation_form"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:background="@color/bg_light">


        <View
            android:id="@+id/new_trip_background2"
            android:layout_width="0dp"
            android:layout_height="0dp"
            android:layout_marginTop="8dp"
            android:background="@android:color/white"
            app:layout_constraintBottom_toBottomOf="@+id/new_trip_edittext_notes"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent" />

        <ImageView
            android:id="@+id/new_trip_ic_notes"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginStart="@dimen/common_16dp"
            android:src="@drawable/ic_round_key_24px"
            android:tint="@color/ic_gray"
            app:layout_constraintBottom_toBottomOf="@+id/new_trip_edittext_notes"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="@id/new_trip_background2" />

        <TextView
            android:id="@+id/new_trip_text_notes"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginStart="@dimen/common_16dp"
            android:layout_marginTop="@dimen/common_8dp"
            android:text="パスワード"
            android:textColor="@android:color/black"
            android:textSize="@dimen/text_size_12"
            android:textStyle="bold"
            app:layout_constraintStart_toEndOf="@id/new_trip_ic_notes"
            app:layout_constraintTop_toTopOf="@id/new_trip_background2" />


        <fun.triplan.ui.common.ValidationTextInputLayout
            android:id="@+id/new_trip_edittext_notes"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_marginEnd="@dimen/common_16dp"
            app:hintEnabled="false"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="@id/new_trip_text_notes"
            app:layout_constraintTop_toBottomOf="@id/new_trip_text_notes"
            app:required="true"
            app:validationType="password">

            <EditText
                android:id="@+id/new_trip_edittext_password_inner"
                android:layout_width="match_parent"
                android:layout_height="match_parent"
                android:hint="半角英数字6~10文字"
                android:inputType="textPassword|textNoSuggestions"
                android:lineSpacingExtra="@dimen/common_6dp"
                android:maxLength="10"
                android:textSize="@dimen/text_size_14" />

        </fun.triplan.ui.common.ValidationTextInputLayout>


        <View
            android:id="@+id/new_trip_background3"
            android:layout_width="0dp"
            android:layout_height="0dp"
            android:background="@android:color/white"
            app:layout_constraintBottom_toBottomOf="@+id/new_trip_edittext_email"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@+id/new_trip_edittext_notes" />

        <ImageView
            android:id="@+id/new_trip_ic_email"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginStart="@dimen/common_16dp"
            android:src="@drawable/ic_round_mail"
            android:tint="@color/ic_gray"
            app:layout_constraintBottom_toBottomOf="@+id/new_trip_edittext_email"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="@id/new_trip_background3" />

        <TextView
            android:id="@+id/new_trip_text_email"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginStart="@dimen/common_16dp"
            android:layout_marginTop="@dimen/common_8dp"
            android:text="メールアドレス"
            android:textColor="@android:color/black"
            android:textSize="@dimen/text_size_12"
            android:textStyle="bold"
            app:layout_constraintStart_toEndOf="@id/new_trip_ic_email"
            app:layout_constraintTop_toTopOf="@id/new_trip_background3" />


        <fun.triplan.ui.common.ValidationTextInputLayout
            android:id="@+id/new_trip_edittext_email"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_marginEnd="@dimen/common_16dp"
            app:hintEnabled="false"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="@id/new_trip_text_email"
            app:layout_constraintTop_toBottomOf="@id/new_trip_text_email"
            app:required="true"
            app:validationType="email">

            <EditText
                android:id="@+id/new_trip_edittext_email_inner"
                android:layout_width="match_parent"
                android:layout_height="match_parent"
                android:hint="example@example.com"
                android:inputType="textEmailAddress|textNoSuggestions"
                android:importantForAutofill="no"
                android:lineSpacingExtra="@dimen/common_6dp"
                android:textSize="@dimen/text_size_14" />

        </fun.triplan.ui.common.ValidationTextInputLayout>


        <Button
            android:id="@+id/submit_button"
            android:layout_width="0dp"
            android:layout_height="@dimen/common_55dp"
            android:background="@drawable/selector_main_enabled_bg"
            android:enabled="@{viewModel.isValid()}"
            android:text="送信する"
            android:textColor="@drawable/selector_main_enabled_text"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent" />

    </fun.triplan.ui.common.ValidationTextInputLayoutWrapperLayout>
</layout>
```

- 以上です。個人的にはバリデーションのロジックをEnumに集約できてViewModelがスッキリするのでよいかなと思っています。
- なにか間違っているところやこうしたほうがいいという意見あればください