How to handle onClick or onTouch like events in ViewModel with data binding in MVVM Android

Kavita_p picture Kavita_p · Apr 30, 2019 · Viewed 10.6k times · Source

I have gone through many blogs related to MVVM model with Data Binding. As data binding with ViewModel makes it easy to write junit test cases.

I want to know, how can I implement listener events like OnTouchListener, OnClickListener, OnFocusChangeListener with data binding in the ViewModel which will make writing unit test cases easy.

I have used butter knife library for binding and through that I am performing OnTouch events, my question is, Is it a proper way to implement listeners in Activity instead of directly implementing that in ViewModel? Please refer the following code for LoginScreen with MVVM structure:

LoginActivityNew.java

public class LoginActivityNew extends AppCompatActivity {

@BindView(R.id.et_password)
AppCompatEditText etPassword;

private LoginViewModel loginViewModel;

ActivityLoginBinding binding;
@Override
protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);

        binding = DataBindingUtil.setContentView(this, R.layout.activity_login);
        loginViewModel = ViewModelProviders.of(this).get(LoginViewModel.class);
        binding.setViewModel(loginViewModel);
        binding.setLifecycleOwner(this);

        ButterKnife.bind(this);

        binding.buttonLogin.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                Common common = new Common(getApplicationContext());
                common.isInternetAvailable(LoginActivityNew.this, new Common.InternetStateListener() {
                    @Override
                    public void onNetworkStateObtain(boolean isAvailable) {
                        loginViewModel.getAuthenticateTokenData().observe(LoginActivityNew.this, new Observer<TokenResponse>() {
                            @Override
                            public void onChanged(@Nullable TokenResponse tokenResponse) {
                                if (tokenResponse != null) {
                                    loginResponseHandler(tokenResponse, tokenResponse.getUserName(), tokenResponse.getPassword());
                                } else {
                                    Log.d("jdhadd","TokenResponse == null");
                                }
                            }
                        });
                    }
                });
            }
        });

}


private void loginResponseHandler(final TokenResponse tokenResponse, final String username, final String password) {
    switch (tokenResponse.getState()) {
        case ApiState.LOADING:
            Log.d("testData","Loading");
            break;
        case ApiState.COMPLETED:

            Log.d("testData","COMPLETED");
            break;
        case ApiState.FAILURE:
            Log.d("testData","FAILURE");

            break;
        default:
    }
}

@OnClick(R.id.et_user_name)
void onTouchUserName() {
    loginViewModel.resetEditTextField("username");
}

@OnClick(R.id.et_password)
void onTouchPassword() {
    loginViewModel.resetEditTextField("password");
}
}

LoginViewModel.java

public class LoginViewModel extends AndroidViewModel {


public final MutableLiveData<String> userName = new MutableLiveData<>();
public final MutableLiveData<String> password = new MutableLiveData<>();
public final MutableLiveData<String> userNameError = new MutableLiveData<>();
public final MutableLiveData<String> passwordError = new MutableLiveData<>();
public final MutableLiveData<Boolean> userNameErrorVisibility = new MutableLiveData<>();
public final MutableLiveData<Boolean> passwordErrorVisibility = new MutableLiveData<>();
public final MutableLiveData<Boolean> isViewPasswordIconVisible = new MutableLiveData<>();

private MutableLiveData<TokenResponse> tokenResponse;
private Application application;

public LoginViewModel(@NonNull Application application) {
    super(application);
    this.application = application;
}

public boolean isValidData() {
    boolean isValid = true;

    Log.d("fekjfnew","email = "+userName.getValue()+",, pass = "+password.getValue());

    if (userName.getValue() == null || userName.getValue().equals("")) {

        userNameError.setValue("Invalid Email");
        isValid = false;
        userNameErrorVisibility.setValue(true);

    } else {
        userNameError.setValue(null);
        userNameErrorVisibility.setValue(false);
    }

    if (password.getValue() == null || password.getValue().equals("")) {
        passwordError.setValue("Password too short");
        passwordErrorVisibility.setValue(true);
        isValid = false;

    } else {
        passwordError.setValue(null);
        passwordErrorVisibility.setValue(false);
    }

    return isValid;
}


public MutableLiveData<TokenResponse> getAuthenticateTokenData() {
    tokenResponse = new MutableLiveData<>();
    if(isValidData()) {
    // Call Repository to Perform API operation
    }
    return tokenResponse;
}





public void setPasswordIcon(boolean isVisible) {
    isViewPasswordIconVisible.setValue(isVisible);
}

public void resetEditTextField(String filedName) {

    if(filedName.equals("username"))
        userNameErrorVisibility.setValue(false);
    else if(filedName.equals("password"))
        passwordErrorVisibility.setValue(false);
}
}

activity_login_new.xml

<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
xmlns:app="http://schemas.android.com/apk/res-auto"
tools:context="com.test.views.activities.LoginActivityNew">

<data>
    <import type="android.view.View"/>
    <variable name="viewModel" type="com.test.viewModels.LoginViewModel"/>

</data>

<LinearLayout
    android:padding="40dp"
    android:orientation="vertical"
    android:id="@+id/cl_login"
    android:gravity="center_horizontal"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="#4">


    <android.support.v7.widget.AppCompatTextView
        android:id="@+id/tv_sign_in"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="@string/text_sign_in"
        android:textColor="@color/colorWhite"
        android:textSize="@dimen/login_header_text_size"
        android:layout_marginTop="50dp"
        />

    <android.support.v7.widget.AppCompatEditText
        android:id="@+id/et_user_name"
        android:layout_width="match_parent"
        style="@style/LoginEditTextViewStyle"
        android:layout_marginTop="10dp"
        android:background="@{viewModel.userNameErrorVisibility ? @drawable/bg_error_edit_text : @drawable/bg_edit_text}"
        android:ems="10"
        android:hint="@string/hint_username_email"
        android:imeOptions="actionNext"
        android:transitionName=""
        android:inputType="textPersonName"
        android:paddingStart="20dp"
        android:paddingTop="10dp"
        android:paddingEnd="20dp"
        android:text="@={viewModel.userName}"
        android:paddingBottom="10dp"
        android:layout_height="@dimen/login_height_of_edit_text" />

    <android.support.v7.widget.AppCompatTextView
        android:id="@+id/tv_incorrect_username"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginStart="20dp"
        android:layout_marginTop="10dp"
        android:text="@={viewModel.userNameError}"
        android:textColor="@color/colorErrorText"
        android:textSize="@dimen/wrong_entries_text_size"
        android:visibility="@{viewModel.userNameErrorVisibility ? View.VISIBLE : View.GONE}"
      />

    <android.support.design.widget.TextInputEditText
        android:id="@+id/et_password"
        android:layout_width="match_parent"
        style="@style/LoginEditTextViewStyle"
        android:layout_marginTop="30dp"
        android:background="@{viewModel.passwordErrorVisibility ? @drawable/bg_error_edit_text : @drawable/bg_edit_text}"
        android:ems="10"
        android:text="@={viewModel.password}"
        android:hint="@string/hint_password"
        android:imeOptions="actionDone"
        android:inputType="text"
        android:paddingStart="20dp"
        android:paddingTop="10dp"
        android:paddingEnd="20dp"
        android:paddingBottom="10dp"
        android:layout_height="@dimen/login_height_of_edit_text" />


    <android.support.v7.widget.AppCompatTextView
        android:id="@+id/tv_incorrect_password"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginStart="20dp"
        android:layout_marginTop="10dp"
        android:text="@={viewModel.passwordError}"
        android:textColor="@color/colorErrorText"
        android:textSize="@dimen/wrong_entries_text_size"
        android:visibility="@{viewModel.passwordErrorVisibility ? View.VISIBLE : View.GONE}"
        app:layout_constraintStart_toEndOf="@id/guideline_v1"
        app:layout_constraintTop_toBottomOf="@id/et_password" />

    <android.support.v7.widget.AppCompatButton
        android:id="@+id/button_login"
        android:layout_width="match_parent"
        android:layout_marginBottom="20dp"
        android:background="#FF077DB2"
        android:text="@string/label_sign_in"
        android:textAllCaps="false"
        android:layout_height="@dimen/login_height_of_edit_text"
        android:textColor="#ffffff" />

    <LinearLayout
        android:id="@+id/ll_finger_print"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginTop="10dp"
        android:gravity="center"
        android:orientation="horizontal"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        android:visibility="gone"
        app:layout_constraintTop_toBottomOf="@id/button_login">

        <android.support.v7.widget.AppCompatImageView
            android:layout_width="24dp"
            android:layout_height="24dp"
            android:src="@drawable/ic_fingerprint" />

        <android.support.v7.widget.AppCompatTextView
            android:id="@+id/text_fingerprint"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginStart="10dp"
            android:text="@string/text_fingerprint_id"
            android:textColor="@color/colorWhite"
            android:textSize="@dimen/fingerprint_id_text_size"
            app:layout_constraintStart_toEndOf="@id/guideline_v7"
            app:layout_constraintTop_toBottomOf="@id/button_login" />
    </LinearLayout>
</LinearLayout>

styles.xml

<style name="LoginEditTextViewStyle" parent="android:Theme">
    <item name="android:paddingStart">20dp</item>
    <item name="android:paddingEnd">20dp</item>
    <item name="android:paddingTop">10dp</item>
    <item name="android:paddingBottom">10dp</item>
    <item name="android:textColor">@color/colorWhite</item>
    <item name="android:textColorHint">@color/colorWhiteWithThirtyTransparency</item>
    <item name="android:background">@drawable/bg_edit_text</item>
    <item name="android:textSize">@dimen/login_edit_text_size</item>
</style>

Answer

First of all, the code of your click listener contains application logic and should not be in the view, but in the viewmodel (for example, you could add a public method called login() to your viewmodel and handle the login logic inside it).

Second, in order to bind the click event to the method, you can do it in the XML file of your layout:

<android.support.v7.widget.AppCompatButton
    android:id="@+id/button_login"
    ...
    android:onClick="@{() -> viewModel.login()}" />

Then, in the unit tests you can invoke the method login() in order to test it.

On the other hand, to bind callbacks that are not directly available in XML, such as OnTouch, you can create adapters to make them available:

object MyAdapters {

    ...

    @JvmStatic
    @BindingAdapter("onTouch")
    fun setTouchListener(view: View, callback: () -> Boolean) {
        view.setOnTouchListener { v, event -> callback() }
    }
}
<android.support.v7.widget.AppCompatButton
    android:id="@+id/button_login"
    ...
    app:onTouch="@{() -> viewModel.methodThatReturnsABoolean()}" />

Please note that you cannot get the MotionEvent value of the OnTouchListener with the code shown above. If you need it, then you will have to implement your adapter differently:

object MyAdapters {

    ...

    @JvmStatic
    @BindingAdapter("onTouchListener")
    fun setTouchListener(view: View, listener: OnTouchListener) {
        view.setOnTouchListener(listener)
    }
}
<android.support.v7.widget.AppCompatButton
    android:id="@+id/button_login"
    ...
    app:onTouchListener="@{viewModel.onTouchListener}" />