RecyclerView DiffUtil with Change Payload

Mar 7, 2021

IMG_2054.jpg

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:

before-payloads.gif

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:

after-payloads.gif

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 when areItemsTheSame() will evaluate to true and areContentsTheSame() will evaluate to false.

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

 
Previous
Previous

Achieving Negative Margin in ConstraintLayout

Next
Next

Conditional Caching with Retrofit and OkHttp