RecyclerView is the most important UI component in Android development. Almost every app has a scrollable list — feeds, messages, search results, settings, product catalogs. RecyclerView handles all of these efficiently by recycling Views as they scroll off screen instead of creating new ones. But setting it up correctly — with DiffUtil, ViewBinding, click handling, multiple view types, and proper state management — requires understanding how all the pieces fit together. This guide covers everything from basic setup to production-ready patterns.


Why RecyclerView?

// The problem: you have 10,000 items to display in a scrollable list
// If you create 10,000 Views = massive memory usage, slow rendering

// RecyclerView's solution: only create enough Views to fill the screen (~10-15)
// As items scroll off screen, their Views are RECYCLED for new items

// ┌────────────────────────┐
// │  Item 1  (View A)      │ ← visible on screen
// │  Item 2  (View B)      │ ← visible
// │  Item 3  (View C)      │ ← visible
// │  Item 4  (View D)      │ ← visible
// │  Item 5  (View E)      │ ← visible
// └────────────────────────┘
//    Item 6  (waiting...)    ← not created yet
//
// User scrolls down:
// Item 1 scrolls off top → View A is RECYCLED
// Item 6 needs to appear → View A is reused with Item 6's data
// No new View created — just rebind existing View with new data

// Three required components:
// 1. Adapter      — binds your data to Views
// 2. ViewHolder   — holds references to Views in each item
// 3. LayoutManager — arranges items (linear, grid, staggered)

Basic Setup with ListAdapter (Modern Approach)

ListAdapter is the recommended base class — it uses DiffUtil internally for efficient updates:

Step 1: Define the item layout

<!-- res/layout/item_article.xml -->
<com.google.android.material.card.MaterialCardView
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:layout_marginHorizontal="16dp"
    android:layout_marginVertical="4dp"
    app:cardCornerRadius="12dp"
    app:cardElevation="2dp">

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:padding="16dp">

        <ImageView
            android:id="@+id/articleImage"
            android:layout_width="80dp"
            android:layout_height="80dp"
            android:scaleType="centerCrop"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent"
            android:contentDescription="@string/article_image_desc" />

        <TextView
            android:id="@+id/titleText"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_marginStart="12dp"
            android:textSize="16sp"
            android:textColor="?attr/colorOnSurface"
            android:maxLines="2"
            android:ellipsize="end"
            app:layout_constraintStart_toEndOf="@id/articleImage"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintTop_toTopOf="parent" />

        <TextView
            android:id="@+id/subtitleText"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_marginTop="4dp"
            android:textSize="14sp"
            android:textColor="?attr/colorOnSurfaceVariant"
            android:maxLines="1"
            app:layout_constraintStart_toStartOf="@id/titleText"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintTop_toBottomOf="@id/titleText" />

        <ImageButton
            android:id="@+id/bookmarkButton"
            android:layout_width="40dp"
            android:layout_height="40dp"
            android:background="?attr/selectableItemBackgroundBorderless"
            android:src="@drawable/ic_bookmark_outline"
            android:contentDescription="@string/bookmark"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintBottom_toBottomOf="parent" />

    </androidx.constraintlayout.widget.ConstraintLayout>

</com.google.android.material.card.MaterialCardView>

Step 2: Create the ViewHolder

// ViewHolder holds references to Views in one item
// Created once, reused many times via binding

class ArticleViewHolder(
    private val binding: ItemArticleBinding,
    private val onItemClick: (Article) -> Unit,
    private val onBookmarkClick: (Article) -> Unit
) : RecyclerView.ViewHolder(binding.root) {

    private var currentArticle: Article? = null

    init {
        // Set click listeners ONCE in constructor (not in bind)
        binding.root.setOnClickListener {
            currentArticle?.let(onItemClick)
        }
        binding.bookmarkButton.setOnClickListener {
            currentArticle?.let(onBookmarkClick)
        }
    }

    fun bind(article: Article) {
        currentArticle = article
        binding.titleText.text = article.title
        binding.subtitleText.text = "${article.author} · ${article.formattedDate}"
        binding.articleImage.load(article.imageUrl) {
            placeholder(R.drawable.placeholder)
            crossfade(true)
        }
        val bookmarkIcon = if (article.isBookmarked) {
            R.drawable.ic_bookmark_filled
        } else {
            R.drawable.ic_bookmark_outline
        }
        binding.bookmarkButton.setImageResource(bookmarkIcon)
    }
}

Step 3: Create the Adapter with DiffUtil

class ArticleAdapter(
    private val onItemClick: (Article) -> Unit,
    private val onBookmarkClick: (Article) -> Unit
) : ListAdapter<Article, ArticleViewHolder>(ArticleDiffCallback()) {

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ArticleViewHolder {
        // Inflate the item layout — called only when a NEW ViewHolder is needed
        val binding = ItemArticleBinding.inflate(
            LayoutInflater.from(parent.context), parent, false
        )
        return ArticleViewHolder(binding, onItemClick, onBookmarkClick)
    }

    override fun onBindViewHolder(holder: ArticleViewHolder, position: Int) {
        // Bind data to an existing ViewHolder — called when recycling
        holder.bind(getItem(position))
    }
}

// DiffUtil callback — tells ListAdapter how to compare items
class ArticleDiffCallback : DiffUtil.ItemCallback<Article>() {

    override fun areItemsTheSame(oldItem: Article, newItem: Article): Boolean {
        // Are these the SAME item? (by unique ID)
        // Used to detect moves and structural changes
        return oldItem.id == newItem.id
    }

    override fun areContentsTheSame(oldItem: Article, newItem: Article): Boolean {
        // Does the item LOOK the same? (content equality)
        // If true, the item is not rebound (no visual change)
        // If false, onBindViewHolder is called to update the View
        return oldItem == newItem   // data class equals() compares all properties
    }
}

Step 4: Set up in Activity/Fragment

class ArticleFragment : Fragment(R.layout.fragment_article_list) {

    private var _binding: FragmentArticleListBinding? = null
    private val binding get() = _binding!!
    private val viewModel: ArticleViewModel by viewModels()

    private val adapter = ArticleAdapter(
        onItemClick = { article -> navigateToDetail(article.id) },
        onBookmarkClick = { article -> viewModel.toggleBookmark(article.id) }
    )

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        _binding = FragmentArticleListBinding.bind(view)

        // Set up RecyclerView
        binding.recyclerView.adapter = adapter
        binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())

        // Observe data and submit to adapter
        viewLifecycleOwner.lifecycleScope.launch {
            viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
                viewModel.articles.collect { articles ->
                    adapter.submitList(articles)
                }
            }
        }
    }

    override fun onDestroyView() {
        binding.recyclerView.adapter = null   // prevent memory leak
        super.onDestroyView()
        _binding = null
    }
}

DiffUtil — How It Works

// DiffUtil calculates the MINIMUM set of updates between two lists
// Instead of refreshing the entire list, it only updates what changed

// Old list: [A, B, C, D, E]
// New list: [A, B, X, D, E, F]
//
// DiffUtil detects:
// - C was REMOVED at position 2
// - X was INSERTED at position 2
// - F was INSERTED at position 5
// - A, B, D, E are UNCHANGED (no rebind needed)

// This triggers targeted animations:
// - Remove animation for C
// - Insert animation for X and F
// - No animation for unchanged items

// Without DiffUtil (notifyDataSetChanged):
// - Entire list is redrawn
// - No animations
// - Scroll position may jump
// - Expensive for large lists

// ListAdapter handles DiffUtil on a BACKGROUND THREAD automatically
// You just call submitList() — it does the diff calculation off the main thread

Payload updates — partial binding

// When only one field changes (e.g., bookmark toggled),
// you can use payload to update ONLY that part, not the entire item

class ArticleDiffCallback : DiffUtil.ItemCallback<Article>() {

    override fun areItemsTheSame(oldItem: Article, newItem: Article): Boolean {
        return oldItem.id == newItem.id
    }

    override fun areContentsTheSame(oldItem: Article, newItem: Article): Boolean {
        return oldItem == newItem
    }

    override fun getChangePayload(oldItem: Article, newItem: Article): Any? {
        // Return what changed — adapter uses this for partial updates
        if (oldItem.isBookmarked != newItem.isBookmarked) {
            return "bookmark_changed"
        }
        return null   // null = full rebind
    }
}

// In Adapter — handle payloads
override fun onBindViewHolder(holder: ArticleViewHolder, position: Int, payloads: MutableList<Any>) {
    if (payloads.isEmpty()) {
        // Full bind
        holder.bind(getItem(position))
    } else {
        // Partial bind — only update what changed
        when (payloads.first()) {
            "bookmark_changed" -> holder.updateBookmark(getItem(position).isBookmarked)
        }
    }
}

// In ViewHolder
fun updateBookmark(isBookmarked: Boolean) {
    val icon = if (isBookmarked) R.drawable.ic_bookmark_filled else R.drawable.ic_bookmark_outline
    binding.bookmarkButton.setImageResource(icon)
}

// Payload updates prevent:
// - Image reloading (Coil/Glide load is skipped)
// - Unnecessary layout passes
// - Visual flickering

LayoutManagers

// LinearLayoutManager — vertical or horizontal list
binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())

// Horizontal list
binding.recyclerView.layoutManager = LinearLayoutManager(
    requireContext(), LinearLayoutManager.HORIZONTAL, false
)

// GridLayoutManager — grid with fixed column count
binding.recyclerView.layoutManager = GridLayoutManager(requireContext(), 2)   // 2 columns

// Grid with span size lookup (e.g., header takes full width)
val gridManager = GridLayoutManager(requireContext(), 2)
gridManager.spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() {
    override fun getSpanSize(position: Int): Int {
        return when (adapter.getItemViewType(position)) {
            VIEW_TYPE_HEADER -> 2   // full width
            VIEW_TYPE_ITEM -> 1     // half width
            else -> 1
        }
    }
}

// StaggeredGridLayoutManager — Pinterest-style masonry layout
binding.recyclerView.layoutManager = StaggeredGridLayoutManager(
    2, StaggeredGridLayoutManager.VERTICAL
)

Multiple View Types

// When your list has different item layouts (header, item, footer, ad)

sealed interface FeedItem {
    data class Header(val title: String) : FeedItem
    data class ArticleItem(val article: Article) : FeedItem
    data class AdItem(val adId: String) : FeedItem
}

class FeedAdapter(
    private val onArticleClick: (Article) -> Unit
) : ListAdapter<FeedItem, RecyclerView.ViewHolder>(FeedDiffCallback()) {

    companion object {
        const val TYPE_HEADER = 0
        const val TYPE_ARTICLE = 1
        const val TYPE_AD = 2
    }

    override fun getItemViewType(position: Int): Int {
        return when (getItem(position)) {
            is FeedItem.Header -> TYPE_HEADER
            is FeedItem.ArticleItem -> TYPE_ARTICLE
            is FeedItem.AdItem -> TYPE_AD
        }
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
        val inflater = LayoutInflater.from(parent.context)
        return when (viewType) {
            TYPE_HEADER -> HeaderViewHolder(
                ItemHeaderBinding.inflate(inflater, parent, false)
            )
            TYPE_ARTICLE -> ArticleViewHolder(
                ItemArticleBinding.inflate(inflater, parent, false),
                onArticleClick
            )
            TYPE_AD -> AdViewHolder(
                ItemAdBinding.inflate(inflater, parent, false)
            )
            else -> throw IllegalArgumentException("Unknown view type: $viewType")
        }
    }

    override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
        when (val item = getItem(position)) {
            is FeedItem.Header -> (holder as HeaderViewHolder).bind(item)
            is FeedItem.ArticleItem -> (holder as ArticleViewHolder).bind(item.article)
            is FeedItem.AdItem -> (holder as AdViewHolder).bind(item)
        }
    }
}

class FeedDiffCallback : DiffUtil.ItemCallback<FeedItem>() {
    override fun areItemsTheSame(oldItem: FeedItem, newItem: FeedItem): Boolean {
        return when {
            oldItem is FeedItem.Header && newItem is FeedItem.Header ->
                oldItem.title == newItem.title
            oldItem is FeedItem.ArticleItem && newItem is FeedItem.ArticleItem ->
                oldItem.article.id == newItem.article.id
            oldItem is FeedItem.AdItem && newItem is FeedItem.AdItem ->
                oldItem.adId == newItem.adId
            else -> false
        }
    }

    override fun areContentsTheSame(oldItem: FeedItem, newItem: FeedItem): Boolean {
        return oldItem == newItem
    }
}

Item Decoration — Spacing and Dividers

// Add consistent spacing between items
class SpacingItemDecoration(private val spacing: Int) : RecyclerView.ItemDecoration() {

    override fun getItemOffsets(
        outRect: Rect,
        view: View,
        parent: RecyclerView,
        state: RecyclerView.State
    ) {
        val position = parent.getChildAdapterPosition(view)
        outRect.left = spacing
        outRect.right = spacing
        outRect.bottom = spacing
        if (position == 0) {
            outRect.top = spacing   // top spacing only for first item
        }
    }
}

// Usage
val spacing = resources.getDimensionPixelSize(R.dimen.spacing_sm)
binding.recyclerView.addItemDecoration(SpacingItemDecoration(spacing))

// Material divider
binding.recyclerView.addItemDecoration(
    MaterialDividerItemDecoration(requireContext(), LinearLayoutManager.VERTICAL).apply {
        dividerInsetStart = resources.getDimensionPixelSize(R.dimen.spacing_lg)
        isLastItemDecorated = false
    }
)

Swipe to Delete and Drag to Reorder

// ItemTouchHelper handles swipe and drag gestures

val touchHelper = ItemTouchHelper(object : ItemTouchHelper.SimpleCallback(
    ItemTouchHelper.UP or ItemTouchHelper.DOWN,   // drag directions
    ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT  // swipe directions
) {
    override fun onMove(
        recyclerView: RecyclerView,
        viewHolder: RecyclerView.ViewHolder,
        target: RecyclerView.ViewHolder
    ): Boolean {
        val fromPos = viewHolder.adapterPosition
        val toPos = target.adapterPosition
        viewModel.moveItem(fromPos, toPos)
        return true
    }

    override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
        val position = viewHolder.adapterPosition
        val article = adapter.currentList[position]
        viewModel.deleteArticle(article)

        // Show undo snackbar
        Snackbar.make(binding.root, "Article deleted", Snackbar.LENGTH_LONG)
            .setAction("Undo") { viewModel.undoDelete(article) }
            .show()
    }
})

touchHelper.attachToRecyclerView(binding.recyclerView)

Performance Tips

// 1. Set fixed size if item dimensions don't change
binding.recyclerView.setHasFixedSize(true)

// 2. Set prefetch count for smoother scrolling
(binding.recyclerView.layoutManager as LinearLayoutManager).initialPrefetchItemCount = 4

// 3. RecycledViewPool — share ViewHolders between RecyclerViews
// Useful for nested RecyclerViews (horizontal lists inside vertical list)
val sharedPool = RecyclerView.RecycledViewPool()
outerRecyclerView.setRecycledViewPool(sharedPool)
innerRecyclerView.setRecycledViewPool(sharedPool)

// 4. Avoid expensive operations in onBindViewHolder
// ❌ Don't create new objects, set up listeners, or do computation in bind
// ✅ Do those in onCreateViewHolder or ViewHolder init

// 5. Use payload updates for partial changes
// When only bookmark changes, don't reload the entire image

// 6. Set adapter to null in onDestroyView to prevent leaks
override fun onDestroyView() {
    binding.recyclerView.adapter = null
    super.onDestroyView()
    _binding = null
}

// 7. DiffUtil runs on background thread in ListAdapter
// Never call notifyDataSetChanged() — let DiffUtil handle updates

Empty State, Loading, and Error

// Common pattern: show different states based on data

// In layout — stack states with FrameLayout or ViewFlipper
<FrameLayout>
    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/recyclerView" />

    <ProgressBar
        android:id="@+id/loadingView"
        android:visibility="gone" />

    <LinearLayout
        android:id="@+id/emptyView"
        android:visibility="gone">
        <ImageView android:src="@drawable/ic_empty" />
        <TextView android:text="@string/no_articles" />
    </LinearLayout>

    <LinearLayout
        android:id="@+id/errorView"
        android:visibility="gone">
        <TextView android:text="@string/error_message" />
        <Button android:id="@+id/retryButton" />
    </LinearLayout>
</FrameLayout>
// In Fragment — observe state and toggle visibility
viewLifecycleOwner.lifecycleScope.launch {
    viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
        viewModel.uiState.collect { state ->
            when (state) {
                is UiState.Loading -> {
                    binding.loadingView.isVisible = true
                    binding.recyclerView.isVisible = false
                    binding.emptyView.isVisible = false
                    binding.errorView.isVisible = false
                }
                is UiState.Success -> {
                    binding.loadingView.isVisible = false
                    binding.recyclerView.isVisible = state.articles.isNotEmpty()
                    binding.emptyView.isVisible = state.articles.isEmpty()
                    binding.errorView.isVisible = false
                    adapter.submitList(state.articles)
                }
                is UiState.Error -> {
                    binding.loadingView.isVisible = false
                    binding.recyclerView.isVisible = false
                    binding.emptyView.isVisible = false
                    binding.errorView.isVisible = true
                }
            }
        }
    }
}

ConcatAdapter — Compose Multiple Adapters

// ConcatAdapter lets you combine multiple adapters into one RecyclerView
// Perfect for: header + content + footer, or loading indicator at the bottom

class HeaderAdapter(private val title: String) : RecyclerView.Adapter<HeaderViewHolder>() {
    override fun getItemCount() = 1
    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): HeaderViewHolder { /* ... */ }
    override fun onBindViewHolder(holder: HeaderViewHolder, position: Int) { /* ... */ }
}

class LoadingFooterAdapter : RecyclerView.Adapter<LoadingViewHolder>() {
    var isLoading = false
        set(value) {
            if (field != value) {
                field = value
                if (value) notifyItemInserted(0) else notifyItemRemoved(0)
            }
        }
    override fun getItemCount() = if (isLoading) 1 else 0
    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): LoadingViewHolder { /* ... */ }
    override fun onBindViewHolder(holder: LoadingViewHolder, position: Int) { /* ... */ }
}

// Combine
val headerAdapter = HeaderAdapter("Latest Articles")
val articleAdapter = ArticleAdapter(onItemClick, onBookmarkClick)
val footerAdapter = LoadingFooterAdapter()

binding.recyclerView.adapter = ConcatAdapter(headerAdapter, articleAdapter, footerAdapter)

// Update content — only the relevant adapter changes
articleAdapter.submitList(articles)
footerAdapter.isLoading = true   // shows loading spinner at bottom

Common Mistakes to Avoid

Mistake 1: Using notifyDataSetChanged instead of DiffUtil

// ❌ Refreshes entire list — no animations, no recycling optimisation
fun updateData(newList: List<Article>) {
    this.items = newList
    notifyDataSetChanged()   // everything redraws!
}

// ✅ Use ListAdapter + submitList — DiffUtil calculates minimal changes
adapter.submitList(newList)   // smooth animations, only changed items update

Mistake 2: Setting click listeners in onBindViewHolder

// ❌ Creates a new lambda object on EVERY bind (every time item is recycled)
override fun onBindViewHolder(holder: ArticleViewHolder, position: Int) {
    val article = getItem(position)
    holder.itemView.setOnClickListener { onClick(article) }   // new object every bind!
}

// ✅ Set listener once in ViewHolder init — update the data reference in bind
class ArticleViewHolder(binding: ItemArticleBinding, onClick: (Article) -> Unit) :
    RecyclerView.ViewHolder(binding.root) {

    private var currentArticle: Article? = null

    init {
        binding.root.setOnClickListener { currentArticle?.let(onClick) }   // set once
    }

    fun bind(article: Article) {
        currentArticle = article   // update reference
    }
}

Mistake 3: Submitting the same list reference

// ❌ DiffUtil compares old and new lists — if same reference, it skips!
val articles = mutableListOf<Article>()
articles.add(newArticle)
adapter.submitList(articles)   // nothing happens! same reference

// ✅ Submit a NEW list (copy)
adapter.submitList(articles.toList())   // new list object — DiffUtil runs

// ✅ Or use immutable state from ViewModel
// viewModel.articles emits a new List every time → submitList works correctly

Mistake 4: Not setting adapter to null in onDestroyView

// ❌ RecyclerView holds adapter → adapter holds ViewHolders → ViewHolders hold Views
// Fragment is on back stack → RecyclerView is destroyed → but adapter references leak

// ✅ Clear adapter reference when View is destroyed
override fun onDestroyView() {
    binding.recyclerView.adapter = null   // break the reference chain
    super.onDestroyView()
    _binding = null
}

Mistake 5: Incorrect DiffUtil comparison

// ❌ Using position or content equality for areItemsTheSame
override fun areItemsTheSame(old: Article, new: Article): Boolean {
    return old == new   // WRONG — this is content equality, not identity
    // If content changes, DiffUtil thinks it's a different item = incorrect animations
}

// ✅ Use unique ID for areItemsTheSame
override fun areItemsTheSame(old: Article, new: Article): Boolean {
    return old.id == new.id   // same ID = same item, even if content changed
}

// ✅ Use full equality for areContentsTheSame
override fun areContentsTheSame(old: Article, new: Article): Boolean {
    return old == new   // data class equals — checks all properties
}

Summary

  • RecyclerView recycles Views as they scroll off screen — efficient for large lists
  • Three required components: Adapter (binds data), ViewHolder (holds Views), LayoutManager (arranges items)
  • Use ListAdapter as the base class — it handles DiffUtil on a background thread automatically
  • DiffUtil calculates minimal updates between lists — smooth animations, no full refresh
  • areItemsTheSame checks identity (same ID); areContentsTheSame checks content equality
  • Use payload updates (getChangePayload) for partial binding when only one field changes
  • LayoutManagers: LinearLayoutManager (list), GridLayoutManager (grid), StaggeredGridLayoutManager (masonry)
  • Support multiple view types with getItemViewType and sealed classes for type safety
  • Use ItemDecoration for spacing and dividers, ItemTouchHelper for swipe and drag
  • Set click listeners in ViewHolder init, not in onBindViewHolder — avoids creating objects on every bind
  • Always submit a new list to ListAdapter — same reference is ignored by DiffUtil
  • Set adapter = null in onDestroyView to prevent memory leaks in Fragments
  • Use ConcatAdapter to combine header, content, and footer adapters
  • Handle empty, loading, and error states alongside the RecyclerView

RecyclerView is the workhorse of Android UI. Almost every production app has multiple RecyclerViews — feeds, search results, settings, chat messages. Master ListAdapter with DiffUtil, set up click listeners correctly, handle multiple view types, and manage empty/loading/error states — and you’ll build smooth, professional lists that handle thousands of items effortlessly.

Happy coding!