RecyclerView DiffUtil with Change Payload
Mar 7, 2021
Often when your RecyclerView requires user interaction and re-rendering only parts of the item that changed. Why? If you have to re-render an entire item that changed, depending on the views that make up that item there could be flickering involved.
For instance, when making an item in the following RecyclerView a favorite by tapping the ♡, the entire changed item may flicker being the card (including its image) gets re-rendered:
The Adapter that implements that interaction is below:
class ItemAdapter( private val favoriteListener: (String, Boolean) -> Unit ) : ListAdapter<Item, ItemViewHolder>(ItemDiffCallback()) { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ItemViewHolder { val binding = ItemCardBinding.inflate( LayoutInflater.from(parent.context), parent, false ) return ItemViewHolder(binding, favoriteListener) } override fun onBindViewHolder(holder: ItemViewHolder, position: Int) { holder.bind(getItem(position)) } } class ItemViewHolder( private val binding: ItemCardBinding, private val favoriteListener: (String, Boolean) -> Unit ) : RecyclerView.ViewHolder(binding.root) { lateinit var item: Item init { binding.favoriteIcon.setOnClickListener { favoriteListener(item.id, !it.isSelected) } } fun bind(item: Item) { this.item = item binding.image.load(item.imageResId) { crossfade(true) } binding.title.text = item.title binding.description.text = item.description binding.favoriteIcon.isSelected = item.isFavorite } } class ItemDiffCallback : DiffUtil.ItemCallback<Item>() { override fun areItemsTheSame(oldItem: Item, newItem: Item): Boolean { return oldItem.id == newItem.id } override fun areContentsTheSame(oldItem: Item, newItem: Item): Boolean { return oldItem == newItem } }
Obviously, that’s not optimal. User experience is of utmost importance. What we want is the ♡ to turn into 🖤(or vice versa) only—the entire item should not re-render causing flickering.
This is the behavior we really want to see:
Now, only the favorite icon is being re-rendered which is what we wanted. How did we do it?
Meet RecyclerView Payloads
class ItemAdapter( private val favoriteListener: (String, Boolean) -> Unit ) : ListAdapter<Item, ItemViewHolder>(ItemDiffCallback()) { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ItemViewHolder { val binding = ItemCardBinding.inflate( LayoutInflater.from(parent.context), parent, false ) return ItemViewHolder(binding, favoriteListener) } override fun onBindViewHolder(holder: ItemViewHolder, position: Int) { holder.bind(getItem(position)) } override fun onBindViewHolder(holder: ItemViewHolder, position: Int, payloads: MutableList<Any>) { if (payloads.isEmpty()) { super.onBindViewHolder(holder, position, payloads) } else { if (payloads[0] == true) { holder.bindFavoriteState(getItem(position).isFavorite) } } } } class ItemViewHolder( private val binding: ItemCardBinding, private val favoriteListener: (String, Boolean) -> Unit ) : RecyclerView.ViewHolder(binding.root) { lateinit var item: Item init { binding.favoriteIcon.setOnClickListener { favoriteListener(item.id, !it.isSelected) } } fun bind(item: Item) { this.item = item binding.image.load(item.imageResId) { crossfade(true) } binding.title.text = item.title binding.description.text = item.description binding.favoriteIcon.isSelected = item.isFavorite } fun bindFavoriteState(isFavorite: Boolean) { binding.favoriteIcon.isSelected = isFavorite } } class ItemDiffCallback : DiffUtil.ItemCallback<Item>() { override fun areItemsTheSame(oldItem: Item, newItem: Item): Boolean { return oldItem.id == newItem.id } override fun areContentsTheSame(oldItem: Item, newItem: Item): Boolean { return oldItem == newItem } override fun getChangePayload(oldItem: Item, newItem: Item): Any? { return if (oldItem.isFavorite != newItem.isFavorite) true else null } }
The
getChangedPayload()
will trigger whenareItemsTheSame()
will evaluate totrue
andareContentsTheSame()
will evaluate tofalse
.
In our example changing Item
’s isFavorite
value will result in areContentsTheSame()
returning false
. triggering a call to getChangePayload()
. And when that happens, we handle the changed payload in the overloaded onBindViewHolder
override fun onBindViewHolder( holder: ItemViewHolder, position: Int, payloads: MutableList<Any> ) { if (payloads.isEmpty()) { super.onBindViewHolder(holder, position, payloads) } else { if (payloads[0] == true) { holder.bindFavoriteState(getItem(position).isFavorite) } } }
Depending on how many parts of the Item can change, we can design our payload differently. In our case, only the favorite status can change so we just return true to signal that we should handle a single payload type we support. Our payload could be an enum
or something else if we supported multiple change types.
And finally, holder.bindFavoriteState()
simply looks like this.
fun bindFavoriteState(isFavorite: Boolean) { binding.favoriteIcon.isSelected = isFavorite }
Only the favorite status gets re-rendered for that item in our RecyclerView which does not cause any flickering which is what we wanted. I hope you see how powerful RecyclerView payloads can be.
You can view the entire source code for this example at https://github.com/jshvarts/DiffUtilPayloadDemo and as always, let me know if you have any ideas on improving this implementation