Advanced RecyclerView Insertion and Deletion — How to Maintain Position

Eric N
6 min readFeb 22, 2023

What if you need to display the correct items’ positions at all times as items are constantly added and removed from the list? I’ll show you how to achieve such requirement in this article

The basics of RecyclerView insertion and deletion are covered in my previous article which this article builds upon

Overview of our sample app

  • Displays a randomly ranked list of up to 195 countries
  • You can delete any country by clicking on the “Delete” button
  • You can more items to the list by tapping on the floating “Add random” button. To avoid duplicates, UUID strings are used instead of country names for addition here.

Take note that although all additions and removals are happening in our sample app, in a real-life scenario, those actions could be performed by the same or another user on multiple devices. An example is a shared shopping cart.

RecyclerView Quiz

Before we before, let’s refresh our knowledge of a few RecyclerView basics

  1. Among notifyItem*Inserted, notifyItem*Removed, notifyItem*Changed and notifyDataSetChanged which triggers RecyclerView.Adapter#onBindViewHolder?
  2. Which of those methods are triggered by Jetpack’s ListAdapter internally?

Answers will be at the end of the article.

How to display up-to-date positions for items in RecyclerView?

Let’s iterate on our sample app from Part 1 — RecyclerView — How to handle insertion and deletion and add the position/ rank to the list item:

list_item.xml

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="16dp"
xmlns:app="http://schemas.android.com/apk/res-auto">

<TextView
android:id="@+id/title"
android:layout_width="0dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toStartOf="@id/cta"
android:layout_marginEnd="8dp"
android:textSize="16sp"
app:layout_constraintTop_toTopOf="parent"
android:layout_height="wrap_content" />
<TextView
android:id="@+id/rank"
android:textStyle="bold"
android:textSize="14sp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/title"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />

<!-- Delete CTA -->
</androidx.constraintlayout.widget.ConstraintLayout>

ViewHolder

class MyViewHolder(private val binding: ListItemBinding): RecyclerView.ViewHolder(binding.root) {
fun bind(item: DummyData, listener: ItemDeleteListener) {
binding.title.text = "${item.content}"
binding.rank.text = itemView.context.getString(R.string.rank, layoutPosition)

binding.cta.setOnClickListener {
listener.delete(layoutPosition)
}
}
}

Our ClassicRvAdapter remains the same as before

override fun onBindViewHolder(holder: MyViewHolder, position: Int) {
val item = items[position]
val listener = object : ItemDeleteListener {
override fun delete(position: Int) {
items.removeAt(position)
notifyItemRemoved(position)
}
}
holder.bind(item, listener)
}
//...
fun add(dummyData: DummyData, position: Int) {
items.add(position, dummyData)
notifyItemInserted(position)
}

If you run the app, you’d notice the ranks are not updated correctly as you add and remove items

This is because notifyItem*Inserted and notifyItem*Removed do not trigger onBindViewHolder. In other words, the existing list item views will not re-render, showing the old ranks instead.

We need to explicitly tell the list item views to refresh.

While notifyDataSetChanged() would work, but it’s inefficient and lacks the nice animation as we learned in Part 1 — RecyclerView — How to handle insertion and deletion. notifyItemRangedChanged is more appropriate

Deletion

When you delete an item at position K in a list of N items, all items from Kth to Nth shift forwards (by 1). So, the following will achieve our requirements:

override fun onBindViewHolder(holder: MyViewHolder, position: Int) {
val item = items[position]

val listener = object : ItemDeleteListener {
override fun delete(position: Int) {
val newItems = ArrayList(items)
newItems.removeAt(position)
items.clear()
items.addAll(newItems)
notifyItemRemoved(position)
val itemChangedCount = items.size - position
notifyItemRangeChanged(position, itemChangedCount)
}
}
holder.bind(item, listener)
}

Insertion

Similarly, when you insert an item at position K in a list of N items, all items from Kth to Nth move backwards (by 1). The following code is what we need to re-render those items

fun add(dummyData: DummyData, position: Int) {
val newItems = ArrayList(items)
newItems.add(position, dummyData)
val changedCount = itemCount - position + 1
items.clear()
items.addAll(newItems)
notifyItemInserted(position)
notifyItemRangeChanged(position, changedCount)
}

Now run the app again and it will work as expected.

The source code is at https://github.com/ericntd/recyclerview-insert-delete/tree/insert-delete-maintaining-position

DiffUtil and Jetpack’s ListAdapter

As you recall from Part 1 — RecyclerView — How to handle insertion and deletion, DiffUtil and ListAdapter can help us call the appropriate notifyItem*Removed/Inserted for us, we only need to focus on feeding the RecyclerView.Adapter the correct data set. Let’s try to replace ClassicRvAdapter with MyListAdapter

class MyListAdapter: ListAdapter<DummyData, MyViewHolder>(DiffUtilItemCallback) {
object DiffUtilItemCallback: DiffUtil.ItemCallback<DummyData>() {
override fun areItemsTheSame(oldItem: DummyData, newItem: DummyData) = oldItem.id == newItem.id

override fun areContentsTheSame(oldItem: DummyData, newItem: DummyData) = oldItem == newItem
}

//...

override fun onBindViewHolder(holder: MyViewHolder, position: Int) {
val item = getItem(position)
val listener = object : ItemDeleteListener {
override fun delete(position: Int) {
val newItems = ArrayList(currentList)
newItems.removeAt(position)
val changedCount = itemCount - position
submitList(newItems)
// Trigger onBindViewHolder for the rest of the items that moved in front
notifyItemRangeChanged(position, changedCount)
}
}
holder.bind(item, listener)
}

//...

fun add(dummyData: DummyData, position: Int) {
val newItems = ArrayList(currentList)
newItems.add(position, dummyData)
val changedCount = itemCount - position + 1
submitList(newItems)
// Trigger onBindViewHolder for the rest of the items that moved to the end of the list
notifyItemRangeChanged(position, changedCount)
}
}

Rerun the app, and you’ll notice that while the items’ ranks get updated, they are not correct. 2 items next to one another would share the same rank.

A bit of debugging showed me that there is some sort of race condition there. notifyItemRangeChanged is always called before ListAdapter internally calls notifyItemRangeRemoved/Inserted (check out AdapterListUpdateCallback). Fortunately, ListAdapter#submitList gives us the option to provide a callback so we can fix the race condition as follows

override fun onBindViewHolder(holder: MyViewHolder, position: Int) {
val item = getItem(position)
val listener = object : ItemDeleteListener {
override fun delete(position: Int) {
val newItems = ArrayList(currentList)
newItems.removeAt(position)
val changedCount = itemCount - position
submitList(newItems) {
// Triggered once deletion is done and notifyItemRangeRemoved has been called
notifyItemRangeChanged(position, changedCount)
}
}
}
holder.bind(item, listener)
}
//...
fun add(dummyData: DummyData, position: Int) {
val newItems = ArrayList(currentList)
newItems.add(position, dummyData)
val changedCount = itemCount - position + 1
submitList(newItems) {
// Triggered once insertion is done and notifyItemRangeInserted has been called
notifyItemRangeChanged(position, changedCount)
}
}

Alternatively, you can also achieve our requirement by calling DiffUtil ourselves

class MyListAdapterDiffUtil: RecyclerView.Adapter<MyViewHolder>() {
private val items = mutableListOf<DummyData>()

class MyDiffCallback(private val oldList: List<DummyData>, private val newList: List<DummyData>) : DiffUtil.Callback() {
override fun getOldListSize() = oldList.size

override fun getNewListSize() = newList.size

override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int) = oldList[oldItemPosition].id == newList[newItemPosition].id

override fun areContentsTheSame(oldCourse: Int, newPosition: Int) = oldList[oldCourse] == newList[newPosition]
}

//...

override fun getItemCount() = items.size

override fun onBindViewHolder(holder: MyViewHolder, position: Int) {
val item = items[position]
Log.d(tag, "onBindViewHolder - item at position $position is ${item.content}")

val listener = object : ItemDeleteListener {
override fun delete(position: Int) {
val newItems = ArrayList(items)
newItems.removeAt(position)
val changedCount = items.size - position
updateAll(newItems)
notifyItemRangeChanged(position, changedCount)
}
}
holder.bind(item, listener)
}

fun updateAll(newList: List<DummyData>) {
val diffCallback = MyDiffCallback(items, newList)
val diffResult = DiffUtil.calculateDiff(diffCallback)
items.clear()
items.addAll(newList)
/**
* Will trigger notifyRangeRemoved and/ or notifyItemRangedInserted internally
*/
diffResult.dispatchUpdatesTo(AdapterListUpdateCallback(this))
}

fun add(dummyData: DummyData, position: Int) {
val newItems = ArrayList(items)
newItems.add(position, dummyData)
val changedCount = itemCount - position + 1
updateAll(newItems)
// Trigger onBindViewHolder for the rest of the items that moved to the end of the list
notifyItemRangeChanged(position, changedCount)
}
}

Answers to the quiz

  1. Among notifyItem*Inserted, notifyItem*Removed, notifyItem*Changed and notifyDataSetChanged which triggers RecyclerView.Adapter#onBindViewHolder?

Answer: notifyItemChanged, notifyItemRangedChanged and notifyDataSetChanged

2. Which of those methods are Jetpack’s ListAdapter internally triggers?

Answer: notifyItemRangeRemoved and notifyItemRangeInserted

Conclusion

In most cases, we won’t need to care about the position or the rank of the items in a RecyclerView so simply using ListAdapter or calling notifyItemRangeRemoved and/ or notifyItemRangeInserted ourselves would be sufficient. However, there are cases where we do, for example, show a real-time leaderboard of players in a game or participants in an election. I hope you have found my tutorial useful.

Are there any challenges you need help with regarding RecyclerView or any interesting learnings you’d like to share? Let me know in a comment ;)

--

--