RecyclerView: DiffUtil & ListAdapter
The TrailAdapter built in the previous lesson works, but it has a hidden cost once
TrailLog's trail list actually starts changing — a new trail logged, one deleted, one renamed. This
lesson fixes that with DiffUtil and Jetpack's ListAdapter, the
production-standard way to keep a RecyclerView in sync with changing data.
The Problem With notifyDataSetChanged()
The simplest way to tell a plain RecyclerView.Adapter that its data changed is to update
the backing list and call notifyDataSetChanged(). It works, but it's a blunt instrument:
it tells the RecyclerView that every single row on screen might be different
now, even if only one trail out of fifty actually changed. That means every visible row gets
unbound and rebound from scratch, which wastes CPU work that wasn't needed, and it also throws away
any in-progress row animations — a row mid-fade-out from an unrelated update gets yanked straight to
its new state instead of finishing smoothly. For a list that changes occasionally rather than
wholesale, this is far more work, and a worse look, than the update actually calls for.
DiffUtil: Calculating the Minimal Change
DiffUtil is Jetpack's tool for comparing an old list and a new list and working out
the smallest possible set of insertions, removals, and content changes needed to turn one into the
other — the same idea as a source-control diff, applied to a list of items instead of lines of text.
Instead of "redraw everything," it produces "row 3 was removed, row 7's content changed" — and the
RecyclerView can then animate exactly that: a small slide-out for the removed row, a
content fade for the changed one, and nothing at all for the untouched rows.
Writing a DiffUtil.ItemCallback for Trail
To compare two lists of Trail, DiffUtil needs to know two different things about a pair
of items: are they the same item (usually by id), and if so, is their content still
identical:
class TrailDiffCallback : DiffUtil.ItemCallback<Trail>() {
override fun areItemsTheSame(oldItem: Trail, newItem: Trail): Boolean {
return oldItem.id == newItem.id
}
override fun areContentsTheSame(oldItem: Trail, newItem: Trail): Boolean {
return oldItem == newItem
}
}
areItemsTheSame() answers "is this logically the same trail, possibly with edited
fields?" — comparing id is exactly right here, since a trail's id never changes even if
its name or notes are edited. areContentsTheSame() answers a different question: "has
anything about it actually changed?" This is where a data class (covered in
Data Classes & Sealed Classes) pays off directly —
because Trail is a data class, Kotlin automatically generates an
equals() that compares every property (name, notes, photoUri, latitude, longitude,
distanceKm, date). Writing oldItem == newItem is therefore both correct and trivial;
without a data class, you'd have to hand-write a comparison of every field yourself and
keep it in sync every time a field is added.
ListAdapter: DiffUtil Without the Boilerplate
You could call DiffUtil.calculateDiff() yourself, on a background thread, and manually
dispatch the result to your adapter — but Jetpack's ListAdapter already does exactly
that for you. It's a base class built specifically to pair with a DiffUtil.ItemCallback,
and it runs the diff calculation on a background thread automatically, so a large list never causes a
visible stutter while the comparison runs.
class TrailAdapter(
private val onTrailClick: (Trail) -> Unit
) : ListAdapter<Trail, TrailAdapter.TrailViewHolder>(TrailDiffCallback()) {
class TrailViewHolder(val binding: ItemTrailBinding) :
RecyclerView.ViewHolder(binding.root)
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): TrailViewHolder {
val binding = ItemTrailBinding.inflate(
LayoutInflater.from(parent.context), parent, false
)
return TrailViewHolder(binding)
}
override fun onBindViewHolder(holder: TrailViewHolder, position: Int) {
val trail = getItem(position)
holder.binding.itemTrailNameText.text = trail.name
holder.binding.itemTrailDistanceText.text = "${trail.distanceKm} km"
holder.binding.root.setOnClickListener { onTrailClick(trail) }
}
}
Notice what's missing compared to the plain RecyclerView.Adapter from the
previous lesson: no getItemCount(), no constructor list of trails, and no
onBindViewHolder indexing into a list you manage yourself —
getItem(position) reads from the list ListAdapter is already tracking
internally. Updating the screen whenever the data changes is now a single call:
trailAdapter.submitList(newTrails)
submitList() hands the new list to ListAdapter, which diffs it against
whatever it currently holds using your TrailDiffCallback, calculates the minimal set of
changes on a background thread, and then dispatches exactly those granular update, insert, and remove
calls to the RecyclerView — all without you writing any of that plumbing by hand.
The Production Standard
Between the plain RecyclerView.Adapter from the previous lesson and ListAdapter
shown here, ListAdapter is what you should reach for in a real app almost every time a
list's contents can change after it's first shown — which, in practice, is nearly always. The plain
Adapter still has its place for genuinely static, never-changing lists, but for TrailLog's trail list —
or any list backed by a database or network call — ListAdapter with a
DiffUtil.ItemCallback is the standard, recommended starting point.