RecyclerView vs LazyColumn: Legacy vs Modern UI Tradeoffs
Understand the internals of both, know when each is the right tool, and migrate confidently
Open interactive version (quiz + challenge)Real-world analogy
What is it?
RecyclerView is Android's View-system component for displaying large, scrollable lists by recycling off-screen item views. LazyColumn is Jetpack Compose's equivalent, using a lazy composable DSL. Both render only visible items but differ fundamentally in the programming model — RecyclerView requires explicit Adapter/ViewHolder/DiffUtil boilerplate; LazyColumn uses declarative Kotlin lambdas and Compose's recomposition system.
Real-world relevance
WhatsApp's chat screen is built on RecyclerView with a highly optimized Adapter handling text messages, images, videos, voice notes, and documents as distinct view types — all recycled from a shared pool. Google's Contacts app uses a mix: the main contacts list is RecyclerView (legacy XML codebase), while the new contact detail screen is built in Compose with LazyColumn. Twitter (X) migrated their timeline from RecyclerView to LazyColumn as part of their Compose adoption, reporting reduced boilerplate and easier A/B testing of item types.
Key points
- RecyclerView — the View system workhorse — RecyclerView (introduced in API 21) replaced ListView by separating concerns: LayoutManager (how items are arranged), Adapter (how items are bound), ViewHolder (recycled view container), and ItemDecoration (dividers, spacing). It handles off-screen item recycling automatically.
- ViewHolder pattern — why it exists — Before RecyclerView, ListView's getView() called findViewById() on every bind — expensive. ViewHolder caches the view references in the tag, so each item's views are found once (at inflation) and reused. RecyclerView enforces this pattern — you must implement a ViewHolder. ListAdapter builds on this with DiffUtil.
- DiffUtil — efficient list updates — DiffUtil computes the minimum number of insertions, deletions, and moves between two lists using Eugene Myers' diff algorithm. ListAdapter wraps DiffUtil and calls submitList() — no more notifyDataSetChanged() which redraws everything. DiffUtil runs on a background thread via AsyncListDiffer.
- ItemDecoration — custom dividers and spacing — Override RecyclerView.ItemDecoration.getItemOffsets() to add spacing around items, and onDraw() to draw dividers or backgrounds. DividerItemDecoration is the built-in convenience. In practice, item_view.xml margins often replace ItemDecoration for spacing.
- ItemAnimator — add/remove/change animations — DefaultItemAnimator plays fade+translate animations for item add/remove/change. Replace it with a custom RecyclerView.ItemAnimator for branded motion. Disable animations with recyclerView.itemAnimator = null when fast updates cause flicker in chat or trading apps.
- LazyColumn — Compose's RecyclerView equivalent — LazyColumn renders only visible items (lazy composables), just like RecyclerView recycles off-screen views. Internally it uses a LazyListState which tracks scroll position and visible items. items{}, itemsIndexed{}, and item{} are the DSL builders. No Adapter, ViewHolder, or LayoutManager needed.
- LazyColumn performance — key and contentType — Provide a stable key parameter to items{}: key = { product -> product.id }. This lets Compose identify items for recomposition and animation. Without keys, adding or removing items recomposes everything. Use contentType to help Compose reuse composable instances across different item types — equivalent to RecyclerView's getItemViewType().
- When RecyclerView is still the right choice — Complex custom scroll physics, interaction with SurfaceView/TextureView (camera preview, video), apps with existing XML-based codebases where a full Compose migration is not yet feasible, or performance-critical lists on low-end devices where fine-grained View recycling control is needed. Google's Messages app uses RecyclerView for its chat list — millions of messages, complex media types.
- When LazyColumn is the right choice — New screens built in Compose, lists with heterogeneous item types that are complex to express with getItemViewType(), lists where item content drives animations (AnimatedVisibility, animateItemPlacement), or lists that need tight integration with Compose state (ViewModel, collectAsStateWithLifecycle).
- Migration patterns — AndroidView and ComposeView — In a RecyclerView item, use ComposeView as the item's root view to render Compose content per item — useful for migrating one item type at a time. In a Compose screen, use AndroidView { RecyclerView(it) } to host a RecyclerView inside a Compose layout — useful when a mature RecyclerView adapter is not yet rewritten.
- Performance comparison — measured realities — Benchmark: on a Pixel 7, LazyColumn with 1000 simple text items scrolls at a comparable frame rate to RecyclerView with DiffUtil on the same device. For very large lists (10,000+ items) with complex item types, RecyclerView with a well-tuned Adapter and setRecycledViewPool() may outperform a naive LazyColumn. Profile with Android Studio's Frame Timing before assuming.
- LazyColumn extras — LazyRow, LazyVerticalGrid, LazyVerticalStaggeredGrid — LazyRow is the horizontal equivalent. LazyVerticalGrid supports fixed column counts or adaptive column widths (GridCells.Adaptive(minSize)). LazyVerticalStaggeredGrid (added in Compose 1.3) supports Pinterest-style variable-height grids. All share the lazy rendering principle — no RecyclerView.LayoutManager subclassing needed.
Code example
// ── RecyclerView with ListAdapter and DiffUtil ───────────────
class ProductAdapter : ListAdapter<Product, ProductAdapter.ViewHolder>(DiffCallback) {
inner class ViewHolder(private val binding: ItemProductBinding)
: RecyclerView.ViewHolder(binding.root) {
fun bind(product: Product) {
binding.productName.text = product.name
binding.productPrice.text = NumberFormat
.getCurrencyInstance()
.format(product.price)
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val binding = ItemProductBinding
.inflate(LayoutInflater.from(parent.context), parent, false)
return ViewHolder(binding)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
holder.bind(getItem(position))
}
companion object DiffCallback : DiffUtil.ItemCallback<Product>() {
override fun areItemsTheSame(old: Product, new: Product) = old.id == new.id
override fun areContentsTheSame(old: Product, new: Product) = old == new
}
}
// Usage in Fragment:
// adapter.submitList(products)
// ── LazyColumn equivalent ─────────────────────────────────────
@Composable
fun ProductList(
products: List<Product>,
modifier: Modifier = Modifier
) {
LazyColumn(
modifier = modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(
items = products,
key = { product -> product.id },
contentType = { "product_card" }
) { product ->
ProductCard(
product = product,
modifier = Modifier.animateItem()
)
}
}
}Line-by-line walkthrough
- 1. ListAdapter(DiffCallback) wires DiffUtil into the adapter — submitList() triggers an async diff and posts only the necessary notifyItemInserted/Removed/Changed calls
- 2. DiffCallback is a companion object implementing DiffUtil.ItemCallback — areItemsTheSame() checks ID equality (same entity), areContentsTheSame() checks deep equality (same data)
- 3. ItemProductBinding is generated by View Binding — eliminates all findViewById() calls; the ViewHolder holds the binding, not individual view references
- 4. onCreateViewHolder inflates the layout once per new ViewHolder; onBindViewHolder binds data to an already-inflated, recycled ViewHolder
- 5. LazyColumn with contentPadding = PaddingValues(16.dp) adds padding inside the scroll area — items scroll under the padding rather than being clipped by it
- 6. verticalArrangement = Arrangement.spacedBy(8.dp) adds 8dp between every item — replaces ItemDecoration for simple spacing
- 7. key = { product -> product.id } gives Compose a stable identity for each item — enables correct animations when items are added/removed/reordered
- 8. contentType = { 'product_card' } tells the Compose runtime that all items share the same composable structure — it can reuse composable instances across positions, like RecyclerView's view type pool
- 9. Modifier.animateItem() (formerly animateItemPlacement) adds Material-spec animations when the item changes position in the list — free with LazyColumn, requires custom ItemAnimator in RecyclerView
Spot the bug
class MessageAdapter(
private var messages: List<Message>
) : RecyclerView.Adapter<MessageAdapter.ViewHolder>() {
fun updateMessages(newMessages: List<Message>) {
messages = newMessages
notifyDataSetChanged()
}
inner class ViewHolder(view: View) : RecyclerView.ViewHolder(view) {
val text: TextView = view.findViewById(R.id.message_text)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
ViewHolder(LayoutInflater.from(parent.context)
.inflate(R.layout.item_message, parent, false))
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
holder.text.text = messages[position].content
}
override fun getItemCount() = messages.size
}Need a hint?
Show answer
Explain like I'm 5
Fun fact
Hands-on challenge
More resources
- RecyclerView — Android Guide (Android Developers)
- LazyColumn — Compose Guide (Android Developers)
- DiffUtil — Android Reference (Android Developers)
- Migrating to Compose — Interoperability (Android Developers)
- LazyVerticalStaggeredGrid (Android Developers)