Advanced RecyclerView Insertion and Deletion — How to Maintain Position
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
- Among
notifyItem*Inserted
,notifyItem*Removed
,notifyItem*Changed
andnotifyDataSetChanged
which triggersRecyclerView.Adapter#onBindViewHolder
? - 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
- Among
notifyItem*Inserted
,notifyItem*Removed
,notifyItem*Changed
andnotifyDataSetChanged
which triggersRecyclerView.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 ;)