RecyclerView and Lists

This content was generated with the assistance of AI and should be verified against the official documentation of Android before being relied on in production.

See the section bibliography for the reference material consulted while preparing these pages.

RecyclerView, from androidx.recyclerview:recyclerview (1.4.0 in this section’s baseline), is the standard way to display a scrolling collection of views in the classic View-based toolkit, and it is still the Compose LazyColumn/LazyRow equivalent’s closest relative — both recycle off-screen item views instead of keeping one view per data row alive forever. This page covers RecyclerView's architecture, builds TrailMate’s trail list on top of ListAdapter and DiffUtil, and covers grid/staggered layouts, ConcatAdapter, ItemTouchHelper swipe/drag gestures, and the performance practices that keep a long list scrolling smoothly. It builds on Fragments and the Fragment Lifecycle and Views, XML Layouts, View Binding and Material Components, and TrailMate’s legacy TrailListFragment (introduced there) is where the TrailAdapter built here is actually used. Compose’s own list APIs are covered separately on Compose Layouts, Modifiers and Lazy Lists; this page is about the View-based toolkit only.

ListView: a Brief History

Before RecyclerView shipped in 2014, the View toolkit’s only scrolling-list widget was ListView (and its grid cousin GridView), backed by an Adapter (BaseAdapter, ArrayAdapter, CursorAdapter) whose getView(position, convertView, parent) method was responsible for both creating a row view and, when the framework passed back a non-null convertView, manually reusing it. ListView had no layout abstraction — it only ever laid items out vertically — no built-in way to animate item insertion/removal, and the view-recycling contract lived entirely in each app’s own getView() implementation, which is exactly the pattern the ViewHolder pattern retrofitted on top of it to avoid a findViewById() call on every scroll frame. RecyclerView generalizes all of this into first-class, separately swappable pieces — a layout strategy, a recycling pool, decorations, animations — documented below, and is the only one of the two still recommended for new code. ListView and GridView remain in the framework for legacy code but should not be used for anything new; TrailMate’s own TrailListFragment uses RecyclerView from the start. See Android Developers — Create Dynamic Lists with RecyclerView.

RecyclerView Architecture

RecyclerView itself is a ViewGroup that delegates almost everything to four collaborating, independently replaceable objects:

  • RecyclerView.Adapter supplies item view types, creates a ViewHolder for each one, and binds a data item into an existing ViewHolder when asked. It never touches layout or scrolling.

  • RecyclerView.ViewHolder wraps one item’s root view and caches the child-view references findViewById() (or view binding) would otherwise have to look up again on every bind.

  • RecyclerView.LayoutManager decides where each visible item view is positioned and measured, and which off-screen views to recycle as the user scrolls — LinearLayoutManager, GridLayoutManager and StaggeredGridLayoutManager are the three the framework ships, covered below.

  • RecyclerView.ItemDecoration draws extra content around or between items — dividers, section headers, item spacing — without either the adapter or the layout manager knowing about it.

  • RecyclerView.ItemAnimator animates the transition (an item appearing, disappearing, moving, or having its content changed) whenever the adapter reports that its data set changed; DefaultItemAnimator runs unless it is replaced.

Diagram of RecyclerView’s collaborating components. A central RecyclerView box has four arrows out to LayoutManager (positions and recycles item views), Adapter (creates ViewHolders and binds data into them), ItemDecoration (draws dividers and spacing around items), and ItemAnimator (animates item add, remove, move and change). A fifth box, ViewHolder, sits between Adapter and the recycled view pool, labeled: created once per Adapter.onCreateViewHolder, reused across many Adapter.onBindViewHolder calls as the user scrolls.

None of these four collaborators knows about the other three directly — RecyclerView itself is the only object that talks to all of them, which is what makes it possible to swap a LinearLayoutManager for a GridLayoutManager, or add a divider ItemDecoration, without touching the adapter at all. See Android Developers — RecyclerView, and the four component references: RecyclerView.Adapter, RecyclerView.ViewHolder, RecyclerView.LayoutManager, RecyclerView.ItemDecoration and RecyclerView.ItemAnimator.

Wiring a Basic RecyclerView

TrailListFragment's layout declares the widget itself, then the fragment attaches a layout manager and an adapter to it:

<!-- feature/trails/src/main/res/layout/fragment_trail_list.xml -->
<androidx.recyclerview.widget.RecyclerView xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/trailRecyclerView"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:clipToPadding="false"
    android:paddingBottom="16dp" />
// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/TrailListFragment.kt
binding.trailRecyclerView.apply {
    layoutManager = LinearLayoutManager(requireContext())
    adapter = trailAdapter
    setHasFixedSize(true)
}

setHasFixedSize(true) tells RecyclerView that changes to the adapter’s content never change the RecyclerView widget’s own size, letting it skip a layout pass it would otherwise have to run defensively — correct here because fragment_trail_list.xml sizes the RecyclerView to match_parent, not to its content.

TrailAdapter: a ListAdapter with DiffUtil

Writing notifyDataSetChanged() (which discards every item’s animation and re-binds everything visible) or a hand-rolled sequence of notifyItemInserted()/notifyItemRemoved()/notifyItemMoved() calls (which is correct only if it exactly matches what actually changed) both fall on the adapter author to get right. ListAdapter<T, VH> removes that burden: it holds the current list internally, and submitList(newList) computes the difference between the old and new list on a background thread using DiffUtil, then dispatches the minimal set of notifyItem*() calls — and their matching animations — automatically. TrailMate’s TrailAdapter is written this way:

// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/TrailAdapter.kt
class TrailAdapter(
    private val onTrailClick: (Trail) -> Unit,
    private val onFavoriteClick: (Trail) -> Unit,
) : ListAdapter<Trail, TrailAdapter.TrailViewHolder>(TrailDiffCallback) {

    class TrailViewHolder(private val binding: ItemTrailBinding) : RecyclerView.ViewHolder(binding.root) {
        fun bind(trail: Trail, onTrailClick: (Trail) -> Unit, onFavoriteClick: (Trail) -> Unit) {
            binding.trailName.text = trail.name
            binding.trailRegion.text = trail.region
            binding.trailLength.text = "%.1f km".format(trail.lengthKm)
            binding.favoriteButton.isSelected = trail.isFavorite
            binding.root.setOnClickListener { onTrailClick(trail) }
            binding.favoriteButton.setOnClickListener { onFavoriteClick(trail) }
        }
    }

    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) {
        holder.bind(getItem(position), onTrailClick, onFavoriteClick)
    }
}

private object TrailDiffCallback : DiffUtil.ItemCallback<Trail>() {
    override fun areItemsTheSame(oldItem: Trail, newItem: Trail): Boolean = oldItem.id == newItem.id
    override fun areContentsTheSame(oldItem: Trail, newItem: Trail): Boolean = oldItem == newItem
}

DiffUtil.ItemCallback<Trail> splits the comparison into two questions, deliberately kept separate:

  • areItemsTheSame() — do these two objects represent the same logical trail, typically compared by a stable identity such as id? This decides whether DiffUtil treats a pair as a move/change or as a genuine insertion plus removal.

  • areContentsTheSame() — for two items already known to be the same trail, has any displayed field changed? Trail is a data class, so structural equality (==) is enough here as long as every field it compares is also a field the row actually renders.

Getting areItemsTheSame() wrong (comparing whole objects instead of a stable id) makes every update look like a full replace, losing item-move animations and forcing an unnecessary re-bind of every row; getting areContentsTheSame() wrong (returning true for objects that actually differ) leaves stale text on screen after submitList(). ListAdapter calls getItem(position) internally to hand onBindViewHolder() the current, not stale, item — never index into a separately held list from inside the adapter. See Android Developers — ListAdapter and Android Developers — DiffUtil.

Click Handling with Lambdas

TrailAdapter above takes its click callbacks as constructor lambdas (onTrailClick, onFavoriteClick) rather than requiring the fragment to implement a listener interface, and attaches them once per bind() call inside the ViewHolder — never inside onCreateViewHolder(), where the trail the click applies to is not yet known. This keeps TrailAdapter fragment-agnostic (it can be constructed and tested with a fake lambda) and keeps the click target current across rebinds without any extra bookkeeping, since each bind() call closes over that call’s own trail.

Multiple View Types

A single RecyclerView list can mix different row layouts — TrailMate’s trail list shows a promoted "Featured trail" card above the regular rows — by overriding getItemViewType() and branching in both onCreateViewHolder() and onBindViewHolder():

// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/TrailListItem.kt
sealed interface TrailListItem {
    data class Featured(val trail: Trail) : TrailListItem
    data class Regular(val trail: Trail) : TrailListItem

    companion object {
        const val VIEW_TYPE_FEATURED = 0
        const val VIEW_TYPE_REGULAR = 1
    }
}

override fun getItemViewType(position: Int): Int = when (getItem(position)) {
    is TrailListItem.Featured -> TrailListItem.VIEW_TYPE_FEATURED
    is TrailListItem.Regular -> TrailListItem.VIEW_TYPE_REGULAR
}

override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder = when (viewType) {
    TrailListItem.VIEW_TYPE_FEATURED -> FeaturedTrailViewHolder.create(parent)
    else -> TrailViewHolder.create(parent)
}

onBindViewHolder() then casts to the concrete ViewHolder subtype matching that position’s view type. A sealed interface for the list-item model (as opposed to branching on Trail fields directly) keeps the "what kind of row is this" decision in one place and gives the when above exhaustiveness checking at compile time — see Sealed Classes and Enums for the language feature itself. See Android Developers — Create Dynamic Lists with RecyclerView (Multiple view types section).

Grid and Staggered Layout Managers

LinearLayoutManager lays items out in a single scrolling row or column (RecyclerView.VERTICAL by default, or RecyclerView.HORIZONTAL). Two more ship in the same artifact for grid-shaped content:

Layout manager Behavior

GridLayoutManager(context, spanCount)

A fixed number of equal-size columns (or rows, if horizontal); TrailMate’s SettingsScreen-adjacent trail gallery uses a two-column grid on phones. setSpanSizeLookup() lets one item span more than one column, useful for the same "Featured" row shown full-width above a grid of regular items.

StaggeredGridLayoutManager(spanCount, orientation)

A fixed number of columns, but each item keeps its own natural height and columns advance independently —  the classic photo-wall layout. TrailMate’s PhotoCaptureScreen history view uses this for hike photos of varying aspect ratios.

binding.trailRecyclerView.layoutManager = GridLayoutManager(requireContext(), 2).apply {
    spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() {
        override fun getSpanSize(position: Int): Int =
            if (trailAdapter.getItemViewType(position) == TrailListItem.VIEW_TYPE_FEATURED) 2 else 1
    }
}

A staggered grid can shift an item slightly out of visual reading order as later columns settle at different lengths; StaggeredGridLayoutManager.setGapStrategy() controls whether a small gap left by a removed item is closed immediately or only on the next full re-layout. See Android Developers — GridLayoutManager and Android Developers — StaggeredGridLayoutManager.

ConcatAdapter

ConcatAdapter presents several independent adapters to one RecyclerView as if they were a single, concatenated list, without either adapter knowing about the other. TrailMate’s trail list shows a header adapter (an "onboarding tip" card, dismissible independently of the trail data) above TrailAdapter's items:

// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/TrailListFragment.kt
val headerAdapter = TrailListHeaderAdapter(onDismiss = { viewModel.dismissOnboardingTip() })
binding.trailRecyclerView.adapter = ConcatAdapter(headerAdapter, trailAdapter)

ConcatAdapter.Config.Builder().setIsolateViewTypes(true) (the default) keeps each child adapter’s view-type numbering isolated from the others, so TrailListHeaderAdapter and TrailAdapter can both freely use view type 0 without colliding. ConcatAdapter is the standard way to build "sectioned" lists — a header, a footer, a loading-state adapter appended by PagingDataAdapter (see below) — without merging unrelated concerns into one adapter class. See Android Developers — ConcatAdapter.

ItemTouchHelper: Swipe and Drag

ItemTouchHelper attaches swipe-to-dismiss and drag-to-reorder gestures to a RecyclerView without either gesture’s touch handling living in the adapter. TrailMate lets a user swipe a trail row to remove it from a custom "My Trails" list:

// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/TrailSwipeCallback.kt
class TrailSwipeCallback(
    private val onSwiped: (position: Int) -> Unit,
) : ItemTouchHelper.SimpleCallback(0, ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT) {

    override fun onMove(
        recyclerView: RecyclerView,
        viewHolder: RecyclerView.ViewHolder,
        target: RecyclerView.ViewHolder,
    ): Boolean = false // dragging is not enabled for this list

    override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
        onSwiped(viewHolder.bindingAdapterPosition)
    }
}

ItemTouchHelper(TrailSwipeCallback { position ->
    val trail = trailAdapter.currentList[position]
    viewModel.removeFromMyTrails(trail.id)
}).attachToRecyclerView(binding.trailRecyclerView)

The first SimpleCallback constructor argument is the drag-direction mask (0 disables dragging entirely; ItemTouchHelper.UP or ItemTouchHelper.DOWN would enable vertical drag-to-reorder instead, implemented by returning true from onMove() and calling notifyItemMoved()), and the second is the swipe-direction mask. viewHolder.bindingAdapterPosition (not the deprecated adapterPosition) is the position to act on, because it stays correct even when the ViewHolder belongs to one child adapter of a ConcatAdapter. onChildDraw() can be overridden to draw a background (a delete icon, a colored strip) behind the row as it is dragged, which TrailMate uses to show a red "Remove" background during the swipe. See Android Developers — ItemTouchHelper.

Stable IDs and Payloads

Two further, easy-to-miss knobs make RecyclerView updates cheaper and its default animations more correct:

  • setHasStableIds(true) plus overriding getItemId(position) to return a value derived from the item’s own identity (trail.id.hashCode().toLong(), not the position) tells RecyclerView it can tell two ViewHolders apart by identity rather than only by position, which improves the accuracy of item-move animations and lets RecyclerView.Adapter (a plain one, not necessarily ListAdapter) reuse a ViewHolder across a position change instead of rebinding it. It has no effect unless the adapter also overrides getItemId() meaningfully.

  • Payloads. notifyItemChanged(position, payload) (or, for ListAdapter, DiffUtil.ItemCallback.getChangePayload(oldItem, newItem)) lets a change carry a description of what changed instead of forcing a full onBindViewHolder() rebind. TrailDiffCallback can report that only isFavorite toggled:

    private const val FAVORITE_CHANGED = "favorite_changed"
    
    private object TrailDiffCallback : DiffUtil.ItemCallback<Trail>() {
        override fun areItemsTheSame(oldItem: Trail, newItem: Trail) = oldItem.id == newItem.id
        override fun areContentsTheSame(oldItem: Trail, newItem: Trail) = oldItem == newItem
    
        override fun getChangePayload(oldItem: Trail, newItem: Trail): Any? =
            if (oldItem.isFavorite != newItem.isFavorite) FAVORITE_CHANGED else null
    }
    
    // in TrailAdapter (an Adapter override, alongside the two-argument onBindViewHolder);
    // TrailViewHolder.bindFavoriteOnly() just sets binding.favoriteButton.isSelected
    override fun onBindViewHolder(holder: TrailViewHolder, position: Int, payloads: MutableList<Any>) {
        if (payloads.contains(FAVORITE_CHANGED)) {
            holder.bindFavoriteOnly(getItem(position).isFavorite)
        } else {
            super.onBindViewHolder(holder, position, payloads)
        }
    }

    This skips re-binding the trail name, region and length text views — and re-loading the trail photo, see below — for a change that only affects one small view, avoiding a visible flicker on the parts of the row that did not change. See Android Developers — RecyclerView.Adapter.setHasStableIds().

Submitting a List from a Flow

TrailMate’s TrailListViewModel exposes the trail list as a StateFlow; TrailListFragment collects it and feeds each emission straight into submitList(), scoped to repeatOnLifecycle so collection stops while the fragment’s view is not started:

// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/TrailListFragment.kt
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
    super.onViewCreated(view, savedInstanceState)
    viewLifecycleOwner.lifecycleScope.launch {
        viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
            viewModel.uiState.collect { uiState ->
                trailAdapter.submitList(uiState.trails)
                binding.emptyView.isVisible = uiState.trails.isEmpty() && !uiState.isLoading
            }
        }
    }
}

repeatOnLifecycle(Lifecycle.State.STARTED) — covered in full on ViewModel, Lifecycle-Aware Components, Coroutines and Flow — cancels and restarts the collector as the fragment’s view moves in and out of the started state, which matters here specifically because it stops submitList()'s background DiffUtil calculation from running (and its result from being applied to a detached RecyclerView) while the fragment is stopped. Passing a plain List<Trail> to submitList() each time is safe because Trail is an immutable data class and TrailListUiState always builds a new list on each state update rather than mutating one in place — ListAdapter compares the previous list it was given against the new one by reference first, so handing it the same mutated list twice would make it think nothing changed. See Flows for StateFlow/collect itself.

Looking Ahead: PagingDataAdapter and Image Loading

Two concerns deliberately stop at a pointer here rather than being covered in full:

  • Paged lists. submitList() above assumes the whole trail list fits comfortably in memory. Once TrailMate’s trail catalog is paged from the network and Room together, TrailAdapter is replaced by a PagingDataAdapter<Trail, TrailViewHolder> from the Paging 3 library, which submits a PagingData<Trail> instead of a plain list and loads further pages automatically as the user scrolls near the end of what is already loaded. See Room Database and Paging for the full Paging 3 setup.

  • Row images. TrailViewHolder.bind() above only binds text; TrailMate’s actual row also loads trail.photoUrl into an ImageView with Glide inside onBindViewHolder(), including how Glide’s own view-recycling-aware request cancellation interacts with RecyclerView's recycling. See Image Loading, Caching and Transformations with Coil and Glide.

Performance Tips

  • Keep onBindViewHolder() cheap. It runs on the main thread on every scroll frame a new row becomes visible; do no network or disk I/O there, and avoid allocations that are easy to hoist into onCreateViewHolder() or a companion object instead (formatters, comparators, drawables shared across rows).

  • Prefer DiffUtil/ListAdapter over notifyDataSetChanged(). A full-dataset notify discards every visible item’s ongoing animation and forces a rebind of everything on screen, even when only one row actually changed.

  • Use setHasFixedSize(true) whenever the RecyclerView's own size does not depend on its adapter’s content (the common case for a RecyclerView filling its parent), to skip an otherwise-defensive extra layout pass.

  • Avoid nested RecyclerViews inside a scrolling parent without setRecycledViewPool() sharing a pool across the inner lists — otherwise each inner RecyclerView builds and discards its own view pool independently as the outer list scrolls it in and out of view.

  • Prefer flat, shallow item layouts. Deep view hierarchies inside item_trail.xml slow down both inflation (onCreateViewHolder()) and measurement on every layout pass; ConstraintLayout (covered on Views, XML Layouts, View Binding and Material Components) keeps a row flat even when its visual structure looks nested.

  • Profile with the Layout Inspector and the CPU/Frame Rendering profilers in Android Studio before optimizing by guesswork — covered on Debugging, Code Quality and Performance.

ViewPager2

ViewPager2, from androidx.viewpager2:viewpager2, is a horizontally (or vertically) paging widget — one full page visible at a time, swiped between — built directly on top of RecyclerView internally, which is why its own adapter base class is RecyclerView.Adapter (commonly FragmentStateAdapter when each page is a whole fragment, as TrailMate’s onboarding walkthrough uses it, or a plain RecyclerView.Adapter when each page is a single view). The two solve different problems even though one is built on the other: RecyclerView scrolls a list where any number of items can be partly visible at once, while ViewPager2 snaps to exactly one page at a time and is the modern replacement for the older, non-RecyclerView-based ViewPager. TrailMate uses ViewPager2 once, for a three-screen onboarding walkthrough shown on first launch, together with TabLayoutMediator to drive a matching dot indicator — everything else in this section’s TrailMate example (the trail list, the photo grid) is a genuine scrolling collection and stays on plain RecyclerView. See Android Developers — Slide Between Fragments Using ViewPager2 and Android Developers — ViewPager2.