LoginSignup
1
2

More than 5 years have passed since last update.

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

Last updated at Posted at 2018-09-10

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

やりたいこと

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

完成形

カスタムTextInputLayoutの実装

  • まずバリデーションの責務を負うカスタムのTextInputLayoutを作成します
  • validateメソッドが呼ばれた際に子要素のEditTextのtextを取得して、attrs.xmlで定義してレイアウトで指定しておいたvalidationTypeに応じたバリデーションを行います
  • Enumにabstractメソッドを持たせることでポリモフィズムできます
  • メールアドレスの正規表現はこちらから拝借しました
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が生成されたあとじゃないと取得できないので初期化ブロックではなく、onMesureonLayoutで行います。これからのメソッドは初期化時以外もよばれるのでisEmpty判定いれてます
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フィールドにしました
class SampleViewModel : ViewModel() {

    var isValid: ObservableBoolean = ObservableBoolean(false)


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

}

Activityの実装

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を呼び出します
  • ValidationTextInputLayoutapp:required="true"app:validationType="password"を設定してバリデーションロジックを指定します

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がスッキリするのでよいかなと思っています。
  • なにか間違っているところやこうしたほうがいいという意見あればください
1
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
2