Care.Card UX Contest: React Native + UX Design for better healthcare. Join Now & Win 100 000 SOLVE!
×Закрыть

Реактивный подход к валидации полей ввода на 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-специалист или нам самим присущ этот дар, то мы можем заметить, что это решение выглядит ужасно для обычного пользователя. Пользователь открывает экран логина/регистрации и первое, что видит  - ошибку ввода на первой строке. Он еще не начал вводить информацию во второе поле, как там сразу же появляется ошибка валидации и т. д.

Конечно же, мы хотим предоставлять нашему пользователю максимально гладкий опыт использования при взаимодействии с необходимыми (нам) полями.

План валидации

Перед тем как начать, давайте составим небольшую карту-план, что нам необходимо сделать, чтобы реализовать максимально дружественную валидацию полей:

  1. Если пользователь открыл экран логина/регистрации, то он не должен видеть никаких ошибок —  никакой информации в поля ещё не поступало, поэтому нам не о чем жаловаться.
  2. Если пользователь только начал вводить данные в поле, то мы не должны показывать никакой ошибки. Если покажем, то только зря отвлечем пользователя от процесса ввода (мы вообще должны быть безумно благодарными, что пользователь доверяет нашему приложению настолько, что решил поделиться своей персональной информацией с нами).
  3. Пользователь закончил вводить данные в текущее поле и перешел к следующему? Теперь-то мы и должны проверить текущее поле на содержание ошибок.
  4. Пользователь вернулся на поле, которое содержит ошибку и начал её исправлять? Мы должны скрыть ошибку, потому что пользователь достаточно умен, и мы не должны надоедать ему этой ошибкой.

С инженерной точки зрения, мы хотим код без колбэков, который достаточно легко читать.

Реализация

Если вы достаточно внимательно читали план, описанный выше, то, наверное, заметили, что, как инженер, вы должны как-то манипулировать фокусом EditText’а и событиями ввода . Когда наше представление в фокусе, то мы должны отключить для него валидацию. Когда фокус оно потеряло, то проверка должна быть применена и должны быть показаны ошибки (если в этом есть необходимость). Если пользователь вернулся к этому представлению (оно снова в фокусе) и начал исправлять ошибку (представление получает события ввода), то мы должны скрыть ошибку.

Давайте посмотрим библиотеку RxBinding и попробуем найти методы, которые помогут решить эту задачу.

В первую очередь, там есть метод focusChanges(View view), который находится в классе RxView. Его предназначение, как несложно догадаться, — наблюдение за событиями фокуса. Если вы сейчас попробуете использовать этот метод, то заметите, что вы получите ивент фокуса моментально, и это будет нарушением нашего 1-го правила в плане, который мы составили выше. В библиотеке 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 { }
}

Теперь нам необходимо решить 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 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) мы можем довольно просто реализовать дружественную для пользователя валидацию полей, не жонглируя большим количеством колбэков. Логику проверки, конечно же, лучше вынести в отдельный класс и покрыть его тестами, но это уже другая история.

LinkedIn

11 комментариев

Подписаться на комментарииОтписаться от комментариев Комментарии могут оставлять только пользователи с подтвержденными аккаунтами.

Спасибо за статью! Обязательно реализую и протестирую этот подход. Надеюсь будут еще статьи, хотелось бы увидеть с какими задачами сталкиваются и какие решения и подходы принимаю на практике)

Обзор либы на ДОУ? Чего? Что дальше? Как включать компьютер?

Может звучать по-хипстерски, но подход уже не очень актуальный.
Всё больше разработчиков полностью выпиливают из приложений громоздкий Rx и используют корутины. Особенно а связке с адаптером для Retrofit от Вортана.
Новые проекты на Rx уже не так часто встречаются, как раньше.

Зачем нужен Котлин, если есть Flutter?

зачем нужна вилка, если есть ложка?

наш код стал более читаемым
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
        }
Ага, настільки читабельний, що йому навіть коментар прийшлося написати. :D

Спасибо за статью, но я тут виже несколько проблемных мест.
1. Из-за flatMap при каждом изменнеии фокуса на поле будет создаваться еще один слушатель изменения текста и так может разростись до бесконечности :)

.flatMap { hasFocus ->

Если заменить flatMap на switchMap то тогда твой блок .doOnEach { body() } никогда не вызовится ибо поле больше не будет ничего эмитить :)
2. Исходя из твоего примера поле Password никогда не будет проверено из-за того что поле password последнее и фокус никуда не уходит :)

Спасибо за конструктивный фидбэк!
1. Да, действительно, будет создаваться ещё один слушатель, но, как только произойдёт вызов dispose() на CompositeDisposable инстансе, должна произойти от них отписка.
2. Тоже верно. Можно проводить допольнительный чек по полям во время нажатия на кнопку (если у вас стандартный registration flow — n-полей и кнопка register, например).

1. Да, действительно, будет создаваться ещё один слушатель, но, как только произойдёт вызов dispose() на CompositeDisposable инстансе, должна произойти от них отписка.

Это не нормальное поведение кода :) А что если там делается валидация через Backend :)

Подписаться на комментарии