Реактивный подход к валидации полей ввода на Android
Привет! Меня зовут Константин Черненко, я инженер в компании Genesis, работаю на проекте BetterMe. Как вы, наверное, знаете, валидация ввода — одна из самых распространенных задач, которую приходится делать в мобильном приложении.
Примерно в 2014 году у нас появился такой инструмент, как RxJava, и мышление Android-инженера начало переход с императивного к реактивному подходу в программировании. Как это связано с валидацией полей? Я думаю, что не открою вам секрет: мы можем интерпретировать события ввода как потоки данных, на которые можно как-нибудь реагировать или как-то ими манипулировать. Кажется, что вы что-то уже подобное слышали, не так ли?
Библиотека RxBinding
Конечно, в этих наших интернетах очень много информации по этому поводу — статьи, библиотеки и ответы на Stack Overflow. Самый распространенный паттерн, который можно встретить, — это использование библиотеки RxBinding (сами знаете кого) и ваше базовое решение может выглядеть следующим образом:
package tech.gen.rxinputvalidation import android.os.Bundle import android.support.v7.app.AppCompatActivity import com.jakewharton.rxbinding2.widget.RxTextView import io.reactivex.Observable import io.reactivex.disposables.Disposable import io.reactivex.functions.Function4 import kotlinx.android.synthetic.main.activity_main.* class MainActivity : AppCompatActivity() { private var disposable: Disposable? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) } override fun onStart() { super.onStart() validateFields() } override fun onStop() { super.onStop() disposable?.let { if (!it.isDisposed) it.dispose() } } private fun validateFields() { // Wrap EditText views into Observables val nameObs = RxTextView.textChanges(nameEt) val surnameObs = RxTextView.textChanges(surnameEt) val emailObs = RxTextView.textChanges(emailEt) val passwordObs = RxTextView.textChanges(passwordEt) // Combine those views input events applying validation logic to each element disposable = Observable.combineLatest(nameObs, surnameObs, emailObs, passwordObs, Function4<CharSequence, CharSequence, CharSequence, CharSequence, Boolean> { name, surname, email, password -> // Validate each element and manipulate it's error visibility return@Function4 isNameValid(name.toString()) && isSurnameValid(surname.toString()) && isEmailValid(email.toString()) && isPasswordValid(password.toString()) }).subscribe { btnDone.isEnabled = it } } private fun isNameValid(name: String): Boolean { return if (name.isEmpty()) { nameInputLayout.isErrorEnabled = true nameInputLayout.error = getString(R.string.name_validation_error) false } else { nameInputLayout.isErrorEnabled = false true } } private fun isSurnameValid(surname: String): Boolean { return if (surname.isEmpty()) { surnameInputLayout.isErrorEnabled = true surnameInputLayout.error = getString(R.string.surname_validation_error) false } else { surnameInputLayout.isErrorEnabled = false true } } private fun isEmailValid(email: String): Boolean { return if (!email.contains("@", true)) { emailInputLayout.isErrorEnabled = true emailInputLayout.error = getString(R.string.email_validation_error) false } else { emailInputLayout.isErrorEnabled = false true } } private fun isPasswordValid(password: String): Boolean { return if (password.length < 4) { passwordInputLayout.isErrorEnabled = true passwordInputLayout.error = getString(R.string.password_validation_error) false } else { passwordInputLayout.isErrorEnabled = false true } } }
Тут скрыта довольно простая логика. Вы заворачиваете ваши EditText в Observable, объединяете эти потоки данных и слушаете их последние изменения, применяя логику валидации к каждому из них.
Если мы не будем привязываться к самой логике валидации, то получается следующая картина.
Плюсы:
- Нам не нужно возиться с TextWatcher’ами - наш код стал более читаемым, потому что отсутствует «шум» из-за колбэков.
- У нас есть что-то наподобие валидации в реальном времени — наш пользователь сразу понимает, какое правило накладывается на какое поле.
Минусы:
- Если у нас в команде есть UX-специалист или нам самим присущ этот дар, то мы можем заметить, что это решение выглядит ужасно для обычного пользователя. Пользователь открывает экран логина/регистрации и первое, что видит - ошибку ввода на первой строке. Он еще не начал вводить информацию во второе поле, как там сразу же появляется ошибка валидации и т. д.
Конечно же, мы хотим предоставлять нашему пользователю максимально гладкий опыт использования при взаимодействии с необходимыми (нам) полями.
План валидации
Перед тем как начать, давайте составим небольшую карту-план, что нам необходимо сделать, чтобы реализовать максимально дружественную валидацию полей:
- Если пользователь открыл экран логина/регистрации, то он не должен видеть никаких ошибок — никакой информации в поля ещё не поступало, поэтому нам не о чем жаловаться.
- Если пользователь только начал вводить данные в поле, то мы не должны показывать никакой ошибки. Если покажем, то только зря отвлечем пользователя от процесса ввода (мы вообще должны быть безумно благодарными, что пользователь доверяет нашему приложению настолько, что решил поделиться своей персональной информацией с нами).
- Пользователь закончил вводить данные в текущее поле и перешел к следующему? Теперь-то мы и должны проверить текущее поле на содержание ошибок.
- Пользователь вернулся на поле, которое содержит ошибку и начал её исправлять? Мы должны скрыть ошибку, потому что пользователь достаточно умен, и мы не должны надоедать ему этой ошибкой.
С инженерной точки зрения, мы хотим код без колбэков, который достаточно легко читать.
Реализация
Если вы достаточно внимательно читали план, описанный выше, то, наверное, заметили, что, как инженер, вы должны как-то манипулировать фокусом EditText’а и событиями ввода . Когда наше представление в фокусе, то мы должны отключить для него валидацию. Когда фокус оно потеряло, то проверка должна быть применена и должны быть показаны ошибки (если в этом есть необходимость). Если пользователь вернулся к этому представлению (оно снова в фокусе) и начал исправлять ошибку (представление получает события ввода), то мы должны скрыть ошибку.
Давайте посмотрим библиотеку RxBinding
и попробуем найти методы, которые помогут решить эту задачу.
В первую очередь, там есть метод focusChanges(View view)
, который находится в классе RxView
. Его предназначение, как несложно догадаться, — наблюдение за событиями фокуса. Если вы сейчас попробуете использовать этот метод, то заметите, что вы получите ивент фокуса моментально, и это будет нарушением нашего RxBinding
есть метод skipInitialValue()
, который позволит нам пропустить этот первый ивент во время подписки.
Теперь мы должны применять логику валидации, когда наш EditText теряет фокус. Тут нам поможет обычный map()
, используя который мы можем определить момент, когда наше представление потеряло фокус, и выполнить блок валидации (лямбда-выражение, потому что, конечно же, каждое поле может иметь свой вид проверки).
fun validateInput(inputView: TextView, body: () -> Unit): Disposable { return RxView.focusChanges(inputView) // Listen for focus events .skipInitialValue() //Skip first emission that occurs when we subscribe. .map { if (!it) { // If view lost focus, lambda (our check logic) should be applied body() } return@map it }.subscribe { } }
Теперь нам необходимо решить TextInputLayout
и слушать изменения текста. Библиотека RxBinding
предоставляет метод textChanges(View view)
, который находится в классе RxTextView
. Также в то время, как пользователь набирает текст, нам необходимо:
- Скрыть сообщение об ошибке, если таково имеется.
- Игнорировать события изменения текста, пока EditText в фокусе.
Поэтому обновлённая версия нашего метода validateInput
может выглядеть следующим образом:
fun validateInput(inputLayout: TextInputLayout, inputView: TextView, body: () -> Unit): Disposable { return RxView.focusChanges(inputView) .skipInitialValue() // Listen for focus events. .map { if (!it) { // If view lost focus, lambda (our check logic) should be applied. body() } return@map it } .flatMap { hasFocus -> return@flatMap RxTextView.textChanges(inputView) .skipInitialValue() .map { if (hasFocus && inputLayout.isErrorEnabled) inputLayout.disableError() } // Disable error when user typing. .skipWhile({ hasFocus }) // Don't react on text change events when we have a focus. .doOnEach { body() } } .subscribe { } }
В Kotlin, если функция принимает лямбда-выражение, то можно её пометить как inline
, чтобы её тело было скопировано в место вызова. Поэтому полное решение может выглядеть так:
inline fun validateInput(inputLayout: TextInputLayout, inputView: TextView, crossinline body: () -> Unit): Disposable { return RxView.focusChanges(inputView) .skipInitialValue() // Listen for focus events. .map { if (!it) { // If view lost focus, lambda (our check logic) should be applied. body() } return@map it } .flatMap { hasFocus -> return@flatMap RxTextView.textChanges(inputView) .skipInitialValue() .map { if (hasFocus && inputLayout.isErrorEnabled) inputLayout.isErrorEnabled = false } // Disable error when user typing. .skipWhile({ hasFocus }) // Don't react on text change events when we have a focus. .doOnEach { body() } } .subscribe { } }
Давайте посмотрим на валидацию ввода, которую мы сейчас реализовали:
На полную реализацию:
package tech.gen.rxinputvalidation import android.os.Bundle import android.support.v7.app.AppCompatActivity import io.reactivex.disposables.CompositeDisposable import kotlinx.android.synthetic.main.activity_main.* class MainActivity : AppCompatActivity() { private var disposable = CompositeDisposable() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) } override fun onStart() { super.onStart() validateFields() } override fun onStop() { super.onStop() if (!disposable.isDisposed) disposable.clear() } private fun validateFields() { with(disposable) { clear() add(validateInput(nameInputLayout, nameEt, { isNameValid(nameEt.text.toString()) })) add(validateInput(surnameInputLayout, surnameEt, { isSurnameValid(surnameEt.text.toString()) })) add(validateInput(emailInputLayout, emailEt, { isEmailValid(emailEt.text.toString()) })) add(validateInput(passwordInputLayout, passwordEt, { isPasswordValid(passwordEt.text.toString()) })) } } private fun isNameValid(name: String) { if (name.isEmpty()) { nameInputLayout.isErrorEnabled = true nameInputLayout.error = getString(R.string.name_validation_error) } else { nameInputLayout.isErrorEnabled = false } } private fun isSurnameValid(surname: String) { if (surname.isEmpty()) { surnameInputLayout.isErrorEnabled = true surnameInputLayout.error = getString(R.string.surname_validation_error) } else { surnameInputLayout.isErrorEnabled = false } } private fun isEmailValid(email: String) { if (!email.contains("@", true)) { emailInputLayout.isErrorEnabled = true emailInputLayout.error = getString(R.string.email_validation_error) } else { emailInputLayout.isErrorEnabled = false } } private fun isPasswordValid(password: String) { if (password.length < 4) { passwordInputLayout.isErrorEnabled = true passwordInputLayout.error = getString(R.string.password_validation_error) } else { passwordInputLayout.isErrorEnabled = false } } }
Выводы
С помощью RxJava (отдельная благодарность RxBinding) мы можем довольно просто реализовать дружественную для пользователя валидацию полей, не жонглируя большим количеством колбэков. Логику проверки, конечно же, лучше вынести в отдельный класс и покрыть его тестами, но это уже другая история.