4

I am refactoring my Android app for an University project to use Architecture Components and I am having a hard time implementing two-way data binding on a SwitchCompat. The app has got a simple user interface with a TextView displaying the status of location updates and the aforementioned SwitchCompat, which toggles on and off location updates.
For now I am using one-way data binding on the SwitchCompat's checked attribute, but would like to use two-way databinding.
The current implementation, using a Model-View-ViewModel architecture is the following:
MainViewModel.java:

public class MainViewModel extends ViewModel {

    private LiveData<Resource<Location>> mLocationResource;

    public MainViewModel() {
        mLocationResource = Repository.getInstance().getLocationResource();
    }

    public LiveData<Resource<Location>> getLocationResource() {
        return mLocationResource;
    }

    public void onCheckedChanged (Context context, boolean isChecked) {
        if (isChecked) {
            Repository.getInstance().requestLocationUpdates(context);
        } else {
            Repository.getInstance().removeLocationUpdates(context);
        }
    }
}

Resource<Location> (saw the idea here) is a class holding nullable data (Location) and a non null state the TextView can handle:
State.java

public enum State {
    LOADING,
    UPDATE,
    UNKNOWN,
    STOPPED
}

And now the android:onCheckedChanged implementation in fragment_main.xml:

android:onCheckedChanged="@{(buttonView, isChecked) -> viewModel.onCheckedChanged(context, isChecked)}"

And finally the custom binding adapter to convert from state to boolean checked state:

@BindingAdapter({"android:checked"})
public static void setChecked(CompoundButton view, Resource<Location> locationResource) {
    State state = locationResource.getState();
    boolean checked;
    if (state == State.STOPPED) {
        checked = false;
    } else {
        checked = true;
    }
    if (view.isChecked() != checked) {
        view.setChecked(checked);
    }
}

and the implementation of the android:checked attribute in fragment_main.xml:

android:checked="@{viewModel.getLocationResource}"

As the Android Developers guide I linked above said, how can I do all the work inside android:checked instead of having both android:checked and android:onCheckedChanged (one-way databinding to two-way data binding)?
Also, please let me know if you think the architecture/logic of my app can be improved :)

y k
  • 716
  • 1
  • 10
  • 25

2 Answers2

1

Here is how I would do it (sorry for the Kotlin code):

First I would refactor the Resource<T> class and make the state variable a MutableLiveData<State> object:

enum class State {
    LOADING,
    UPDATE,
    UNKNOWN,
    STOPPED
}

class Resource<T>() {
    var state = MutableLiveData<State>().apply { 
        value = State.STOPPED //Setting the initial value to State.STOPPED
    }
}

Then I would create the following ViewModel:

class MainViewModel: ViewModel() {

     val locationResource = Resource<Location>()

}

In the databinding layout I would write the following:

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

    <data>

        <variable
            name="viewModel"
            type="MainViewModel" />

    </data>

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

        <androidx.appcompat.widget.SwitchCompat
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            app:resourceState="@={viewModel.locationResource.state}" />

        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="@{String.valueOf(viewModel.locationResource.state)}" />

    </LinearLayout>

</layout>

Note the two-way databinding expression @= at the SwitchCompat view.

And now to the BindingAdapter and InverseBindingAdapter:

@BindingAdapter("resourceState")
fun setResourceState(compoundButton: CompoundButton, resourceState: State) {
    compoundButton.isChecked = when (resourceState) {
        // You can decide yourself how the mapping should look like:
        State.LOADING -> true 
        State.UPDATE -> true
        State.UNKNOWN -> true
        State.STOPPED -> false
    }
}

@InverseBindingAdapter(attribute = "resourceState", event = "resourceStateAttrChanged")
fun getResourceStateAttrChanged(compoundButton: CompoundButton): State =
    // You can decide yourself how the mapping should look like:
    if (compoundButton.isChecked) State.UPDATE else State.STOPPED

@BindingAdapter("resourceStateAttrChanged")
fun setResourceStateAttrChanged(
    compoundButton: CompoundButton,
    attrChange: InverseBindingListener
) {
    compoundButton.setOnCheckedChangeListener { _, isChecked -> 
        attrChange.onChange()

        // Not the best place to put this, but it will work for now:
        if (isChecked) {
            Repository.getInstance().requestLocationUpdates(context);
        } else {
            Repository.getInstance().removeLocationUpdates(context);
        }
    }
}

And thats it. Now:

  • Whenever locationResource.state changes to State.STOPPED, the SwitchCompat button will go to the unchecked state.
  • Whenever locationResource.state changes from State.STOPPED to another state, the SwitchCompat button will go to the checked state.
  • Whenever the SwitchCompat button is tapped and changes to the checked state then the value of locationResource.state will change to State.UPDATE.
  • Whenever the SwitchCompat button is tapped and changes to the unchecked state then the value of locationResource.state will change to State.STOPPED.

Feel free to ask me any questions if something is not clear.

janosch
  • 1,848
  • 14
  • 25
  • Hello Janosch thanks for the reply. I don't understand where is the logic to call `requestLocationUpdates` or `removeLocationUpdates` based on the checked state: shouldn't it be in the `BindingAdapter` of `resourceStateAttrChanged`? I converted your [snippet](https://pastebin.com/KZeG1ve6) to Java and edited based on my needs and my assumptions, but I get the following [error](https://pastebin.com/U25uy44w). (Pastebin links). Thanks for helping. – y k Feb 04 '19 at 21:11
  • Hm shouldn't the state paramter of `setResourceState(CompoundButton view, int state)` be of type `LocationState`, is `LocationState` an enum? The same holds for the return type of `public static int getResourceStateAttrChanged(CompoundButton view)`. The error looks like you are still using the function `getLocationResource()` and your `mLocationResource` is still a LiveData object, I suggest you delete `getLocationResource()` make mLocationResource public and change it to Resource with a MutableLiveData object `state` as a member variable, just like in the first step of my answer. – janosch Feb 04 '19 at 21:45
  • Also you're not calling `attrChange.onChange()` in your `public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) ` method. It notifies the databinding library that the value of the switch has changed. As to where to call requestLocationUpdates or removeLocationUpdates that's another question, but your solution will work for now. – janosch Feb 04 '19 at 22:02
  • Hey again. `LocationState` is a class holding `public static final int`s and I made this change after seeing [this video](https://youtu.be/Hzs6OBcvNQE). Yes I am still using `getLocationResource()` because I wouldn't like to expose `mLocationResource` but I'd rather use a getter. Also, I'd like to keep the structure of `Resource` and not make the `State` field a `MutableLiveData`. As for the missing `attrChange.onChange()`, yes, it's missing because I need to request/remove based on checked state and since I don't know two-way data binding, as the title says, I threw it there... – y k Feb 04 '19 at 22:07
  • ... if you could maybe edit your answer including the `onCheckedChanged` logic in `MainViewModel.java` in the original question, that would be really helpful – y k Feb 04 '19 at 22:08
  • Yeah you're right, it is not necessary to expose the variable. But your current structure of MutableLiveData> won't work, because you can't encapsulate LiveData objects inside LiveData objects and expose the inner LiveData object to your databinding layout. Your current BindingAdapter will currently only be triggered when the entire Resource object changes, but not when the status of your Resource object changes, that's why I am suggesting that you wrap the status itself in a MutableLiveData object. You should simply add attrChange.onChange() in your onCheckedChanged method. – janosch Feb 04 '19 at 22:31
  • I have no problems with `Resource` not changing since I have it wrapped in a `MutableLiveData` where I can use (all this in Repository) `mLocationResource.setValue(locationResource)`, everything works (If I'm using a hack please let me know!). As for the `attrChange.onChange()` thing, thanks for editing your reply now I understood. However, you are against putting the request/remove logic there: where should I place it? What is your advice? Thanks! – y k Feb 04 '19 at 23:11
1

At the end, I gave up trying to convert from one-way data binding to two-way data binding, but I managed to simplify a little bit the android:checked attribute:
I replaced value

"@{viewModel.getLocationResource}"

with

"@{viewModel.locationResource.state != State.STOPPED}"

and completely removed the android:checked @BindingAdapter.

y k
  • 716
  • 1
  • 10
  • 25