落ちてるコードは基本的にユーザーのボタン押下をトリガーにバリデーションをそれぞれの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が生成されたあとじゃないと取得できないので初期化ブロックではなく、
onMesure
かonLayout
で行います。これからのメソッドは初期化時以外もよばれるので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
を呼び出します -
ValidationTextInputLayout
にapp: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がスッキリするのでよいかなと思っています。
- なにか間違っているところやこうしたほうがいいという意見あればください