2

I am using a list adapter with recycler view, now the data class contains a variable isChecked, this is used to indicate if the user has selected this variable or not, the code is updating the list as I can see the logs (which I had put for testing purpose) return that the current list is always updated when the user clicks on a item but for some reason the changes in UI (based on isChecked variable) is only reflected when scrolling the recycler view or by clicking other items. I put a notifyDataSetChanged to see if it forces the list to update and see if the updated views are correct and it works, but then this destroys the whole purpose of using a diff util. I have a nested list in my wrapper data class which is being compared in the diff util as illustrated below

private val DIFF_CALLBACK = object : DiffUtil.ItemCallback<MainDataClass>() {
            override fun areItemsTheSame(
                oldItem: MainDataClass,
                newItem: MainDataClass
            ): Boolean {
                if (oldItem is MainDataClass.Item && newItem is MainDataClass.Item) {
                    return oldItem.data.id == newItem.data.id
                } else if (oldItem is MainDataClass.List && newItem is MainDataClass.List) {
                    return oldItem.list == newItem.list
                } else return false
            }

            override fun areContentsTheSame(
                oldItem: MainDataClass,
                newItem: MainDataClass
            ): Boolean {
                if (oldItem is MainDataClass.Item && newItem is MainDataClass.Item) {
                    return oldItem.data == newItem.data
                } else if (oldItem is MainDataClass.List && newItem is MainDataClass.List) {
                    return oldItem.list == newItem.list
                } else return false
            }
        }

MainDataClass.List contains the list of particular items as mentioned above.

public class Item{

    private Integer count;
    private Integer id;
    private String icon_img;
    private String name;
    private String cover_img;
    private String group_name;
    private Integer parent_id;
    private Integer status;
    private boolean checked = false;
    private Integer whatToVisible;

    public Item(Integer count, Integer id, String icon_img, String cover_img, String group_name, Integer parent_id) {
        this.count = count;
        this.id = id;
        this.icon_img = icon_img;
        this.cover_img = cover_img;
        this.group_name = group_name;
        this.parent_id = parent_id;
        this.checked = false;
    }

    public Item(Integer id, String icon_img, String group_name, Integer parent_id, boolean checked) {
        this.id = id;
        this.icon_img = icon_img;
        this.name = name;
        this.group_name = group_name;
        this.parent_id = parent_id;
        this.checked = checked;
    }

    public Item(Integer id,Integer parent_id) {
        this.id = id;
        this.parent_id = parent_id;
    }

    public static Item objectExample() {
        return new TrendingGroupsResponse(-2, -2);
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public boolean isChecked() {
        return checked;
    }

    public void setChecked(boolean checked) {
        this.checked = checked;
    }

    public Integer getStatus() {
        return status;
    }

    public void setStatus(Integer status) {
        this.status = status;
    }

    public Integer getCount() {
        return count;
    }

    public String getIcon_img() {
        return icon_img;
    }

    public void setIcon_img(String icon_img) {
        this.icon_img = icon_img;
    }

    public String getCover_img() {
        return cover_img;
    }

    @Override
    public int hashCode() {
        return this.id;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Item)) return false;
        Item that = (Item) o;
        return checked == that.checked && Objects.equals(count, that.count) && Objects.equals(id, that.id) && Objects.equals(icon_img, that.icon_img) && Objects.equals(name, that.name) && Objects.equals(cover_img, that.cover_img) && Objects.equals(group_name, that.group_name) && Objects.equals(parent_id, that.parent_id) && Objects.equals(status, that.status) && Objects.equals(whatToVisible, that.whatToVisible);
    }

    @Override
    public String toString() {
        return "id: " + this.id;
    }

    public void setCover_img(String cover_img) {
        this.cover_img = cover_img;
    }

    public String getGroup_name() {
        if (name != null){
            group_name = name;
        }
        return group_name;
    }

    public void setGroup_name(String group_name) {
        this.group_name = group_name;
    }

    public Integer getParent_id() {
        return parent_id;
    }

    public void setParent_id(Integer parent_id) {
        this.parent_id = parent_id;
    }

    public void setCount(Integer count) {
        this.count = count;
    }

    public Integer getId() {
        return id;
    }

    public void setId(Integer id) {
        this.id = id;
    }

    public String getIconImg() {
        return icon_img;
    }

    public void setIconImg(String icon_img) {
        this.cover_img = icon_img;
    }

    public String getCoverImg() {
        return cover_img;
    }

    public void setCoverImg(String cover_img) {
        this.cover_img = cover_img;
    }

    public String getGroupName() {
        if (name != null) {
            group_name = name;
        }
        return group_name;
    }

    public void setGroupName(String group_name) {
        this.group_name = group_name;
    }

    public Integer getWhatToVisible() {
        return whatToVisible;
    }

    public void setWhatToVisible(Integer whatToVisible) {
        this.whatToVisible = whatToVisible;
    }

Method called when main item is clicked from main list

fun addOrRemoveSelectedItemsIfPresent(item: Item) {
        viewModelScope.launch {
            addOrRemoveItemsFromPopularItems(item.id.toString())
            updateAllItems(item)
            var itemList = _selectedItems.value
            if (itemList == null) itemList = ArrayList()
            itemList.forEach { item1: Item ->
                if (item1.id == item.id) {
                    itemList.remove(group)
                    selectedItemCount.set(selectedItemCount.get() - 1)
                    _selectedItems.value = itemList
                    return@launch
                }
            }
            itemList.add(response)
            selectedItemCount.set(selectedItemCount.get() + 1)
            _selectedItems.value = itemList
        }
    }

private fun addOrRemoveItemsFromPopularItems(id: String) {
        val popularItems = _popularItemsLiveData.value?.data
        popularItems?.forEach {
            if (it.id.toString() == id) {
                if (it.isChecked == null || it.isChecked == false) {
                    it.isChecked = true
                } else {
                    it.isChecked = false
                }
            }
        }
        _popularItemsLiveData.postValue(Success(popularItems))
    }
Aziz Agasi
  • 53
  • 7
  • 1
    Post your MainDataClass and the sub items as well – Martin Marconcini Oct 07 '21 at 07:49
  • @MartinMarconcini I can't because of it can't be revealed, sorry, but I can say that it MainDataClass is a sealed class containing a data class of one item and another data class having a list of items – Aziz Agasi Oct 07 '21 at 14:41
  • I mean, you can post your data class, change the names and show a basic of the datatypes you're using. If you compare two lists with two data classes in kotlin you're going to get different results, but if you're comparing different types here (as evidenced by your `if` statements), I'm not sure how the DiffUtil will react. Alternatively, peek at [DiffUtil's Source Code](https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:recyclerview/recyclerview/src/main/java/androidx/recyclerview/widget/DiffUtil.java;l=82?q=DiffUtil&sq=) ;) – Martin Marconcini Oct 07 '21 at 14:52
  • Finally, have you debugged this? You can put breakpoints in that diffUtil, mock a small data set, and compare, or even better write a unit test that exhibit _which_ case is failing. What I'm saying is that [this simple comparison](https://pl.kotl.in/u9TisGnx2) shows how it detects when an item in the list is different, so your problem lies elsewhere. – Martin Marconcini Oct 07 '21 at 14:55
  • @MartinMarconcini Everything is correct and diff util is working as it is expected to, the problem arises here, there is a boolean called isChecked which is to see if the item is selected or not, by default it's value is false, and is updated in viewModel which updates the list, the observer of this list submits the list again but when I compare the value of isChecked for new and old item both are showing as true (only for the selected group) – Aziz Agasi Oct 08 '21 at 09:28
  • @MartinMarconcini This does not happen if the view of that item is not visible in recycler view that is it needs to be scrolled to be made visible also rest of the items do not show this wierd issue unless they are clicked on and are visible in the recycler view – Aziz Agasi Oct 08 '21 at 09:28
  • What is likely happening (but I cannot tell since I don't have your code) is that you're modifying the original object when you set `isChecked = true`. Instead of passing the same list, make a copy of the objects so that when you modify your "local" copy, you're not actually modifying the _same copy_ the adapter has. Otherwise, when it's DiffUtil's time to do its job, the values are the same since they point to the same object. Hence why using `===` is not a good idea. :) – Martin Marconcini Oct 08 '21 at 09:30
  • @MartinMarconcini I am not using === instead I am comparing the ID I have added the Item class model so you can check that out, there was an issue regarding this === operator so I was suggested to use the ID's to check if they are the same item – Aziz Agasi Oct 08 '21 at 09:33
  • My apologies, I may have confused your question with some other user who was using `===` (I haven't had coffee). Anyway, show me two things in your code. (no need to paste the whole thing). 1. Where do you set `isChecked = true`. 2) Where do you construct this list and the whole set of "hands" that touch it before it reaches the adapter. – Martin Marconcini Oct 08 '21 at 09:37
  • @MartinMarconcini The data from the adapter is called from an API it is wrapped in 3 loading states and the data is used only when Sucess state is used to wrap the data. Now there are two lists inside the fragment, one showing the most popular items and other list showing all of the items. The functionality works fine when one item is clicked in their list but breaks when the changes needs to be shown in the popular items list (one where the problem is happening). When the item from main group is clicked, in the viewModel a method is called to add it to a list of selected items. – Aziz Agasi Oct 08 '21 at 12:56
  • The first thing that happens is another method is called passing the id of the view clicked, which then iterates through the data of popular items to see if the id matches, once that happens, the value of isSelected is set to the opposite value of isSelected like isSelected = !isSelected. – Aziz Agasi Oct 08 '21 at 13:00
  • Then the livedata for original list of popular item is changed with this list with the Success wrapper and that's it. Then another method is called to update the view of the main group which does not manipulate the data of popular items in anyway. Then finally code regarding adding or removing the clicked view inside a list of items that contains all selected items is executed – Aziz Agasi Oct 08 '21 at 13:00
  • https://stackoverflow.com/questions/53156597/listadapter-with-diffutil-itemcallback-always-considers-objects-the-same This is the same question but main answer says to submit the list by using .toList() I did that but that didn't solve anything – Aziz Agasi Oct 08 '21 at 13:11
  • Give me a few minutes to compose an answer, because we're gonna run out of chat :) – Martin Marconcini Oct 08 '21 at 13:26

1 Answers1

6

Let's break down the problem here:

  1. Data comes from an API.
  2. Data becomes a List<Things> (let's call them "Things" to protect their identity).
  3. Your UI shows two lists, one where you have all the items received. Let's call this one allList.
  4. You also want to display a subset of this list in another recyclerView, based on the items the user selected from allList. We'll call this selectedList. (hey, I didn't say I was good at naming things).

Now the key here is what happens when you tap an item in the allList. You want this item to be added to the selectedList if it wasn't there, or this item to be removed from the list if it was already selected.

That is what you need to happen.

Here's how I would do this:

  1. The user taps an item, we pass this event to the VM (viewmodel) alongside the item or its Id. (I'd pass the whole item, because you already have it, but it's irrelevant).
  2. The VM now must invoke a use-case or similar to update the selected item list (selectedList). The VM doesn't really care what goes into that list, it's not its job. That's the use-case/delegate. But let's ignore that nice to have abstraction for a moment, and assume the VM has a method that can do that for you:

fun updateSelectedList(selectedItem: Thing): List<Thing> //remember we call 'em "things".

What happens inside this method is not yet relevant.

You then pass this list via liveData to your Fragment/UI and it will submitList(...). It all works. You also updated the isChecked state of the item somewhere, so the allList can also update its "view state" to check or uncheck the view.

Imagining a solution

Before we even talk about the potential problem, imagine you have a perfect codebase where the updateSelectedList returned a List that you submit to your recyclerView and it works fine (exactly like your allList right now which you claim it works fine).

In this perfect scenario the DiffUtil would not find itself in any trouble whatsoever, if an item is new (or disappeared) it would be able to calculate the differences and render the appropriate views in the correct position every time, like it often does.

Problem

So where can the problem be then?

You claim that at the time of the DiffUtil invocation, the value of isChecked is already changed, so the diffUtil doesn't detect the difference.

This leads me to believe, and the reason why I wrote this lengthy answer, that the data you're passing to each adapter is the same, so when you modify the item in the selectedList to be isSelected = true/false, you're effectively modifying the same reference/object that the allList contains.

To better illustrate this, I made a Kotlin Playground with an example of what I mean, it's available here in play.kotlinlang.org

So what Is the actual suspected problem?

I believe the order in which you execute your steps is causing the data to be mutated in such way that when the RecyclerView needs to calculate changes, they have already happened.

What can you do?

The way I see a problem like this is:

  1. There should be a single source of truth about the list and its state.
  2. All the list(s) supplied to the Adapter(s) (all and selected) should be immutable copies.
  3. When an event happens that will mutate either list (I assume they are both mutated at the same time, since when you select or deselect an item, the other list must also reflect the change), the ViewModel, through a use-case/repo will perform the transformation(s) and emit two new immutable lists, with the values modified.

In other words.

You select (or deselect) an item in the "all list", your VM evaluates what needs to be done, and produces a new immutable list with the item copied.

The answer you linked suggests using toList() this is fine to create the new list but this is a shallow copy, not a deep copy, so the items are still the same, what changes is the actual List. Meaning:

val item1 = "A"
val item2 = "B"
val list1 = mutableList(item1, item2)
val list2 = list1.toList()

In this ^^^ list2[0] and list2[1] are pointing to the same object as list1[0] and [1] respectively. If you change item1, you change it in both lists.

And I suspect this is what is happening with you.

So again, do not only do a toList. Construct a new immutable list from preferably immutable objects (so use data class and use val, not var).

It's better to do:

val modifiedItem = oldItem.copy(isSelected = true)

I think all this data transformation (modifying the two lists and the item) should happen pretty much simultaneously in the ViewModel (delegated to use-cases if you want).

I hope this gives you a nudge in a helpful direction.

Remember: Separation of Concerns is your friend. In your perfect world, the adapter's job is to calculate a diff between list A and list B and render them. Nothing more.

In the selected list, all that happens is that items appear and reappear (based on whether they were selected or not). This list should be simply made out of the selected items (doesn't need to be a copy so as long as you don't mutate them in any way, merely display them).

In the all list, items mostly stay in the same position, but their contents change (to indicate if they are selected). So again, this is an immutable list of read-only objects the list receives.

Think of this scenario:

Your all list contains 2 items.

Neither is selected.

You select item "A" (the 1st). If you do the right thing, the next lists you receive are:

  1. A selected list with 1 item. (for the selection)
  2. A new "all list" with 2 items, where item[0] (the 1st one) is isSelected = true. This will be compared against the previous list where isSelected was false. And it should work.

How do I know this works?

I had to do a very similar thing a couple of years ago ;)

Martin Marconcini
  • 26,875
  • 19
  • 106
  • 144