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
areItemsTheSamechecks identity (same ID);areContentsTheSamechecks 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
getItemViewTypeand 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 = nullinonDestroyViewto 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!
Comments (0)