RecyclerView Basics
TrailLog's whole reason to exist is a list of logged trails, and that list needs to display comfortably whether the user has logged three trails or three hundred. RecyclerView is Android's standard widget for exactly that: displaying a scrolling list of items efficiently, no matter how long the list gets. This lesson builds the trail list from the ground up: the item layout, the Adapter, the ViewHolder, the LayoutManager, and handling a tap on a row.
Why "Recycler"? The Problem It Solves
If TrailLog naively created a brand-new row view for every single trail in the list, a user with
three hundred logged trails would mean three hundred live view objects in memory — even though only
eight or so are ever visible on screen at once. RecyclerView instead keeps a small pool
of row views around, roughly enough to fill the visible screen plus a couple extra, and
reuses them as the user scrolls: when a row scrolls off the top, its view isn't
destroyed, it's handed back to display whatever new row is scrolling up from the bottom. This is why
it matters for a list that could have hundreds of trails — memory use and scroll performance stay
flat no matter how long the underlying list grows, because the number of live row views never grows
past what the screen can actually show.
Piece 1: The Item Layout
First, a layout describing exactly one row in the list — a small card showing a trail's name and distance:
<!-- res/layout/item_trail.xml -->
<androidx.cardview.widget.CardView
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="8dp"
app:cardCornerRadius="8dp"
app:cardElevation="2dp">
<LinearLayout
android:orientation="vertical"
android:padding="12dp"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<TextView
android:id="@+id/itemTrailNameText"
android:textStyle="bold"
android:textSize="16sp"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<TextView
android:id="@+id/itemTrailDistanceText"
android:textColor="#666666"
android:layout_marginTop="4dp"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
</LinearLayout>
</androidx.cardview.widget.CardView>
This XML describes one card-shaped row — nothing here says anything about a list; it's purely the shape of a single item.
Piece 2: The ViewHolder
A ViewHolder wraps one inflated item_trail.xml row and holds onto
references to its views, so they only need to be looked up once per row rather than every time that
row is re-bound. Using View Binding here (see
View Binding) gives type-safe references for free:
class TrailViewHolder(private val binding: ItemTrailBinding) :
RecyclerView.ViewHolder(binding.root) {
fun bind(trail: Trail) {
binding.itemTrailNameText.text = trail.name
binding.itemTrailDistanceText.text = "${trail.distanceKm} km"
}
}
Piece 3: The Adapter
The Adapter is what connects your data (a list of Trail objects) to
those row views. It has exactly three jobs: create a new row view when needed
(onCreateViewHolder), fill an existing row with a particular trail's data
(onBindViewHolder), and report how many items exist in total
(getItemCount):
class TrailAdapter(
private val trails: List<Trail>,
private val onTrailClick: (Trail) -> Unit
) : RecyclerView.Adapter<TrailAdapter.TrailViewHolder>() {
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 = trails[position]
holder.binding.itemTrailNameText.text = trail.name
holder.binding.itemTrailDistanceText.text = "${trail.distanceKm} km"
holder.binding.root.setOnClickListener { onTrailClick(trail) }
}
override fun getItemCount(): Int = trails.size
}
onCreateViewHolder only runs when the pool of reusable rows needs a brand-new one — it
inflates item_trail.xml once and wraps it in a TrailViewHolder.
onBindViewHolder runs far more often, every time a row (new or recycled) needs to display
a particular trail at a given position. Keeping the ViewHolder class nested inside the
Adapter, as shown here, is a common convention since it's only ever meaningful in the context of this
specific Adapter.
Piece 4: The LayoutManager
A RecyclerView on its own doesn't know how to arrange its items — that's the job of a
LayoutManager. For a plain vertical list like TrailLog's trail list,
LinearLayoutManager is the standard choice. If TrailLog ever wanted a grid of trail photo
thumbnails instead of a single-column list, GridLayoutManager is the alternative —
same Adapter, same ViewHolder, just a different arrangement of the same rows.
Wiring It All Together
Inside TrailListFragment, once the trails are loaded, connecting everything is just two
lines:
binding.recyclerView.layoutManager = LinearLayoutManager(context)
binding.recyclerView.adapter = TrailAdapter(trails) { trail ->
openTrailDetail(trail)
}
layoutManager tells the RecyclerView how to arrange rows;
adapter tells it what data to show and how to build each row. Both must be set, or the
list simply stays empty.
Handling an Item Click
Notice the TrailAdapter constructor above takes a lambda,
onTrailClick: (Trail) -> Unit, alongside the list of trails. That lambda is called from
inside onBindViewHolder, attached to each row's click listener
(holder.binding.root.setOnClickListener { onTrailClick(trail) }). The Adapter itself
doesn't know or care what happens on a click — it just reports which Trail was tapped,
and the Fragment that created the Adapter decides what to do about it, in this case navigating to the
trail's detail screen through the Navigation Component.
This separation keeps the Adapter reusable: it never needs to change just because the destination of
a tap changes.