Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Two-way Data Binding, RecyclerView, ViewModel, Room, LiveData, Oh My

New to Android development and I’m trying to wrap my head around two-way data binding in conjunction with RecyclerView, ViewModel, Room and LiveData. I grok one-way bindings, but can’t figure out two-way.

Simply, I’d like to be able to tap the id/switch_enabled Switch and update the Db to reflect this (I then plan to leverage this to update other members in the class/Db). I think I need some help with set(value) on my ViewModel and getting the correct RecyclerView item updated in the Db, but I’m uncertain how to do this or if this is the right or best way to do this.

Thank you.

Class:

data class Person (@ColumnInfo(name = "first_name") val firstName: String,
                   @ColumnInfo(name = "last_name") val lastName: String,

                   //...

                   val enabled: Boolean = true
){
    @PrimaryKey(autoGenerate = true)
    var id: Long = 0
}

Layout detail for RecyclerView:

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

    <data>
        <variable
            name="p" type="com.example.data.Person" />
    </data>

    <androidx.constraintlayout.widget.ConstraintLayout
        android:id="@+id/constraintLayout"
        android:layout_width="match_parent"
        android:layout_height="wrap_content">

        <TextView
            android:id="@+id/first_name"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="@{p.firstName}"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent"
            tools:text="John" />

        <TextView
            android:id="@+id/last_name"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:ellipsize="end"
            android:text="@{' ' + p.lastName}"
            app:layout_constraintStart_toEndOf="@id/first_name"
            app:layout_constraintTop_toTopOf="parent"
            tools:text=" Doubtfire" />

        <Switch
            android:id="@+id/switch_enabled"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:checked="@={p.enabled}"
            app:layout_constraintBaseline_toBaselineOf="@id/last_name"
            app:layout_constraintEnd_toEndOf="parent" />

        <!--...-->

    </androidx.constraintlayout.widget.ConstraintLayout>
</layout>

ViewModel:

class MainViewModel(private val repository: DataRepository) : ViewModel() {
    private val _people: LiveData<List<Person>>
//    @Bindable?
//    @get:Bindable?
    var people: LiveData<List<Person>>
        @Bindable
        get() = _people
        set(value) {
            //Find out which member of the class is being changed and update the Db?
            Log.d(TAG, "Value for set is $value!")
        }
    init {
        _people = repository.livePeople()
    }
}

Fragment:

override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
                          savedInstanceState: Bundle?): View? {
    val binding = FragmentPeopleBinding.inflate(inflater, container, false)
    val context = context ?: return binding.root

    val factory = Utilities.provideMainViewModelFactory(context)
    viewModel = ViewModelProviders.of(requireActivity(), factory).get(MainViewModel::class.java)

    val adapter = PeopleViewAdapter()
    viewModel.people.observe(this, Observer<List<Person>> {
        adapter.submitList(it)
    })

    binding.apply {
        vm = viewModel
        setLifecycleOwner(this@PeopleFragment)
        executePendingBindings()
        rvPeopleDetails.adapter = adapter
    }
    return binding.root
}

List Adapter:

class PeopleViewAdapter: ListAdapter<Person, PeopleViewAdapter.ViewHolder>(PeopleDiffCallback()) {
    class PeopleDiffCallback : DiffUtil.ItemCallback<Person>() {
        override fun areItemsTheSame(oldItem: Person, newItem: Person): Boolean {
            return oldItem.id == newItem.id
        }

        override fun areContentsTheSame(oldItem: Person, newItem: Person): Boolean     {
            return oldItem.number == newItem.number
        }
    }

    class ViewHolder(val binding: FragmentPeopleDetailBinding) : RecyclerView.ViewHolder(binding.root) {

        fun bind(person: Person) {
            binding.p = person
        }
    }

    @NonNull
    override fun onCreateViewHolder(@NonNull parent: ViewGroup, viewType: Int): ViewHolder =
            ViewHolder(FragmentPeopleDetailBinding.inflate(LayoutInflater.from(parent.context), parent, false))

    @NonNull
    override fun onBindViewHolder(@NonNull holder: ViewHolder, position: Int) {
        holder.apply {
            bind(getItem(position))
        }
    }
}
like image 376
Bink Avatar asked Jan 15 '19 21:01

Bink


2 Answers

I just ran into the same problem of setting up two way data binding within an MVVM architecture with a ViewModel and RecyclerView list. I determined that it was either not possible or not worth the effort to get two way binding working in this situation because you aren't directly using the viewmodel in the recyclerview item layout (the layout variable you're using is of type Person, not your viewmodel).

What I would suggest is actually adding your viewmodel as a layout variable, then using android:onClick="@{() -> viewmodel.onSwitchClicked()}" and implementing that method within your viewmodel.

Check out the details in my project here: https://github.com/linucksrox/ReminderList

like image 82
linucksrox Avatar answered Oct 02 '22 14:10

linucksrox


I came to the same conclusion that the best way is to provide the view model to the layout binding hosting the item that is displayed in the recycler view. I have created a general purpose solution for this scenario.

The adapter can be seen below and supports multiple type of layouts as well.

public abstract class ViewModelBaseAdapter<T extends Diffable, VM extends ViewModel>
    extends ListAdapter<T, DoubleItemViewHolder<T, VM>> {

    private final int itemVariableId;

    private final int viewModelVariableId;

    /**
     * Constructor
     *
     * @param diffCallback the comparison strategy between items in {@code this} adapter
     * @param variableId   the variable in the data binding layout to set with the items
     */
    public ViewModelBaseAdapter(int itemVariableId, int viewModelVariableId) {

        super(new DiffUtil.ItemCallback<T>() {

            @Override
            public boolean areItemsTheSame(@NonNull Diffable oldItem,
                                           @NonNull Diffable newItem) {

                return oldItem.isSame(newItem);
            }

            @Override
            public boolean areContentsTheSame(@NonNull Diffable oldItem,
                                              @NonNull Diffable newItem) {

                return oldItem.isContentSame(newItem);
            }
        });

        this.itemVariableId = itemVariableId;
        this.viewModelVariableId = viewModelVariableId;
    }

    @NonNull
    @Override
    public DoubleItemViewHolder<T, VM> onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {

    ViewDataBinding binding = DataBindingUtil.inflate(
            LayoutInflater.from(parent.getContext()), viewType, parent, false);

        return new DoubleItemViewHolder<>(binding, itemVariableId, viewModelVariableId);
    }

    @Override
    public void onBindViewHolder(@NonNull DoubleItemViewHolder<T, VM> holder, int position) {

        holder.bind(getItem(position), getItemViewModel(position));
    }

    @Override
    public abstract int getItemViewType(int position);

    /**
     * Provides the {@code ViewModel} to be bound together with the item at
     * a specified position.
     *
     * @param position the position of the item
     * @return the view model
     */
    public abstract VM getItemViewModel(int position);
}

The interface and ViewHolder is defined as follows.

public interface Diffable {

    boolean isSame(Diffable other);

    boolean isContentSame(Diffable other);
}
public final class DoubleItemViewHolder<V1, V2> extends RecyclerView.ViewHolder     {

    private final ViewDataBinding binding;

    private final int firstVariableId;

    private final int secondVariableId;

    /**
     * Constructor
     *
     * @param binding          the binding to use
     * @param firstVariableId  the first variable set on the binding
     * @param secondVariableId the second variable set on the binding
     */
    public DoubleItemViewHolder(ViewDataBinding binding,
                                int firstVariableId,
                                int secondVariableId) {

        super(binding.getRoot());
        this.binding = Objects.requireNonNull(binding);
        this.firstVariableId = firstVariableId;
        this.secondVariableId = secondVariableId;
    }

    /**
     * Sets the data binding variables to the provided items
     * and calls {@link ViewDataBinding#executePendingBindings()}.
     *
     * @param firstItem  the first item to bind
     * @param secondItem the second item to bind
     * @throws NullPointerException if {@code firstItem} or {@code secondItem} is {@code null}
     */
    public void bind(@NonNull V1 firstItem, @NonNull V2 secondItem) {

        Objects.requireNonNull(firstItem);
        Objects.requireNonNull(secondItem);
        binding.setVariable(firstVariableId, firstItem);
        binding.setVariable(secondVariableId, secondItem);
        binding.executePendingBindings();
    }
}

Now that the "boiler plate" has been set up it becomes simple to use.

Example

The purpose of the example is to provide a complete answer including set up for anyone wishing to use this approach, it can be generalised very simply.

First the models are defined.

public class AppleModel implements Diffable {
    // implementation...
}

public class DogModel implements Diffable {
    // implementation...
}

Then we expose diffables in the view model like so.

private final MutableLiveData<List<Diffable>> diffables = new MutableLiveData<>();

public LiveData<List<Diffable>> getDiffables() {

    return diffables;
}

And implement the adapter by overriding the ViewModelBaseAdapter.

public class ModelAdapter
    extends ViewModelBaseAdapter<Diffable, MyViewModel> {

    private final MyViewModel myViewModel;

    public SalesmanHistoryAdapter(MyViewModel myViewModel) {

        super(BR.item, BR.vm);
        myViewModel = myViewModel;
    }

    @Override
    public int getItemViewType(int position) {

        final Diffable item = getItem(position);

        if (item instanceof AppleModel) {
            return R.layout.item_apple_model;
        }

        if (item instanceof DogModel) {
            return R.layout.item_dog_model;
        }

        throw new IllegalArgumentException("Adapter does not support " + item.toString());
    }

    @Override
    public MyViewModel getItemViewModel(int position) {
        // You can provide different viewmodels if you like here.
        return myViewModel;
    }
}

Then you attach those items and the adapter to the recycler view in the layout.

<variable
        name="adapter"
        type="ModelAdapter" />

    <variable
        name="vm"
        type="MerchantLogViewModel" />

<androidx.recyclerview.widget.RecyclerView
                list_adapter="@{adapter}"
                list_adapter_items="@{vm.diffables}"
                android:layout_width="match_parent"
                android:layout_height="match_parent"
                android:orientation="vertical"
                android:scrollbars="vertical"
                app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" />

They are attached using this binding adapter.

@BindingAdapter(value = {
        "list_adapter",
        "list_adapter_items"
})
public static <T> void setRecyclerViewListAdapterItems(RecyclerView view,
                                                       @NonNull ListAdapter<T, ?> adapter,
                                                       @Nullable final List<T> items) {

    Objects.requireNonNull(adapter);

    if (view.getAdapter() == null) {
        view.setAdapter(adapter);
        Timber.w("%s has no adapter attached so the supplied adapter was added.",
                 view.getClass().getSimpleName());
    }

    if (items == null || items.isEmpty()) {

        adapter.submitList(new ArrayList<>());
        Timber.w("Only cleared adapter because items is null");

        return;
    }

    adapter.submitList(items);
    Timber.i("list_adapter_items added %s.", items.toString());
}

Where your item layouts (only showing for the DogModel here but the same goes for AppleModel).

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

    <data>
        <variable
            name="item"
            type="DogModel" />

        <variable
            name="vm"
            type="MyViewModel" />

    </data>
    <!-- Add rest below -->

Now you can use the view model a long with the item in the layout using data binding.

like image 30
Ludvig W Avatar answered Oct 02 '22 14:10

Ludvig W