Search code examples
androidandroid-databindingandroid-livedataandroid-viewmodel

How to update the bindings inside a RecyclerView item?


Picture a list of StackOverflow questions that can individually be upvoted and downvoted. Each one of them has a count that displays the number of its votes. Clicking on upvote increases that count by one, clicking on downvote decreases that count by one. That's the general idea of what I'm trying to achieve. The layout, rv_item.xml (condensed for brevity) for a single item in the RecyclerView:

data>
    <variable
        name="item"
        type="com.mycodez.Item"/>

    <variable
        name="vm"
        type="com.mycodez.ListViewModel" />
</data>

<Button
    android:id="@+id/decrease_quantity"
    android:onClick="@{() -> vm.decrementItemQuantity(item)}"/>

<TextView
    android:id="@+id/quantity"
    android:text="@{String.valueOf(item.quantity)}"
    tools:text="1" />

<Button
    android:id="@+id/increase_quantity"
    android:onClick="@{() -> vm.incrementItemQuantity(item)}"/>

Where @+id/decrease_quantity is similar to a downvote, @+id/quantity similar to a vote count, and @+id/increase_quantity similar to an upvote. The adapter:

class ListRvAdapter extends RecyclerView.Adapter<ListRvAdapter.ViewHolder> {
    private List<Item> items;
    private ListViewModel viewModel;
    private LifecycleOwner lifecycleOwner;

    ListRvAdapter(List<Item> items, ListViewModel viewModel, LifecycleOwner lifecycleOwner) {
        this.items = items;
        this.viewModel = viewModel;
        this.lifecycleOwner = lifecycleOwner;
    }

    @NonNull
    @Override
    public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
        LayoutInflater layoutInflater = LayoutInflater.from(parent.getContext());
        RvItemBinding binding = RvItemBinding.inflate(layoutInflater, parent, false);
        return new ViewHolder(binding, viewModel, lifecycleOwner);
    }

    @Override
    public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
        holder.bind(items.get(position));
    }

    @Override
    public int getItemCount() {
        return items.size();
    }

    public static class ViewHolder extends RecyclerView.ViewHolder {
        private final RvItemBinding binding;
        private ListViewModel viewModel;
        private LifecycleOwner lifecycleOwner;

        ViewHolder(RvItemBinding binding, ListViewModel viewModel, LifecycleOwner lifecycleOwner) {
            super(binding.getRoot());
            this.binding = binding;
            this.viewModel = viewModel;
            this.lifecycleOwner = lifecycleOwner;
        }

        public void bind(Item item) {
            binding.setItem(item);
            binding.setVm(viewModel);
            binding.executePendingBindings();
            binding.setLifecycleOwner(lifecycleOwner);
        }
    }
}

I've omitted the ViewModel, ListViewModel because what vm.decrementItemQuantity(item) and vm.incrementItemQuantity(item) essentially do is call the model to update the quantity in the database:

@Query("UPDATE items SET quantity=quantity + 1 WHERE id=:itemId")
Completable incrementQuantity(int itemId);

@Query("UPDATE items SET quantity=quantity - 1 WHERE quantity > 1 AND id=:itemId")
Completable decrementQuantity(int itemId);

So while the quantity is being updated in the model, it's not clear to me how I should update the UI for that particular RecyclerView item to reflect that change. Normally, when using LiveData and DataBinding, I'd do something like itemQuantityLiveData.setValue(item.quantity) and that would immediately be reflected in the XML. But in this case, I'm using the bind(Item item) method in my ViewHolder, and the item object is being passed to the DataBinding layout as a variable, not as an expression.

How do I solve this?


Solution

  • No need to overthink things. Calling notifyItemChanged(position) on the adapter instance is the missing piece. So it becomes a matter of how to pass the index of the RecyclerView item back to the adapter.

    Edit rv_item.xml to take an additional variable, position:

    <data>
        <variable
            name="item"
            type="com.mycodez.Item"/>
    
        <!-- add this variable -->
        <variable
            name="position"
            type="int"/>
    
        <variable
            name="vm"
            type="com.mycodez.ListViewModel" />
    </data>
    

    Also, in that same XML file, pass position to the vm methods triggered by onClick:

    <Button
        android:id="@+id/decrease_quantity"
        android:onClick="@{() -> vm.decrementItemQuantity(item, position)}"/>
    
    <Button
        android:id="@+id/increase_quantity"
        android:onClick="@{() -> vm.incrementItemQuantity(item, position)}"/>
    

    Then the bind method in the ListRvAdapter ViewHolder becomes:

    public void bind(Item item, int position) { // new argument
        binding.setItem(item);
        binding.setPosition(position); // pass position to the layout
        binding.setVm(viewModel);
        binding.executePendingBindings();
        binding.setLifecycleOwner(lifecycleOwner);
    }
    

    Pass the new argument in the adapter's onBindViewHolder like so:

    @Override
    public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
        holder.bind(items.get(position), position);
    }
    

    Finally, add two methods to the ListRvAdapter for respectively increasing and decreasing quantity:

    public void increaseQuantity(int position) {
        Item item = items.get(position);
        item.quantity += 1;
        notifyItemChanged(position);
    }
    
    public void decreaseQuantity(int position) {
        Item item = items.get(position);
        item.quantity -= 1;
        notifyItemChanged(position);
    }
    

    With all that in place, when the Button @+id/increase_quantity is clicked, vm.incrementItemQuantity(item, position) is triggered. In a MVVM setup, the ViewModel will have a LiveData Event object for position:

    public MutableLiveData<Event<Integer>> itemQuantityIncreased = new MutableLiveData<>(); 
    

    And the call to vm.incrementItemQuantity(item, position) will set that object's value:

    itemQuantityIncreased.setValue(new Event<>(position));

    Finally, the View will have an Observer on the ViewModel's itemQuantityIncreased LiveData object. Because the Event sends the RecyclerView item's position with it, the View can get a hold of that and call adapter.increaseQuantity(position) where it's appropriate. adapter.decreaseQuantity(position) works just the same.