Search code examples
androidandroid-recyclerviewandroid-listadapter

onCreateViewHolder is called twice for one item in list


I am using RecyclerView with items that change quite frequently. Like every item has progress bar. Also, I'm using ListAdapter with DiffUtils callback. And I've noticed a strange behavior: let's say I have just one item in the list and change progress for that item. For some reason, for this case RecyclerView creates two viewholders for that item and uses them both for binding that item. This results in glitching of the UI, especially when the item is heavy. Here is an example with some logs:

class MainActivity : ComponentActivity() {

data class TestData(
    val id: Long,
    val name: String
)

class MyHolder(view: View) : ViewHolder(view) {

    val textView = view as TextView

    fun bind(data: TestData) {
        Log.i("TAG", "bind title $data for holder $this")
        textView.text = data.name
    }
}

class Adapter : ListAdapter<TestData, MyHolder>(
    object : DiffUtil.ItemCallback<TestData>() {
        override fun areItemsTheSame(oldItem: TestData, newItem: TestData): Boolean {
            return oldItem.id == newItem.id
        }

        override fun areContentsTheSame(oldItem: TestData, newItem: TestData): Boolean {
            return oldItem == newItem
        }

    }
) {

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MyHolder {
        return MyHolder(LayoutInflater.from(parent.context).inflate(R.layout.test, parent, false))
    }

    override fun onBindViewHolder(holder: MyHolder, position: Int) {
        holder.bind(getItem(position))
    }

}

private val myAdapter = Adapter()

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)

    setContentView(R.layout.activity)

    val recycler = findViewById<RecyclerView>(R.id.recycler)
    recycler.adapter = myAdapter

    lifecycleScope.launch {

        repeat(10) {
            delay(500)
            myAdapter.submitList(listOf(TestData(1, "name $it")))
        }

    }

}

}

here is the log:

06-14 01:57:03.068 12096 12096 I TAG     : bind title TestData(id=1, name=name 0) for holder MyHolder{66be5bf position=0 id=-1, oldPos=-1, pLpos:-1 no parent}
06-14 01:57:03.586 12096 12096 I TAG     : bind title TestData(id=1, name=name 1) for holder MyHolder{9497c51 position=0 id=-1, oldPos=-1, pLpos:-1 no parent}
06-14 01:57:04.083 12096 12096 I TAG     : bind title TestData(id=1, name=name 2) for holder MyHolder{66be5bf position=0 id=-1, oldPos=-1, pLpos:-1 no parent}
06-14 01:57:04.584 12096 12096 I TAG     : bind title TestData(id=1, name=name 3) for holder MyHolder{9497c51 position=0 id=-1, oldPos=-1, pLpos:-1 no parent}
06-14 01:57:05.083 12096 12096 I TAG     : bind title TestData(id=1, name=name 4) for holder MyHolder{66be5bf position=0 id=-1, oldPos=-1, pLpos:-1 no parent}
06-14 01:57:05.582 12096 12096 I TAG     : bind title TestData(id=1, name=name 5) for holder MyHolder{9497c51 position=0 id=-1, oldPos=-1, pLpos:-1 no parent}
06-14 01:57:06.082 12096 12096 I TAG     : bind title TestData(id=1, name=name 6) for holder MyHolder{66be5bf position=0 id=-1, oldPos=-1, pLpos:-1 no parent}
06-14 01:57:06.601 12096 12096 I TAG     : bind title TestData(id=1, name=name 7) for holder MyHolder{9497c51 position=0 id=-1, oldPos=-1, pLpos:-1 no parent}
06-14 01:57:07.102 12096 12096 I TAG     : bind title TestData(id=1, name=name 8) for holder MyHolder{66be5bf position=0 id=-1, oldPos=-1, pLpos:-1 no parent}
06-14 01:57:07.601 12096 12096 I TAG     : bind title TestData(id=1, name=name 9) for holder MyHolder{9497c51 position=0 id=-1, oldPos=-1, pLpos:-1 no parent}

As you can see, I just update on item with same id, but recyclerView uses two viewHolders. Why? Is it some sort of optimization? It works really poorly.


Solution

  • The intended way to handle partial updates of "heavy" items is to implement

    ItemCallback.getChangePayload(oldItem : T, newItem: T) : Any?

    in your ItemCallback to get detailed diff then consume it in your adapter by overriding

    Adapter.onBindViewHolder(holder VH, position Int, payloads List<Any>).

    That way you get no animation and only update views within your viewholder that actually need to change.

    For your curiosity - it comes down to this line in DefaultItemAnimator:

    public boolean canReuseUpdatedViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, @NonNull List<Object> payloads) {
        return !payloads.isEmpty() || super.canReuseUpdatedViewHolder(viewHolder, payloads);
    }