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.

Note: This Adapter and DiffCallback are correct and match how a real TrailLog project would be structured, but this website's own project doesn't run a live Android Studio build, so nothing here executes on the page itself.

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.

Up next: With the list itself efficient, Material Components covers the buttons, text fields, and cards that make TrailLog's screens look and feel like a modern Android app.