Thuy's Blog

Prefer ListAdapter

September 14, 2019

Problem

We want to show a list of items on a RecyclerView. All the items have the same kind and are represented by the following Item class:

data class Item(val id: String = UUID.randomUUID().toString())

When we tap an item, a new item will be inserted above that tapped item. ItemsViewModel will be responsible for that logic:

class ItemsViewModel : ViewModel() {
  private val _items = MutableLiveData(listOf(Item()))
  val items: LiveData<List<Item>> get() = _items

  fun clickAt(position: Position) {
    val currentItems = _items.value!!
    _items.value = currentItems.subList(0, position) +
      Item() +
      currentItems.subList(position, currentItems.size)
  }
}

Next, in order for RecyclerView to render List<Item>, we will need a RecyclerView.ViewHolder and RecyclerView.Adapter. My ItemViewHolder has a simple responsibility. It shows the UUID from an Item and propagate click events via onItemClick.

class ItemViewHolder(
  private val binding: ItemBinding,
  private val onItemClick: (Position) -> Unit
) : RecyclerView.ViewHolder(binding.root) {
  fun bind(item: Item) {
    binding.textView.text = item.id
    binding.root.setOnClickListener { onItemClick(adapterPosition) }
  }
}

RecyclerView.ViewHolder is done. Now, how do we implement the RecyclerView.Adapter?

Solution

Not recommended

This solution is based on the old way of ListView. Basically the adapter will keep a mutable reference to a List<Item>. Then it will expose a setItems() so that whenever we change the items, the adapter will refresh RecyclerView via notifyDataSetChanged().

class ItemsAdapter(
  private var items: List<Item> = emptyList(),
  private val onItemClick: (Position) -> Unit
) : RecyclerView.Adapter<ItemViewHolder>() {
  override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ItemViewHolder {
    val binding = ItemBinding.inflate(LayoutInflater.from(parent.context), parent, false)
    return ItemViewHolder(binding = binding, onItemClick = onItemClick)
  }

  override fun getItemCount(): Int {
    return items.size
  }

  override fun onBindViewHolder(holder: ItemViewHolder, position: Int) {
    holder.bind(items[position])
  }

  fun setItems(newItems: List<Item>) {
    items = newItems
    notifyDataSetChanged()
  }
}

Then, in the view controller ItemsActivity, we combine everything together to make it work:

/** In ItemsActivity.kt */
private fun initWithOldWay(binding: ActivityItemsBinding) {
  val adapter = ItemsAdapter(items = viewModel.items.value!!, onItemClick = {
    viewModel.clickAt(position = it)
  })
  binding.itemsView.adapter = adapter
  viewModel.items.observe(this, Observer {
    adapter.setItems(it)
  })
}

A reason why I do not prefer this way is because notifyDataSetChanged() does not animate RecyclerView and new item is inserted:

Recommended

A preferable way will be to use ListAdapter. ListAdapter offers a submitList() which we can use to refresh RecyclerView. Item changes will be animated out of the box. However, we have to define a diff callback so that ListAdapter can automatically calculate the diff between item changes. A simple implementation of diff callback can be based on equals():

object ItemDiffCallback : DiffUtil.ItemCallback<Item>() {
  override fun areItemsTheSame(oldItem: Item, newItem: Item): Boolean {
    return oldItem == newItem
  }

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

This ItemDiffCallback only works for Item. If we have many cases of showing items, we can abstract into a generic function like below:

/** T should be a data class. */
fun <T> equatableDiffCallbacks(): DiffUtil.ItemCallback<T> {
  return object : DiffUtil.ItemCallback<T>() {
    override fun areItemsTheSame(oldItem: T, newItem: T): Boolean {
      return oldItem == newItem
    }

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

Defining the adapter now becomes a bit simpler with ListAdapter. There’s no need to override getItemCount() and we don’t have to manage a mutable reference of List<Item>:

class NewItemsAdapter(
  private val onItemClick: (Position) -> Unit
) : ListAdapter<Item, ItemViewHolder>(equatableDiffCallbacks<Item>()) {
  override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ItemViewHolder {
    val binding = ItemBinding.inflate(LayoutInflater.from(parent.context), parent, false)
    return ItemViewHolder(binding = binding, onItemClick = onItemClick)
  }

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

onBindViewHolder() and onCreateViewHolder() remain unchanged. In the view controller, when observing the items LiveData from ItemsViewModel, we now call submitList() instead of setItems(). Everything else is still the same:

/** In ItemsActivity.kt */
private fun initWithNewWay(binding: ActivityItemsBinding) {
  val adapter = NewItemsAdapter(onItemClick = {
    viewModel.clickAt(position = it)
  })
  binding.itemsView.adapter = adapter
  viewModel.items.observe(this, Observer {
    adapter.submitList(it)
  })
}

Final result:

Sample code is available on my GitHub.


Thuy Trinh

Written by Thuy Trinh who lives and works in Frankfurt, Germany building robust Android apps. You should follow him on Twitter