Lesson 54 of 83 intermediate

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

RecyclerView is like a professional filing system with labeled folders, a strict filing clerk (ViewHolder), and elaborate color-coded dividers. LazyColumn is like a digital filing app — you just describe what each file looks like, and the app handles all the filing, refiling, and retrieval automatically. Both store the same documents; the difference is who does the administrative work.

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

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. 1. ListAdapter(DiffCallback) wires DiffUtil into the adapter — submitList() triggers an async diff and posts only the necessary notifyItemInserted/Removed/Changed calls
  2. 2. DiffCallback is a companion object implementing DiffUtil.ItemCallback — areItemsTheSame() checks ID equality (same entity), areContentsTheSame() checks deep equality (same data)
  3. 3. ItemProductBinding is generated by View Binding — eliminates all findViewById() calls; the ViewHolder holds the binding, not individual view references
  4. 4. onCreateViewHolder inflates the layout once per new ViewHolder; onBindViewHolder binds data to an already-inflated, recycled ViewHolder
  5. 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. 6. verticalArrangement = Arrangement.spacedBy(8.dp) adds 8dp between every item — replaces ItemDecoration for simple spacing
  7. 7. key = { product -> product.id } gives Compose a stable identity for each item — enables correct animations when items are added/removed/reordered
  8. 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. 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?
There are two significant performance and correctness issues. One causes visible UI flicker. The other breaks RecyclerView animations and may cause incorrect item display during fast updates.
Show answer
Bug 1: notifyDataSetChanged() in updateMessages() — this forces RecyclerView to rebind and redraw every visible item, causing a visible flicker (all items flash white/blank then reappear). It also skips all item animations (no fade-in for new messages, no fade-out for deleted ones). Fix: migrate to ListAdapter<Message, ViewHolder> with a DiffUtil.ItemCallback. Call submitList(newMessages). DiffUtil computes the diff on a background thread and applies the minimum number of notifyItem* calls with animations. Bug 2: The adapter holds a mutable var messages reference. If updateMessages() is called from a background thread while RecyclerView is in the middle of a layout pass (reading messages.size or messages[position]), this can cause an IndexOutOfBoundsException or show stale item content. ListAdapter's submitList() is thread-safe and handles this correctly through AsyncListDiffer.

Explain like I'm 5

RecyclerView is like a restaurant with waiters who memorize each table's order (ViewHolder) and have a system for clearing and resetting tables (recycling). LazyColumn is like a magic restaurant where tables appear and disappear automatically as diners arrive — you just write the menu, and the restaurant handles everything else.

Fun fact

DiffUtil's Eugene Myers' diff algorithm was originally designed for comparing text files (it is the algorithm behind Unix 'diff' and Git's line diff). Google adapted it for list diffing in 2016 when RecyclerView's notifyDataSetChanged() was causing visible screen flicker in the Google Photos app. LazyColumn's equivalent is Compose's slot reuse system, which tracks items by key identity rather than position.

Hands-on challenge

Implement a product list that works in both RecyclerView (for a legacy Fragment screen) and LazyColumn (for a new Compose screen). Ensure the RecyclerView version uses ListAdapter with DiffUtil. Ensure the LazyColumn version uses stable keys and animateItem(). Then benchmark both implementations using Android Studio's Frame Timing tool on a list of 500 items with a simulated 60fps scroll.

More resources

Open interactive version (quiz + challenge) ← Back to course: Android Interview Mastery