Search code examples
androidkotlinandroid-recyclerviewlistadapter

Update a row in recyclerview with ListAdapter


  • I am trying to perform update & delete operation in a recyclerview with ListAdapter. For this example I am using LiveData to get updates as soon as data is updated.

  • I don't know why list doesn't shows updated data, but when I see logs it shows correct data.

Code:

@AndroidEntryPoint
class DemoActivity : AppCompatActivity() {

    var binding: ActivityDemoBinding? = null
    private val demoAdapter = DemoAdapter()
    private val demoViewModel: DemoViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityDemoBinding.inflate(layoutInflater)
        setContentView(binding?.root)
        initData()
    }

    private fun initData() {
        binding?.apply {
            btnUpdate.setOnClickListener {
                demoViewModel.updateData(pos = 2, newName = "This is updated data!")
            }

            btnDelete.setOnClickListener {
                demoViewModel.deleteData(0)
            }

            rvData.apply {
                layoutManager = LinearLayoutManager(this@DemoActivity)
                adapter = demoAdapter
            }
        }

        demoViewModel.demoLiveData.observe(this, {
            it ?: return@observe
            demoAdapter.submitList(it)
            Log.d("TAG", "initData: $it")
        })
    }
}

activity_demo.xml:

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".activities.DemoActivity">

    <Button
        android:id="@+id/btn_update"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_alignParentStart="true"
        android:text="Update Data" />

    <Button
        android:id="@+id/btn_delete"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_alignParentEnd="true"
        android:text="Delete Data" />

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/rv_data"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:layout_below="@id/btn_update" />

</RelativeLayout>

DemoAdapter:

    class DemoAdapter() : ListAdapter<DemoModel, DemoAdapter.DemoViewHolder>(DiffCallback()) {

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DemoViewHolder {
        val binding =
            ListItemDeleteBinding.inflate(LayoutInflater.from(parent.context), parent, false)
        return DemoViewHolder(binding)
    }

    override fun onBindViewHolder(holder: DemoViewHolder, position: Int) {
        val currentItem = getItem(position)
        holder.bind(currentItem)
    }

    inner class DemoViewHolder(private val binding: ListItemDeleteBinding) :
        RecyclerView.ViewHolder(binding.root) {

        fun bind(student: DemoModel) {
            binding.apply {
                txtData.text = student.name + " " + student.visible
                if (student.visible) txtData.visible()
                else txtData.inVisible()
            }
        }
    }

    class DiffCallback : DiffUtil.ItemCallback<DemoModel>() {
        override fun areItemsTheSame(oldItem: DemoModel, newItem: DemoModel) =
            oldItem.id == newItem.id

        override fun areContentsTheSame(oldItem: DemoModel, newItem: DemoModel) =
            (oldItem.id == newItem.id) &&
                    (oldItem.visible == newItem.visible) &&
                    (oldItem.name == newItem.name)
    }
}

DemoViewModel:

class DemoViewModel : ViewModel() {

    var demoListData = listOf(
        DemoModel(1, "One", true),
        DemoModel(2, "Two", true),
        DemoModel(3, "Three", true),
        DemoModel(4, "Four", true),
        DemoModel(5, "Five", true),
        DemoModel(6, "Six", true),
        DemoModel(7, "Seven", true),
        DemoModel(8, "Eight", true)
    )

    var demoLiveData = MutableLiveData(demoListData)

    fun updateData(pos: Int, newName: String) {
        val listData = demoLiveData.value?.toMutableList()!!
        listData[pos].name = newName
        demoLiveData.postValue(listData)
    }

    fun deleteData(pos: Int) {
        val listData = demoLiveData.value?.toMutableList()!!
        listData.removeAt(pos)
        demoLiveData.postValue(listData)
    }
}

Martin's Solution: https://github.com/Gryzor/TheSimplestRV


Solution

  • I suggest you:

    1. Do yourself a favor and add a proper ViewModel/Sealed Class to encapsulate your state.
    2. Initialize your adapter in the usual order:
     override fun onCreate(savedInstanceState: Bundle?) {
            super.onCreate(savedInstanceState)
            binding = ActivityDeleteBinding.inflate(layoutInflater)
            setContentView(binding?.root)
            binding.recyclerView.layoutManager = ... (tip: if you won't change the layout manager, I suggest you declare it in the XML directly, skipping this line here. E.g.: app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager")
            
            binding.recyclerView.adapter = yourAdapter
    
            //now observe data which will ultimately lead to `adapter.submitList(...)`
            
            initData()
        }
    
    
    1. Make sure your DiffUtil.ItemCallback is properly comparing your models. You did old == new in Content, but that's not comparing the content, that's comparing the whole thing. It's the same in this case (I assume, but we haven't seen your Delete model class), but it's best to be explicit about it; the id is not the "content" theoretically speaking for the purposes of this callback thing.

    2. delAdapter.submitList(it.toMutableList()) this is fine, but if you do it (and you do) before the adapter is set, and the LayoutManager is set (as you do), then it's likely possible that the ListAdapter is not magically recomputing it.

    Update After Seeing More of Your Code

    Let's look at your mutation code (one of the various):

        fun updateData(pos: Int, newName: String) {
            val listData = demoLiveData.value?.toMutableList()!!
            listData[pos].name = newName
            demoLiveData.postValue(listData)
        }
    

    I see various problems here.

    1. You're grabbing the value from the LiveData. No-Go. LiveData is a value-holder, but I wouldn't "pull it from there" at any time, expect when I receive it via the observation. LiveData is not a repository, it's just holding the value and offering you "guarantees" that it will be managed in conjunction with your lifecycleOwner.
    2. You then use toMutableList() and while this creates a new instance of the List (List<DemoModel> in your case), it does not create a deep copy of the references in the list. Meaning the items in the new (and old) list, are the same, pointing to the exact same spot in memory.
    3. You then perform this operation listData[pos].name = newName in the "new list" but you're effectively modifying the old list as well (you can set a breakpoint there, and inspect the contents of all the lists involved and notice how the same item at pos is now changed to the newName everywhere.
    4. If you want to see even more, put a breakpoint here:
    demoViewModel.demoLiveData.observe(this, {
                demoAdapter.submitList(it) <--> BREAKPOINT HERE
    })
    

    Also put a breakpoint in ListAdapter.java (the android class) in the submitList method:

     public void submitList(@Nullable List<T> list) {
            mDiffer.submitList(list); ---> BREAKPOINT HERE
     }
    

    And when stopped at the 1st breakpoint, observe the value of the list (it) and it's reference. (the first time the breakpoints hit, continue, since we want to observe the list AFTER you mutate the list and not on the "first creation").

    Now press your button to change something (update the list) and the breakpoint(s) are going to be hit again, now the submitList call will have a list and it's gonna look like:

    Image of Android Debugger With a List

    notice the Reference: it's (in my example) ArrayList@100073.

    Now continue... (the debugger), it will stop again in the mDiffer.submitList(list) line of ListAdapter.

    Let's compare.

    For the record, this is what I do:

            binding.updateButton.setOnClickListener {
                viewModel.updateData(0, "Hello World " + 5)
            }
    

    So The item at position "0" should be called "Hello World 5" now.

    This is already visible here in the debugger:

    Android Debugger with a List

    It's correctly changed in the list, but we're submitting to the adapter... let's see what the adapter has internally (before this is applied), let's jump to the next breakpoint in ListAdapter#submitList():

    Android Debugger Showing List of Items in ListAdapter mDiffer instance

    Notice something strange here?

    The item at position 0, is already modified. How?! Simple, the reference to that object DemoModel is the same. In my example: it's DemoModel@10078.

    So how can you prevent this?

    1. Never pass a mutable list to your adapter, always pass a copy (and immutable!)

    your Live Data should have been:

    var demoLiveData = MutableLiveData(demoList.toList()) //To List creates a new copy of the list, immutable. 
    
    1. This reinforces the concept of a Single Source of Truth. When you mutate data, you need to be sure you know what the scope of the mutation is. The reason why you saw no "change" is because by mutating the data behind the scenes of the adapter, by the time the DiffUtil (Which is async) was called and the change dispatched, the list was already mutated and the Diff Util computed zero changes, which meant the adapter had nothing else to do. Changing an item in the list, does not (and will never) trigger an adapter to "notify the data was changed", since the adapter is "not observing" the list.

    I hope this clarifies your confusion and the importance of not using mutable data all over the place.

    Last but not least, I created a super simple project to exercise your problem and pushed it to https://github.com/Gryzor/TheSimplestRV (or if you prefer to see the viewModel alone).

    Feel free to look at it (I used one of the default templates so the code is in a Fragment, but... irrelevant of course).

    Good luck! :)

    Why does NOTIFY DATA SET CHANGED WORK THEN?!

    Well, when you do that, you FORCE the adapter to rebind every item, therefore it has to go through the list again (which is changed) and the change is reflected, at the expense of CPU, Battery, flickering, position lost, annoyance to the user(s), etc.