3

it is a known issue that ListAdapter (actually the AsyncListDiffer from its implementation) does not update the list if the new list only has modified items but has the same instance. The updates do not work on new instance list either if you use the same objects inside.

For all of this to work, you have to create a hard copy of the entire list and objects inside. Easiest way to achieve this:

items.toMutableList().map { it.copy() }

But I am facing a rather weird issue. I have a parse function in my ViewModel that finally posts the items.toMutableList().map { it.copy() } to the LiveData and gets observes in the fragment. Even with the hard copy, DiffUtil does not work. If I move the hard copy inside the fragment, then it works.

To get this easier, if I do this:

IN VIEW MODEL:

   [ ... ] parse stuff here

items.toMutableList().map { it.copy() }
restaurants.postValue(items)

IN FRAGMENT:

 restaurants.observe(viewLifecycleOwner, Observer { items ->
     adapter.submitList(items)

... then, it doesn't work. But if I do this:

IN VIEW MODEL:

   [ ... ] parse stuff here

restaurants.postValue(items)

IN FRAGMENT:

 restaurants.observe(viewLifecycleOwner, Observer { items ->
     adapter.submitList(items.toMutableList().map { it.copy() })

... then it works.

Can anybody explain why this doesn't work?

In the mean time, I have opened an issue on the Google Issue Tracker because maybe they will fix the AsyncListDiffer not updating same instance lists or items. It defeats the purpose of the new adapter. The AsyncListDiffer SHOULD ALWAYS accept same instance lists or items, and fully update using the diff logic that the user customises in the adapter.

M'aiq the Coder
  • 762
  • 6
  • 18
  • I'd like the source for: _it is a known issue that ListAdapter (actually the AsyncListDiffer from its implementation) does not update the list if the new list only has modified items but has the same instance_; is that so? – Martin Marconcini Feb 12 '21 at 09:14
  • 1
    @MartinMarconcini 1. Here is a thread on stack overflow that attaches many other threads reporting the issue. https://stackoverflow.com/questions/57876118/diffutil-itemcallback-arecontentsthesame-always-returns-true-after-updating-an/64932645#64932645 2. In androidx.recyclerview.widget.AsyncListDiffer at line 256 you can see the function returning at start when newList == mList and line 305 T oldItem = oldList.get(oldItemPosition); -> for the same object instance. – M'aiq the Coder Feb 12 '21 at 11:15
  • Thanks for the reference. I haven't really used AsyncDiff in a long time (normally just do DiffUtil.Callback directly, since the most expensive part to me is often the "parsing" and "transformation" which I do in suspend functions in an IO context these days, but I see your point. I think ycesar's answer is accurate though, you want to create a new list by deep copying the items from the old. – Martin Marconcini Feb 12 '21 at 13:13

2 Answers2

1

I made a quick sample using DiffUtil.Callback and ListAdapter<T, K> (so I called submitList(...) on the adapter), and had no issues.

Then I modified the adapter to be a normal RecyclerView.Adapter and constructed an AsyncDiffUtil inside of it (using the same DiffUtil.Callback from above).

The architecture is:

  1. Activity -> Fragment (contains RecyclerView).
  2. Adapter
  3. ViewModel
  4. "Fake Repository" that simply holds a val source: MutableList<Thing> = mutableListOf()

Model

I've created a Thing object: data class Thing(val name: String = "", val age: Int = 0).

For readability I added typealias Things = List<Thing> (less typing). ;)

Repository

It's fake in the sense that items are created like:

 private fun makeThings(total: Int = 20): List<Thing> {
        val things: MutableList<Thing> = mutableListOf()

        for (i in 1..total) {
            things.add(Thing("Name: $i", age = i + 18))
        }

        return things
    }

But the "source" is a mutableList of (the typealias).

The other thing the repo can do is "simulate" a modification on a random item. I simply create a new data class instance, since it's obviously all immutable data types (as they should be). Remember this is just simulating a real change that may have come from an API or DB.


    fun modifyItemAt(pos: Int = 0) {
        if (source.isEmpty() || source.size <= pos) return

        val thing = source[pos]
        val newAge = thing.age + 1
        val newThing = Thing("Name: $newAge", newAge)

        source.removeAt(pos)
        source.add(pos, newThing)
    }

ViewModel

Nothing fancy here, it talks and holds the reference to the ThingsRepository, and exposes a LiveData:

    private val _state = MutableLiveData<ThingsState>(ThingsState.Empty)
    val state: LiveData<ThingsState> = _state

And the "state" is:

sealed class ThingsState {
    object Empty : ThingsState()
    object Loading : ThingsState()
    data class Loaded(val things: Things) : ThingsState()
}

The viewModel has two public methods (Aside from the val state):

    fun fetchData() {
        viewModelScope.launch(Dispatchers.IO) {
            _state.postValue(ThingsState.Loaded(repository.fetchAllTheThings()))
        }
    }

    fun modifyData(atPosition: Int) {
        repository.modifyItemAt(atPosition)
        fetchData()
    }

Nothing special, just a way to modify a random item by position (remember this is just a quick hack to test it).

So FetchData, launches the async code in IO to "fetch" (in reality, if the list is there, the cached list is returned, only the 1st time the data is "made" in the repo).

Modify data is simpler, calls modify on the repo and fetch data to post the new value.

Adapter

Lots of boilerplate... but as discussed, it's just an Adapter:

class ThingAdapter(private val itemClickCallback: ThingClickCallback) :
    RecyclerView.Adapter<RecyclerView.ViewHolder>() {

The ThingClickCallback is just:

interface ThingClickCallback {
    fun onThingClicked(atPosition: Int)
}

This Adapter now has an AsyncDiffer...

private val differ = AsyncListDiffer(this, DiffUtilCallback())

this in this context is the actual adapter (needed by the differ) and DiffUtilCallback is just a DiffUtil.Callback implementation:

    internal class DiffUtilCallback : DiffUtil.ItemCallback<Thing>() {
        override fun areItemsTheSame(oldItem: Thing, newItem: Thing): Boolean {
            return oldItem.name == newItem.name
        }

        override fun areContentsTheSame(oldItem: Thing, newItem: Thing): Boolean {
            return oldItem.age == newItem.age && oldItem.name == oldItem.name
        }
    

nothing special here.

The only special methods in the adapter (aside from onCreateViewHolder and onBindViewHolder) are these:

    fun submitList(list: Things) {
        differ.submitList(list)
    }

    override fun getItemCount(): Int = differ.currentList.size

    private fun getItem(position: Int) = differ.currentList[position]

So we ask the differ to do these for us and expose the public method submitList to emulate a listAdapter#submitList(...), except we delegate to the differ.

Because you may be wondering, here's the ViewHolder:

    internal class ViewHolder(itemView: View, private val callback: ThingClickCallback) :
        RecyclerView.ViewHolder(itemView) {
        private val title: TextView = itemView.findViewById(R.id.thingName)
        private val age: TextView = itemView.findViewById(R.id.thingAge)

        fun bind(data: Thing) {
            title.text = data.name
            age.text = data.age.toString()
            itemView.setOnClickListener { callback.onThingClicked(adapterPosition) }
        }
    }

Don't be too harsh, I know i passed the click listener directly, I only had about 1 hour to do all this, but nothing special, the layout it's just two text views (age and name) and we set the whole row clickable to pass the position to the callback. Nothing special here either.

Last but not least, the Fragment.

Fragment

class ThingListFragment : Fragment() {
    private lateinit var viewModel: ThingsViewModel
    private var binding: ThingsListFragmentBinding? = null
    private val adapter = ThingAdapter(object : ThingClickCallback {
        override fun onThingClicked(atPosition: Int) {
            viewModel.modifyData(atPosition)
        }
    })
...

It has 3 member variables. The ViewModel, the Binding (I used ViewBinding why not it's just 1 liner in gradle), and the Adapter (which takes the Click listener in the ctor for convenience).

In this impl., I simply call the viewmodel with "modify item at position (X)" where X = the position of the item clicked in the adapter. (I know this could be better abstracted but this is irrelevant here).

there's only two other implemented methods in this fragment...

onDestroy:

    override fun onDestroy() {
        super.onDestroy()
        binding = null
    }

(I wonder if Google will ever accept their mistake with Fragment's lifecycle that we still have to care for this).

Anyway, the other is unsurprisingly, onCreateView.

 override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        val root = inflater.inflate(R.layout.things_list_fragment, container, false)
        binding = ThingsListFragmentBinding.bind(root)

        viewModel = ViewModelProvider(this).get(ThingsViewModel::class.java)
        viewModel.state.observe(viewLifecycleOwner) { state ->
            when (state) {
                is ThingsState.Empty -> adapter.submitList(emptyList())
                is ThingsState.Loaded -> adapter.submitList(state.things)
                is ThingsState.Loading -> doNothing // Show Loading? :)
            }
        }

        binding?.thingsRecyclerView?.adapter = adapter
        viewModel.fetchData()

        return root
    }

Bind the thing (root/binding), get the viewModel, observe the "state", set the adapter in the recyclerView, and call the viewModel to start fetching data.

That's all.

How does it work then?

The app starts, the fragment is created, subscribes to the VM state LiveData, and triggers the Fetch of data. The ViewModel calls the repo, which is empty (new), so makeItems is called the list now has items and cached in the repo's "source" list. The viewModel receives this list asynchronously (in a coroutine) and posts the LiveData state. The fragment receives the state and posts (submit) to the Adapter to finally show something.

When you "click" on an Item, ViewHolder (which has a click listener) triggers the "call back" towards the fragment which receives a position, this is then passed onto the Viewmodel and here the data is mutated in the Repo, which again, pushes the same list, but with a different reference on the clicked item that was modified. This causes the ViewModel to push a new LIveData state with the same list reference as before, towards the fragment, which -again- receives this, and does adapter.submitList(...).

The Adapter asynchronously calculates this and the UI updates.

It works, I can put all this in GitHub if you want to have fun, but my point is, while the concerns about the AsyncDiffer are valid (and may be or been true), this doesn't seem to be my (super limited) experience.

Are you using this differently?

When I tap on any row, the change is propagated from the Repository

UPDATE: forgot to include the doNothing function:


val doNothing: Unit
    get() = Unit

I've used this for a while, I normally use it because it reads better than XXX -> {} to me. :)

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

While doing

items.toMutableList().map { it.copy() }
restaurants.postValue(items)

you are creating a new list but items remains the same. You have to store that new list into a variable or passing that operation directly as a param to postItem.

ycesar
  • 420
  • 5
  • 9