Реактивний підхід до валідації полів введення на 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.Вами 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 element to each disposable = Вами.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 в Вами, поєднуєте ці потоки даних і слухаєте їх останні зміни, застосовуючи логіку валідації до кожного з них.
Якщо ми не будемо прив'язуватися до самої логіці валідації, то виходить наступна картина.
Плюси:
- Нам не потрібно возитися з TextWatcher'амі - наш код став більш читабельним, тому що відсутня «шум» з-за колбэков.
- У нас є що-то на кшталт валідації в реальному часі — наш користувач відразу розуміє, яке зазвичай накладається на яке поле.
Мінуси:
- Якщо у нас в команді є UX-спеціаліст або нам самим притаманний цей дар, то ми можемо помітити, що це рішення виглядає жахливо для звичайного користувача. Користувач відкриває екран логіна/реєстрації і перше, що бачить помилку введення на першому рядку. Він ще не почав вводити інформацію в друге поле, як там відразу ж з'являється помилка валідації і т. д.
Звичайно ж, ми хочемо надавати нашому користувачеві максимально гладкий досвід використання при взаємодії з необхідними (нам) полями.
План валідації
Перед тим як почати, давайте складемо невелику карту-план, що нам необхідно зробити, щоб реалізувати максимально дружню валідацію полів:
- Якщо користувач відкрив екран логіна/реєстрації, то він не повинен бачити ніяких помилок — ніякої інформації в поля ще не надходило, тому нам не про що скаржитися.
- Якщо користувач тільки почав вводити дані в полі, то ми не повинні показувати ніякої помилки. Якщо покажемо, то тільки даремно відірвемо користувача від процесу введення (ми взагалі повинні бути дуже вдячними, що користувач довіряє нашого додатком настільки, що вирішив поділитися своєю персональною інформацією з нами).
- Користувач закінчив вводити дані в поточний поле і перейшов до наступного? Тепер-то ми і повинні перевірити поточне поле на утримання помилок.
- Користувач повернувся на поле, яке містить помилку і почав її виправляти? Ми повинні приховати помилку, тому що користувач досить розумний, і ми не повинні набридати йому цією помилкою.
З інженерної точки зору, ми хочемо код без колбэков, який досить легко читати.
Реалізація
Якщо ви уважно читали план, описаний вище, то, напевно, помітили, що, як інженер, ви повинні якось маніпулювати фокусом EditText'а і подіями введення . Коли наше уявлення у фокусі, то ми повинні відключити для нього валідацію. Коли фокус воно втратило, то перевірка повинна бути застосована і повинні бути показані помилки (якщо в цьому є необхідність). Якщо користувач повернувся до цього поданням (воно знову у фокусі) і почав виправляти помилку (подання отримує події вводу), то ми повинні приховати помилку.
Давайте подивимося бібліотеку RxBinding і спробуємо знайти методи, які допоможуть вирішити цю задачу.
В першу чергу, там є метод focusChanges(View view), який знаходиться в класі RxView. Його призначення, як нескладно здогадатися, — спостереження за подіями фокуса. Якщо ви зараз спробуєте використовувати цей метод, то помітите, що ви отримаєте івент фокуса моментально, і це буде порушенням нашого 1-го правила в плані, який ми склали вище. В бібліотеці RxBinding є метод skipInitialValue(), який дозволить нам пропустити цей перший івент під час підписки.
Тепер ми повинні застосовувати логіку валідації, коли наш EditText втрачає фокус. Тут нам допоможе звичайний map(), використовуючи який ми можемо визначити момент, коли наше уявлення втратило фокус, і виконати блок валідації (лямбда-вираз, тому що, звичайно ж, кожне поле може мати свій вид перевірки).
fun validateInput(inputView: TextView, body: () -> Unit): Disposable { return RxView.focusChanges(inputView) // Listen for events focus .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 { } }
Тепер нам необхідно вирішити 4-й пункт нашого плану. Ми повинні ховати помилку, коли користувач повернувся до поля і почав виправляти помилку. Для цього нам необхідно передати посилання на 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 text on 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 text on 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) ми можемо досить просто реалізувати дружній для користувача валідацію полів, не жонглюючи великою кількістю колбэков. Логіку перевірки, звичайно ж, краще винести в окремий клас і покрити його тестами, але це вже інша історія.
Опубліковано: 08/06/18 @ 10:17
Розділ Різне
Рекомендуємо:
DOU Проектор: CleverStaff — сервіс для автоматизації рекрутингу
Не малюванням єдиним: навіщо дизайнеру розуміти бізнес замовника і впливати на продукт
Я, девелопер
Хто, де і як буде вчити тестувальників в Києві в 2026 році
Финстрип за Травень 2018. Просів з 80 до 60К