📋 Screens, Lists & Navigation · Lesson 23
RecyclerView Click, Grid & DiffUtil
Item clicks with a lambda
Kotlin
class SubjectAdapter(
private val items: List<Subject>,
private val onClick: (Subject) -> Unit // the screen decides what a click does
) : RecyclerView.Adapter<SubjectAdapter.VH>() {
class VH(val binding: ItemSubjectRowBinding) : RecyclerView.ViewHolder(binding.root)
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
VH(ItemSubjectRowBinding.inflate(LayoutInflater.from(parent.context), parent, false))
override fun onBindViewHolder(holder: VH, position: Int) {
val s = items[position]
holder.binding.tvName.text = s.name
holder.binding.root.setOnClickListener { onClick(s) }
}
override fun getItemCount() = items.size
}
// Activity
binding.recyclerView.adapter = SubjectAdapter(subjects) { subject ->
startActivity(SubjectActivity.newIntent(this, subject.name))
}ListAdapter + DiffUtil
ListAdapter stores the list for you and uses DiffUtil on a background thread to work out what changed. You just call submitList().
Kotlin – SubjectListAdapter.kt
class SubjectListAdapter(private val onClick: (Subject) -> Unit) :
ListAdapter<Subject, SubjectListAdapter.VH>(DIFF) { // no list field – ListAdapter keeps it
class VH(val binding: ItemSubjectRowBinding) : RecyclerView.ViewHolder(binding.root)
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
VH(ItemSubjectRowBinding.inflate(LayoutInflater.from(parent.context), parent, false))
override fun onBindViewHolder(holder: VH, position: Int) {
val s = getItem(position)
holder.binding.tvName.text = s.name
holder.binding.root.setOnClickListener { onClick(s) }
}
companion object {
val DIFF = object : DiffUtil.ItemCallback<Subject>() {
override fun areItemsTheSame(old: Subject, new: Subject) = old.name == new.name // same row?
override fun areContentsTheSame(old: Subject, new: Subject) = old == new // same data?
}
}
}
// usage
val adapter = SubjectListAdapter { openSubject(it) }
binding.recyclerView.adapter = adapter
adapter.submitList(subjects) // later: adapter.submitList(newList)Old list
- Maths
- Physics
- Chemistry
New list
- Maths
- Physics ✎ (changed)
- Biology ✚ (inserted)
Real adapter from Student Diary
The note list in the final project shows title, details, subject chip, due date, priority strip, done checkbox, favourite star and a photo thumbnail:
Kotlin – ui/notes/NoteAdapter.kt
package com.codingeasily.studentdiary.ui.notes
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.core.content.ContextCompat
import androidx.core.view.isVisible
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide
import com.codingeasily.studentdiary.R
import com.codingeasily.studentdiary.data.Note
import com.codingeasily.studentdiary.databinding.ItemNoteBinding
import com.codingeasily.studentdiary.util.DateUtils
import java.io.File
/**
* ListAdapter + DiffUtil: when the list changes, only the changed rows are redrawn.
*/
class NoteAdapter(
private val onClick: (Note) -> Unit,
private val onToggleDone: (Note) -> Unit,
private val onToggleFavorite: (Note) -> Unit
) : ListAdapter<Note, NoteAdapter.NoteViewHolder>(DIFF) {
inner class NoteViewHolder(val binding: ItemNoteBinding) : RecyclerView.ViewHolder(binding.root)
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): NoteViewHolder {
val binding = ItemNoteBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return NoteViewHolder(binding)
}
override fun onBindViewHolder(holder: NoteViewHolder, position: Int) {
val note = getItem(position)
val b = holder.binding
val ctx = b.root.context
b.tvTitle.text = note.title
b.tvTitle.alpha = if (note.isDone) 0.5f else 1f
b.tvSubject.text = note.subject
b.tvContent.text = note.content
b.tvContent.isVisible = note.content.isNotBlank()
b.tvDue.isVisible = note.dueAt != null
note.dueAt?.let { b.tvDue.text = ctx.getString(R.string.due_at, DateUtils.formatShort(it)) }
val color = when (note.priority) {
Note.PRIORITY_HIGH -> R.color.priority_high
Note.PRIORITY_LOW -> R.color.priority_low
else -> R.color.priority_medium
}
b.priorityStrip.setBackgroundColor(ContextCompat.getColor(ctx, color))
// Remove the old listener first, otherwise setChecked() would fire it while recycling
b.cbDone.setOnCheckedChangeListener(null)
b.cbDone.isChecked = note.isDone
b.cbDone.setOnCheckedChangeListener { _, _ -> onToggleDone(note) }
b.btnFavorite.setImageResource(if (note.isFavorite) R.drawable.ic_star else R.drawable.ic_star_outline)
b.btnFavorite.setOnClickListener { onToggleFavorite(note) }
b.ivThumb.isVisible = note.imagePath != null
note.imagePath?.let { Glide.with(b.ivThumb).load(File(it)).centerCrop().into(b.ivThumb) }
b.root.setOnClickListener { onClick(note) }
}
companion object {
private val DIFF = object : DiffUtil.ItemCallback<Note>() {
override fun areItemsTheSame(old: Note, new: Note) = old.id == new.id
override fun areContentsTheSame(old: Note, new: Note) = old == new
}
}
}Grid and horizontal lists
Kotlin
// 2 columns (Student Diary – SubjectsFragment)
binding.recyclerView.layoutManager = GridLayoutManager(requireContext(), 2)
// horizontal carousel
binding.rvBanners.layoutManager = LinearLayoutManager(this, LinearLayoutManager.HORIZONTAL, false)
PagerSnapHelper().attachToRecyclerView(binding.rvBanners) // snap one card at a time
// header that spans both columns in a grid
(binding.recyclerView.layoutManager as GridLayoutManager).spanSizeLookup =
object : GridLayoutManager.SpanSizeLookup() {
override fun getSpanSize(position: Int) = if (position == 0) 2 else 1
}Swipe to delete + Undo
Kotlin
// From Student Diary – NoteListFragment
val swipe = object : ItemTouchHelper.SimpleCallback(0, ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT) {
override fun onMove(rv: RecyclerView, vh: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder) = false
override fun onSwiped(vh: RecyclerView.ViewHolder, direction: Int) {
val position = vh.bindingAdapterPosition
if (position == RecyclerView.NO_POSITION) return
val note = adapter.currentList[position]
viewModel.delete(note)
Snackbar.make(binding.root, getString(R.string.note_deleted, note.title), Snackbar.LENGTH_LONG)
.setAction(R.string.undo) { viewModel.restore(note) }
.show()
}
}
ItemTouchHelper(swipe).attachToRecyclerView(binding.recyclerView)Empty state
Kotlin
viewModel.notes(filter).observe(viewLifecycleOwner) { list ->
adapter.submitList(list)
binding.emptyView.isVisible = list.isEmpty() // "No notes here yet. Tap + to add one."
}Multiple view types
Kotlin – headers + items
sealed class Row {
data class Header(val title: String) : Row()
data class Item(val note: Note) : Row()
}
override fun getItemViewType(position: Int) = when (getItem(position)) {
is Row.Header -> TYPE_HEADER
is Row.Item -> TYPE_ITEM
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
val inflater = LayoutInflater.from(parent.context)
return if (viewType == TYPE_HEADER) HeaderVH(ItemHeaderBinding.inflate(inflater, parent, false))
else NoteVH(ItemNoteBinding.inflate(inflater, parent, false))
}Common mistakes
- Submitting the same mutable list after editing it → no update. Submit a new list.
areItemsTheSamecomparing whole objects → every change looks like remove + insert (flicker). Compare ids.- CheckBox listener firing while binding → set the listener to null first, then set isChecked, then the new listener (see NoteAdapter).
- Using
adapterPosition(deprecated) → usebindingAdapterPosition.
Summary
- Pass click actions as lambdas into the adapter.
- ListAdapter + DiffUtil + submitList = efficient, animated updates.
- GridLayoutManager for grids; ItemTouchHelper for swipe; show an empty view when the list is empty.
✅ Practice
Convert your states list to ListAdapter, add a search box that submits a filtered list, show it as a 2-column grid on tablets (resources.configuration.screenWidthDp >= 600) and add swipe-to-delete with Undo.
Frequently Asked Questions
What does DiffUtil do?
It compares the old and new list, finds which items were added, removed, moved or changed, and updates only those rows with animations.
Why does submitList not update my list?
If you pass the same mutable list object after changing it, DiffUtil sees no difference. Always submit a new list (for example list.toList()) — Room and Flow do this automatically.
Where should I set the click listener?
In onBindViewHolder (simple) or in the ViewHolder init block (slightly faster). Pass the action into the adapter as a lambda so the adapter does not know about navigation.
