Porting a RecyclerView deletes four classes and keeps one idea

A RecyclerView migration removes most of its own machinery, because Compose's composition already does recycling and diffing. What survives is stable identity — getItemId becomes key — and knowing that makes the port mechanical.

6 min read
androidcomposekotlininterop

Day 90 — Porting a RecyclerView deletes four classes

Day 90 of 100. The most common single migration, and the one where the mapping is least one-to-one.

The symptom

A port that recreates the old structure in the new framework:

class OrderAdapter : ComposeAdapter<Order>() { … }        // no

Or, more subtly, a LazyColumn that keeps the old mental model:

LazyColumn {
    items(orders.size) { index ->
        OrderRow(orders[index])
    }
}

Index-based items, no key, and the row reading from an outer list. It renders. Then Day 23's identity problems arrive — expansion state on the wrong row, scroll jumps on insert, animations attaching to the wrong item.

Why the obvious approach fails

The obvious approach is to translate each class:

RecyclerView "equivalent"
Adapter … a wrapper class?
ViewHolder … a composable?
DiffUtil.ItemCallback … a comparator?
LayoutManager … a parameter?

Three of those four have no equivalent because the problem they solved no longer exists. Looking for a mapping produces a port that carries machinery for a problem Compose already handles.

Four RecyclerView classes disappear; stable identity survives as key

The actual mechanism

What each piece was for, and what replaced it:

Adapter existed to bind data to recycled views on demand. LazyColumn's DSL is that binding — items(orders) { order -> … } is the adapter, expressed as a lambda.

ViewHolder existed to cache findViewById lookups across recycling. Compose has no view lookups, so there's nothing to cache. The composable is the row.

DiffUtil existed to compute a minimal set of change notifications. Compose's composition already diffs — that's what recomposition is — so the whole areItemsTheSame/areContentsTheSame protocol collapses into the key and the normal equality Day 79 described.

LayoutManager existed to abstract the arrangement. That's now the choice of composable: LazyColumn, LazyRow, LazyVerticalGrid, LazyVerticalStaggeredGrid.

What survives is stable identity. getItemId() and areItemsTheSame were both answering "is this the same item", and so is key:

LazyColumn {
    items(orders, key = { it.id }) { order ->
        OrderRow(order, onSelect = { onSelect(order.id) })
    }
}

That's the whole port for a simple list. Four classes to one lambda, with the one idea worth keeping made into a parameter.

The mapping table

For a mechanical port:

RecyclerView Compose
LinearLayoutManager LazyColumn / LazyRow
GridLayoutManager LazyVerticalGrid(GridCells.Fixed(n))
StaggeredGridLayoutManager LazyVerticalStaggeredGrid
getItemId() key = { it.id }
getItemViewType() contentType = { it::class }
Multiple view types when inside the item lambda
Header / footer item { } before / after items()
ItemDecoration (dividers) HorizontalDivider() in the item, or Arrangement.spacedBy
ItemAnimator Modifier.animateItem()
ItemTouchHelper swipe SwipeToDismissBox (Day 73)
addOnScrollListener snapshotFlow { listState.firstVisibleItemIndex }
scrollToPosition listState.scrollToItem(index)
smoothScrollToPosition listState.animateScrollToItem(index)

contentType is the one most ports miss — Day 23's parameter, and the direct heir of getItemViewType. Without it a mixed-type feed loses composition reuse.

Paging

Paging 3 has a Compose artifact, so a paged list doesn't need rearchitecting:

val orders = viewModel.pagedOrders.collectAsLazyPagingItems()

LazyColumn {
    items(orders.itemCount, key = orders.itemKey { it.id }) { index ->
        orders[index]?.let { OrderRow(it) }
    }

    when (orders.loadState.append) {
        is LoadState.Loading -> item { LoadingRow() }
        is LoadState.Error -> item { RetryRow(onRetry = orders::retry) }
        else -> Unit
    }
}

itemKey and itemContentType are the helpers that supply Day 23's parameters from the paging source. The nullable orders[index] is the placeholder case — a page not yet loaded — and handling it is what gives you the loading row for free.

The pattern that improves in the port

Worth noticing, because it's an argument for porting rather than wrapping. Several things that were awkward in RecyclerView become trivial:

Multiple view types — a when in the item lambda, rather than an integer type, a factory switch and parallel view holders.

Nested lists — a LazyRow inside a LazyColumn item, rather than a nested RecyclerView with shared pools and scroll-position bookkeeping.

Sticky headersstickyHeader { } in the DSL, rather than an ItemDecoration that draws over the list. Sections too: items() called repeatedly with a stickyHeader between them is a grouped list, where the adapter version needed a flattened list plus a position-to-section lookup.

Empty and error states — an if around the LazyColumn, rather than a view that toggles visibility outside the adapter.

That last one is the quiet win. Adapter-based lists tend to push empty states into the containing fragment, which is why they're the state most often forgotten (Day 85).

When to keep the RecyclerView

Being honest about the exception. A RecyclerView inside a ComposeView is fine and sometimes right:

  • The adapter is large, well-tested, and the screen isn't otherwise changing.
  • It uses a third-party library with no Compose equivalent.
  • The list has genuinely exotic behaviour — a custom LayoutManager doing something no lazy layout models.

What isn't a good reason is "it works" alone. A RecyclerView embedded in Compose carries Day 89's boundary cost, and it keeps four classes alive that the port would delete.

How to prove it

The identity check is the one that matters, and it's the same as Day 23's:

@Test fun stateFollowsTheItem() = runComposeUiTest {
    var orders by mutableStateOf(listOf(a, b, c))
    setContent { OrderList(orders) }

    onNodeWithText("Order B").performClick()          // expand B
    orders = listOf(b, c)                             // remove A

    onNodeWithText("Order B").assertIsDisplayed()
    onNodeWithTag("expanded-B").assertExists()        // still B that's expanded
}

Against an index-keyed port, that fails — which is precisely the bug getItemId was preventing in the old code, so the test is checking that the port kept the property that mattered.

The performance comparison is worth running once too, because expectations are often wrong in both directions. A ported list is usually comparable rather than dramatically faster — RecyclerView was well optimised — and where Compose wins is the code you no longer maintain. Day 78's Macrobenchmark on the before and after settles it in a way that opinion doesn't.

What this generalizes to

The lesson for any framework migration: port the requirements, not the classes. Four of the five pieces of a RecyclerView existed to solve problems — view lookup cost, manual diffing, recycling bookkeeping — that the new framework solves structurally. Translating them produces a faithful reproduction of accidental complexity.

The way to find the one piece that survives is to ask what each class was for rather than what it did. getItemId was for identity, and identity is still needed. ViewHolder was for findViewById, and there is no findViewById.

The same question is worth asking about your own abstractions during a port. A BaseListFragment or a GenericAdapter<T> usually exists to remove boilerplate that the new framework doesn't generate — so it has nothing left to remove, and porting it forward carries a solution to a problem that no longer exists.

Tomorrow, Day 91 closes the interop pillar with navigation — the migration where the two systems have to coexist longest.


Day 90 of a 100-day series on Jetpack Compose, working through the official documentation in order. Source: RecyclerView to LazyColumn.