Android: Validate your form fields using Kotlin StateFlow

Android: Validate your form fields using Kotlin StateFlow

·

7 min read

Form validation is important in providing users with a good user experience. Let's see how we can implement the form validation using Kotlin StateFlow in a smart and easy way!

Note: This article is about android XML layouts!

Prerequisites

1) Create form layout

Create the XML layout for the form UI

Here we are creating a sign-in form which contains email and password fields.

  • Create a 'viewModel' variable to bind the ViewModel reference to the layout.

  • With the ViewModel reference, we can access the variables in the ViewModel which we gonna hold the field values (text input). (Two-way data binding - Check android:text attribute.)

Note:

We have used Material Design Components to create the views since it has a lot of features like error text which is very convenient for us to show errors in the field.

<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">

    <data>

        <variable
            name="viewModel"
            type="io.github.dhina17.justmyfinance.ui.signin.SignInViewModel" />
    </data>

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:gravity="center"
        android:orientation="vertical">

        <com.google.android.material.textfield.TextInputLayout
            android:id="@+id/emailContainer"
            style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:hint="Email"
            android:paddingHorizontal="20dp"
            android:paddingVertical="10dp">

            <com.google.android.material.textfield.TextInputEditText
                android:id="@+id/emailField"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:inputType="textEmailAddress"
                android:text="@={viewModel.email}" />
        </com.google.android.material.textfield.TextInputLayout>

        <com.google.android.material.textfield.TextInputLayout
            android:id="@+id/passwordContainer"
            style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:hint="Password"
            android:paddingHorizontal="20dp"
            android:paddingVertical="10dp">

            <com.google.android.material.textfield.TextInputEditText
                android:id="@+id/passwordField"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:inputType="textPassword"
                android:text="@={viewModel.password}" />
        </com.google.android.material.textfield.TextInputLayout>

        <com.google.android.material.button.MaterialButton
            android:id="@+id/signInButton"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_gravity="center"
            android:layout_marginHorizontal="20dp"
            android:layout_marginVertical="10dp"
            android:paddingVertical="15dp"
            android:text="Sign In"
            tools:text="Sign In" />

    </LinearLayout>

</layout>

2) Create a fragment with a ViewModel

  • Create a ViewModel named "SignInViewModel.kt' with two MutableStateFlow<String> fields named 'email' and 'password' to hold the respective text input from the UI (form fields).

  • We already bound the ViewModel fields to the respective TextInputEditText widget (view). (See the above XML code)

  • Create a fragment named 'SignInFragment.kt' and inflate the view using data binding.

  • Initialize the ViewModel and bind it to the layout. (In simple words, assign the created ViewModel reference to the variable present in the XML layout)

// SignInViewModel.kt
package io.github.dhina17.justmyfinance.ui.signin

import androidx.lifecycle.ViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import javax.inject.Inject

@HiltViewModel
class SignInViewModel @Inject constructor() : ViewModel() {
    val email = MutableStateFlow("")
    val password = MutableStateFlow("")
}
// SignInFragment.kt
package io.github.dhina17.justmyfinance.ui.signin

import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import dagger.hilt.android.AndroidEntryPoint
import io.github.dhina17.justmyfinance.databinding.FragmentSignInBinding

@AndroidEntryPoint
class SignInFragment : Fragment() {

    private lateinit var binding: FragmentSignInBinding

    private val viewModel: SignInViewModel by viewModels()

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        // Inflate the view and bind the view model to the UI
        binding = FragmentSignInBinding.inflate(inflater, container, false)
        // We have create a variable named 'viewModel' in the layout.
        // Assign a value to it.
        binding.viewModel = viewModel
        return binding.root
    }
}

Note: Here I have used Hilt for dependency Injection so ViewModel creation is much easier and no boilerplates. Don't confuse with it. You can use your own way to initialize the ViewModel**.**

You can also create an activity instead of a fragment.

3) Combine fields and validate

In your 'SignInFragment.kt',

  • Combine all the form fields (StateFlow) to create a new boolean flow with producer code containing the form validation logic. This boolean flow named 'isFormValidFlow' emits true if all the form fields are valid otherwise false.

  • Here I have checked only if the fields are empty or not. You can use your form validation logic here.

private val isFormValidFlow: Flow<Boolean> by lazy {
        // Combine the fields to create a flow for form validation
        combine(
            viewModel.email,
            viewModel.password
        ) { email, password ->
            // Check your validation here
            // Here I simply check if it's empty or not
            val isEmailValid = email.isNotEmpty()
            val isPasswordValid = password.isNotEmpty()
            // Return the boolean value indicates if the form fields are valid or not
            isEmailValid && isPasswordValid
        }
    }

4) Collect the form validation flow

As we know, the producer code of a flow will run on its collection (terminal operator like collect(), etc ) since it's cold in nature.

In SignInFragment's onViewCreated() method,

  • On the sign-in button click, collect the form validation flow so the validation code will run with the current values of the email and password fields.

  • Depending on the validation value (boolean), proceed with your sign-in process if it's true otherwise simply show the common error for all fields like a toast.

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {

        binding.signInButton.setOnClickListener {
            lifecycleScope.launch {
                // For us, it's enough to run the isFormValidFlow producer code only once on sign in button click.
                // since we don't want to collect the values continuously, use first() instead of collect()
                val isFormValid = isFormValidFlow.first()
                if (isFormValid) {
                    // Proceed to sign in
                } else {
                    Toast.makeText(requireContext(), "Form fields are invalid", Toast.LENGTH_SHORT)
                        .show()
                }
            }
        }
    }

Now the form validation part is done. However, showing errors via toast is not a good practice for the form fields. We have to show the error on the respective field, right?

Let's do it.

5) Show errors on the fields

As we know we are using Material Design Components to take the advantage of its own error text attr of a TextInputLayout to show the field errors.

  • For our convenience, create an extension function for TextInputLayout to set the error text.

  • In the 'isFormValidFlow' producer code, set the error text based on the field. (Since it will run whenever the Flow is collected)

private val isFormValidFlow: Flow<Boolean> by lazy {
        // Combine the fields to create a flow for form validation
        combine(
            viewModel.email,
            viewModel.password
        ) { email, password ->
            // Check your validation here
            // Here I simply check if it's empty or not
            val isEmailValid = email.isNotEmpty()
            val isPasswordValid = password.isNotEmpty()
            with(binding) {
                val errorText = "Please fill out this field."
                // See the 'not' operator
                // since if the field is valid, error is false (vice versa)
                emailContainer.setFieldError(!isEmailValid, errorText)
                passwordContainer.setFieldError(!isPasswordValid, errorText)
            }
            // Return the boolean value indicates if the form fields are valid or not
            isEmailValid && isPasswordValid
        }
    }

    /**
     * Set the given error text to the TextInputLayout based on isError.
     *
     * @param errorText Error Text which need to be set.
     * @param isError If it's error or not.
     */
    private fun TextInputLayout.setFieldError(isError: Boolean, errorText: String) {
        error = if (isError) errorText else null
    }

Note:

We have validated only if the field is empty or not. You can use your own validation and set error text based on that easily. Like this way.

private val isFormValidFlow: Flow<Boolean> by lazy {
        // Combine the fields to create a flow for form validation
        combine(
            viewModel.email,
            viewModel.password
        ) { email, password ->
            // Check your validation here
            // Here I simply check if it's empty or not
            val isEmailValid = email.isNotEmpty()
            val isPasswordFilled = password.isNotEmpty()

            // Check if the password length is less than 8.
            val isPasswordValidLength = password.length >= 8

            with(binding) {
                val errorText = "Please fill out this field."
                val passwordLengthError = "Password must be 8 or more characters"
                // See the 'not' operator
                // since if the field is valid, error is false (vice versa)
                emailContainer.setFieldError(!isEmailValid, errorText)
                passwordContainer.setFieldError(!isPasswordFilled, errorText)
                // Check if the password field is filled or not. then proceed for length check.
                // Here use your own logic to show the error properly.
                if (isPasswordFilled) {
                    // Set error for password length
                    passwordContainer.setFieldError(!isPasswordValidLength, passwordLengthError)
                }
            }
            // Return the boolean value indicates if the form fields are valid or not
            isEmailValid && isPasswordFilled && isPasswordValidLength
        }
    }

Note: Use your logic to show the error properly without overriding one error with another error.

Now you can remove the toast since we show the error in the fields itself.

You may notice that, even after filling something in the field, still the error shows until you click the sign-in button again, right?

It's fine for most cases.

However, If you want to show real-time updates to the form fields after clicking the sign-in button for the first time,

  • In the onViewCreated() method, Collect the 'isFormValidFlow' outside the sign-in button click listener lambda and store the validation boolean value in a local variable.

  • Lazily start the collection job once after the sign-in button clicked for the first time. In this way, you can see real-time updates to the fields.

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        val validationJob = lifecycleScope.launch(start = CoroutineStart.LAZY) {
            isFormValidFlow.collectLatest { isValid ->
                isFormValid = isValid
            }
        }

        binding.signInButton.setOnClickListener {
            // Start the job only on click
            validationJob.start()
            if (isFormValid) {
                // Proceed to sign in
            } else {
                Toast.makeText(requireContext(), "Form fields are invalid", Toast.LENGTH_SHORT)
                    .show()
            }
        }
    }

Now you know how to validate the form fields in a simple way.

If you have any doubts or you know of any other way to validate the form fields, please let me know in the comment section.

Thanks for reading! ❤️

Dhina17

Did you find this article valuable?

Support Android with Dhina17 by becoming a sponsor. Any amount is appreciated!