18

What is the best approach to validate form data using ViewModel and Databinding?

I have a simple Sign-Up activity that links binding layout and ViewModel

class StartActivity : AppCompatActivity() {

    private lateinit var binding: StartActivityBinding
    private lateinit var viewModel: SignUpViewModel

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        viewModel = ViewModelProviders.of(this, SignUpViewModelFactory(AuthFirebase()))
                .get(SignUpViewModel::class.java);

        binding = DataBindingUtil.setContentView(this, R.layout.start_activity)
        binding.viewModel = viewModel;

        signUpButton.setOnClickListener {

        }
    }
}

ViewModel with 4 ObservableFields and signUp() method that should validate data before submitting data to the server.

class SignUpViewModel(val auth: Auth) : ViewModel() {
    val name: MutableLiveData<String> = MutableLiveData()
    val email: MutableLiveData<String> = MutableLiveData()
    val password: MutableLiveData<String> = MutableLiveData()
    val passwordConfirm: MutableLiveData<String> = MutableLiveData()

    fun signUp() {

        auth.createUser(email.value!!, password.value!!)
    }
}

I guess we can add four boolean ObservableFields for each input, and in signUp() we can check inputs and change state of boolean ObservableField that will produce an appearing error on layout

val isNameError: ObservableField<Boolean> = ObservableField()


fun signUp() {

        if (name.value == null || name.value!!.length < 2 ) {
            isNameError.set(true)
        }

        auth.createUser(email.value!!, password.value!!)
    }

But I am not sure that ViewModel should be responsible for validation and showing an error for a user and we will have boilerplate code

<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto">

    <data>

        <import type="android.view.View" />

        <variable
            name="viewModel"
            type="com.maximdrobonoh.fitnessx.SignUpViewModel" />
    </data>

    <android.support.constraint.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:background="@color/colorGreyDark"
        android:orientation="vertical"
        android:padding="24dp">

        <TextView
            android:id="@+id/appTitle"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_marginEnd="8dp"
            android:layout_marginStart="8dp"
            android:layout_marginTop="8dp"
            android:text="@string/app_title"
            android:textColor="@color/colorWhite"
            android:textSize="12sp"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent" />

        <LinearLayout
            android:id="@+id/screenTitle"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_marginEnd="8dp"
            android:layout_marginStart="8dp"
            android:orientation="horizontal"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@+id/appTitle">

            <TextView
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_marginEnd="4dp"
                android:text="@string/sign"
                android:textColor="@color/colorWhite"
                android:textSize="26sp"
                android:textStyle="bold" />

            <TextView
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:text="@string/up"
                android:textColor="@color/colorWhite"
                android:textSize="26sp" />
        </LinearLayout>

        <LinearLayout
            android:id="@+id/form"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_marginEnd="8dp"
            android:layout_marginStart="8dp"
            android:layout_marginTop="24dp"
            android:orientation="vertical"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@+id/screenTitle">

            <android.support.v7.widget.AppCompatEditText
                style="@style/SignUp.InputBox"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:hint="@string/sign_up_name"
                android:inputType="textPersonName"
                android:text="@={viewModel.name}" />

            <android.support.v7.widget.AppCompatEditText
                style="@style/SignUp.InputBox"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:hint="@string/sign_up_email"
                android:inputType="textEmailAddress"
                android:text="@={viewModel.email}"
               />

            <android.support.v7.widget.AppCompatEditText
                style="@style/SignUp.InputBox"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:hint="@string/sign_up_password"
                android:inputType="textPassword"
                android:text="@={viewModel.password}" />

            <android.support.v7.widget.AppCompatEditText
                style="@style/SignUp.InputBox"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:hint="@string/sign_up_confirm_password"
                android:inputType="textPassword"
                android:text="@={viewModel.passwordConfirm}" />

            <Button
                android:id="@+id/signUpButton"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:layout_marginTop="16dp"
                android:background="@drawable/button_gradient"
                android:text="@string/sign_up_next_btn"
                android:textAllCaps="true"
                android:textColor="@color/colorBlack" />

        </LinearLayout>

    </android.support.constraint.ConstraintLayout>
</layout>
Pavneet_Singh
  • 36,884
  • 5
  • 53
  • 68
Maxim Drobonog
  • 195
  • 1
  • 2
  • 9

5 Answers5

21

There can be many ways to implement this. I am telling you two solutions, both works well, you can use which you find suitable for you.

I use extends BaseObservable because I find that easy than converting all fields to Observers. You can use ObservableFields too.

Solution 1 (Using custom BindingAdapter)

In xml

<variable
    name="model"
    type="sample.data.Model"/>

<EditText
    passwordValidator="@{model.password}"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:text="@={model.password}"/>

Model.java

public class Model extends BaseObservable {
    private String password;

    @Bindable
    public String getPassword() {
        return password;
    }

    public void setPassword(String password) {
        this.password = password;
        notifyPropertyChanged(BR.password);
    }
}

DataBindingAdapter.java

public class DataBindingAdapter {
    @BindingAdapter("passwordValidator")
    public static void passwordValidator(EditText editText, String password) {
        // ignore infinite loops
        int minimumLength = 5;
        if (TextUtils.isEmpty(password)) {
            editText.setError(null);
            return;
        }
        if (editText.getText().toString().length() < minimumLength) {
            editText.setError("Password must be minimum " + minimumLength + " length");
        } else editText.setError(null);
    }
}

Solution 2 (Using custom afterTextChanged)

In xml

<variable
    name="model"
    type="com.innovanathinklabs.sample.data.Model"/>

<variable
    name="handler"
    type="sample.activities.MainActivityHandler"/>

<EditText
    android:id="@+id/etPassword"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:afterTextChanged="@{(edible)->handler.passwordValidator(edible)}"
    android:text="@={model.password}"/>

MainActivityHandler.java

public class MainActivityHandler {
    ActivityMainBinding binding;

    public void setBinding(ActivityMainBinding binding) {
        this.binding = binding;
    }

    public void passwordValidator(Editable editable) {
        if (binding.etPassword == null) return;
        int minimumLength = 5;
        if (!TextUtils.isEmpty(editable.toString()) && editable.length() < minimumLength) {
            binding.etPassword.setError("Password must be minimum " + minimumLength + " length");
        } else {
            binding.etPassword.setError(null);
        }
    }
}

MainActivity.java

public class MainActivity extends AppCompatActivity {
    ActivityMainBinding binding;

    @Override
    protected void onCreate(@Nullable Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        binding = DataBindingUtil.setContentView(this, R.layout.activity_main);
        binding.setModel(new Model());
        MainActivityHandler handler = new MainActivityHandler();
        handler.setBinding(binding);
        binding.setHandler(handler);
    }
}

Update

You can also replace

android:afterTextChanged="@{(edible)->handler.passwordValidator(edible)}"

with

android:afterTextChanged="@{handler::passwordValidator}"

Because parameter are same of android:afterTextChanged and passwordValidator.

Khemraj Sharma
  • 57,232
  • 27
  • 203
  • 212
10

This approach uses TextInputLayouts, a custom binding adapter, and creates an enum for form errors. The result I think reads nicely in the xml, and keeps all validation logic inside the ViewModel.

The ViewModel:

class SignUpViewModel() : ViewModel() {

   val name: MutableLiveData<String> = MutableLiveData()
   // the rest of your fields as normal

   val formErrors = ObservableArrayList<FormErrors>()

   fun isFormValid(): Boolean {
      formErrors.clear()
      if (name.value?.isNullOrEmpty()) {
          formErrors.add(FormErrors.MISSING_NAME)
      }
      // all the other validation you require
      return formErrors.isEmpty()
   }

   fun signUp() {
      auth.createUser(email.value!!, password.value!!)
   }

   enum class FormErrors {
      MISSING_NAME,
      INVALID_EMAIL,
      INVALID_PASSWORD,
      PASSWORDS_NOT_MATCHING,
   }

}

The BindingAdapter:

@BindingAdapter("app:errorText")
fun setErrorMessage(view: TextInputLayout, errorMessage: String) {
    view.error = errorMessage
}

The XML:

<layout>

  <data>

        <import type="com.example.SignUpViewModel.FormErrors" />

        <variable
            name="viewModel"
            type="com.example.SignUpViewModel" />

  </data>

<!-- The rest of your layout file etc. -->

       <com.google.android.material.textfield.TextInputLayout
            android:id="@+id/text_input_name"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            app:errorText='@{viewModel.formErrors.contains(FormErrors.MISSING_NAME) ? "Required" : ""}'>

            <com.google.android.material.textfield.TextInputEditText
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:hint="Name"
                android:text="@={viewModel.name}"/>

        </com.google.android.material.textfield.TextInputLayout>

<!-- Any other fields as above format -->

And then, the ViewModel can be called from activity/fragment as below:

class YourActivity: AppCompatActivity() {

   val viewModel: SignUpViewModel
  // rest of class

  fun onFormSubmit() {
     if (viewModel.isFormValid()) {
        viewModel.signUp()
        // the rest of your logic to proceed to next screen etc.
     }
     // no need for else block if form invalid, as ViewModel, Observables
     // and databinding will take care of the UI
  }


}
Vin Norman
  • 2,749
  • 1
  • 22
  • 33
  • 1
    Oh my god, thank you so much. I love you for providing this code. I've tried to implement it my own and wrote over 100 lines and it didn't work. This works like a charm!!! Just one question: How can I get from my strings and put it inside the xml app:errorText? – Andrew Sep 04 '20 at 22:00
  • 1
    @Andrew the **context** variable is generated for use in binding expressions you dont have to import it, just import **R** and used it like that app:errorText=@{ context.getString(R.string.email_required) } – ben khedher mahmoud Oct 26 '20 at 12:23
  • 1
    @Andrew and @ben-khedher-mahmoud it's actually even easier than that. You can access your string resources directly in xml, so it may look a little like `app:errorText='@{viewModel.formErrors.contains(FormErrors.MISSING_NAME) ? @string/your_error_string : ""}'` – Vin Norman Oct 29 '20 at 14:55
2

I've written a library for validating bindable fields of an Observable object.

Setup your Observable model:

class RegisterUser:BaseObservable(){
@Bindable
var name:String?=""
    set(value) {
        field = value
        notifyPropertyChanged(BR.name)
    }

@Bindable
var email:String?=""
    set(value) {
        field = value
        notifyPropertyChanged(BR.email)
    }

}

Instantiate and add rules

class RegisterViewModel : ViewModel() {

var user:LiveData<RegisterUser> = MutableLiveData<RegisterUser>().also {
    it.value = RegisterUser()
}

var validator = ObservableValidator(user.value!!, BR::class.java).apply {
    addRule("name", ValidationFlags.FIELD_REQUIRED, "Enter your name")

    addRule("email", ValidationFlags.FIELD_REQUIRED, "Enter your email")
    addRule("email", ValidationFlags.FIELD_EMAIL, "Enter a valid email")

    addRule("age", ValidationFlags.FIELD_REQUIRED, "Enter your age (Underage or too old?)")
    addRule("age", ValidationFlags.FIELD_MIN, "You can't be underage!", limit = 18)
    addRule("age", ValidationFlags.FIELD_MAX, "You sure you're still alive?", limit = 100)

    addRule("password", ValidationFlags.FIELD_REQUIRED, "Enter your password")

    addRule("passwordConfirmation", ValidationFlags.FIELD_REQUIRED, "Enter password confirmation")
    addRule("passwordConfirmation", ValidationFlags.FIELD_MATCH, "Passwords don't match", "password")
}

}

And setup your xml file:

<com.google.android.material.textfield.TextInputLayout
style="@style/textFieldOutlined"
error='@{viewModel.validator.getValidation("email")}'
android:layout_width="match_parent"
android:layout_height="wrap_content">

<com.google.android.material.textfield.TextInputEditText
    android:id="@+id/email"
    style="@style/myEditText"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:hint="Your email"
    android:imeOptions="actionNext"
    android:inputType="textEmailAddress"
    android:text="@={viewModel.user.email}" />

Thalis Vilela
  • 335
  • 1
  • 10
0

What you have in mind is right, actually. The viewmodel should not know anything about the android system and will only work with pure java/kotlin. Thus, doing what you are thinking of is right. ViewModel's shouldn't know about the android system as all view interactions should be handled on the View. But, their properties can be bounded to the view.

TL;DR

This will work

fun signUp() {

    if (name.value == null || name.value!!.length < 2 ) {
        isNameError.set(true)
    }

    auth.createUser(email.value!!, password.value!!)
}


Suggestion

I would suggest, if you would like to dig in deeper, you could use Custom Binding Adapters. This way you:

  • won't need additional variables to your view model
  • have a cleaner view model since the error handling is on the custom binding adapter
  • would easier read on your XML views as you could define there the validations you need

I'll let your imagination fly on how you could make the custom binding adapter only have the validations. For now, it's better to understand the basics of custom binding adapters.

Cheers

Jian Astrero
  • 744
  • 6
  • 19
0

Yes, you can use your validation logic from ViewModel, because you're having your observable variables from ViewModel & your xml is also deriving data from ViewModel class also.

So, Solution :

  • You can create @BindingAdapter in ViewModel and bind your button click with it. Check your validation there and do some other stuffs also.

  • You can create Listener, and implement it on ViewModel to receive clicks from button and bind that listener to xml.

  • You can use Two-Way data binding also (Be aware of infinite loops though).

    //Let's say it's your binding adapter from ViewModel
    fun signUp() {
       if (check validation logic) {
          // Produce errors
       }
       // Further successful stuffs
    }
    
Jeel Vankhede
  • 11,592
  • 2
  • 28
  • 58