RecyclerView doesn't give you a built-in setOnItemClickListener() like ListView did. That was a deliberate design decision — RecyclerView gives you full control over how clicks are handled, which means you can do things ListView never could: click different parts of an item differently, handle long press, handle double tap, or pass typed data instead of a raw View to your callback.
The trade-off is that there's no single "correct" way to do it — there are a few patterns, each suited to different situations. This guide covers all of them in Kotlin with ViewBinding, from the simplest lambda to the cleanest interface callback pattern.
Approach 1 — Lambda in Adapter Constructor (Simplest)
Pass the click handler as a lambda when creating the adapter. Clean, simple, no interface needed. Best when you have one click action per item:
class UserAdapter(
private val users: List<User>,
private val onItemClick: (User) -> Unit // lambda callback
) : RecyclerView.Adapter<UserAdapter.ViewHolder>() {
inner class ViewHolder(private val binding: ItemUserBinding)
: RecyclerView.ViewHolder(binding.root) {
fun bind(user: User) {
binding.tvName.text = user.name
binding.tvEmail.text = user.email
// Set click on the whole item
binding.root.setOnClickListener {
onItemClick(user) // pass the item, not the View
}
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
ViewHolder(ItemUserBinding.inflate(
LayoutInflater.from(parent.context), parent, false))
override fun onBindViewHolder(holder: ViewHolder, position: Int) =
holder.bind(users[position])
override fun getItemCount() = users.size
}
// In your Activity or Fragment — pass the lambda when creating adapter
val adapter = UserAdapter(users) { user ->
// Handle click — user is fully typed, no casting
Toast.makeText(this, "Clicked: ${user.name}", Toast.LENGTH_SHORT).show()
navigateToDetail(user.id)
}
binding.recyclerView.adapter = adapter
View to the callback and called getTag() to retrieve the item. In Kotlin, pass the typed item directly — no tags, no casting, no null checks.
Approach 2 — Interface Callback (Most Flexible)
Define an interface with one or more callback methods. This is the cleanest pattern when you need multiple click actions per item — for example, clicking the item vs clicking a delete button within it:
// Define the interface
interface OnUserClickListener {
fun onUserClick(user: User)
fun onUserLongClick(user: User): Boolean
fun onDeleteClick(user: User)
}
class UserAdapter(
private val users: MutableList<User>,
private val listener: OnUserClickListener
) : RecyclerView.Adapter<UserAdapter.ViewHolder>() {
inner class ViewHolder(private val binding: ItemUserBinding)
: RecyclerView.ViewHolder(binding.root) {
fun bind(user: User) {
binding.tvName.text = user.name
binding.tvEmail.text = user.email
// Item click
binding.root.setOnClickListener {
listener.onUserClick(user)
}
// Long press on item
binding.root.setOnLongClickListener {
listener.onUserLongClick(user)
}
// Click on the delete icon within the item
binding.btnDelete.setOnClickListener {
listener.onDeleteClick(user)
}
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
ViewHolder(ItemUserBinding.inflate(
LayoutInflater.from(parent.context), parent, false))
override fun onBindViewHolder(holder: ViewHolder, position: Int) =
holder.bind(users[position])
override fun getItemCount() = users.size
}
// Implement the interface in your Fragment
class UsersFragment : Fragment(), OnUserClickListener {
private lateinit var adapter: UserAdapter
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
adapter = UserAdapter(userList, this)
binding.recyclerView.adapter = adapter
}
override fun onUserClick(user: User) {
findNavController().navigate(
UsersFragmentDirections.actionToDetail(user.id)
)
}
override fun onUserLongClick(user: User): Boolean {
showContextMenu(user)
return true // consume the event
}
override fun onDeleteClick(user: User) {
viewModel.deleteUser(user)
}
}
Approach 3 — Multiple Click Zones on One Item
This is where RecyclerView really shines over ListView. You can set independent click listeners on different views within the same item — the image does one thing, the title does another, a button does something else entirely:
inner class ViewHolder(private val binding: ItemArticleBinding)
: RecyclerView.ViewHolder(binding.root) {
fun bind(
article: Article,
onArticleClick: (Article) -> Unit,
onBookmarkClick: (Article) -> Unit,
onShareClick: (Article) -> Unit
) {
binding.tvTitle.text = article.title
binding.tvAuthor.text = article.author
Glide.with(binding.root)
.load(article.imageUrl)
.into(binding.imgThumbnail)
// Clicking the thumbnail opens the article
binding.imgThumbnail.setOnClickListener {
onArticleClick(article)
}
// Clicking the title also opens the article
binding.tvTitle.setOnClickListener {
onArticleClick(article)
}
// Clicking bookmark icon saves the article
binding.btnBookmark.setOnClickListener {
onBookmarkClick(article)
}
// Clicking share icon opens share sheet
binding.btnShare.setOnClickListener {
onShareClick(article)
}
}
}
Approach 4 — ListAdapter with DiffUtil (Modern Best Practice)
ListAdapter is the modern replacement for RecyclerView.Adapter. It handles list diffing automatically — only updating rows that actually changed instead of calling notifyDataSetChanged() on everything. Combined with a lambda click callback, it's the cleanest full solution:
// DiffUtil callback — tells ListAdapter how to compare items
class UserDiffCallback : DiffUtil.ItemCallback<User>() {
override fun areItemsTheSame(oldItem: User, newItem: User) =
oldItem.id == newItem.id // same row?
override fun areContentsTheSame(oldItem: User, newItem: User) =
oldItem == newItem // same data? (uses data class equals)
}
class UserListAdapter(
private val onItemClick: (User) -> Unit,
private val onLongClick: (User) -> Boolean = { false }
) : ListAdapter<User, UserListAdapter.ViewHolder>(UserDiffCallback()) {
inner class ViewHolder(private val binding: ItemUserBinding)
: RecyclerView.ViewHolder(binding.root) {
fun bind(user: User) {
binding.tvName.text = user.name
binding.tvEmail.text = user.email
binding.root.setOnClickListener { onItemClick(user) }
binding.root.setOnLongClickListener { onLongClick(user) }
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
ViewHolder(ItemUserBinding.inflate(
LayoutInflater.from(parent.context), parent, false))
override fun onBindViewHolder(holder: ViewHolder, position: Int) =
holder.bind(getItem(position)) // getItem() from ListAdapter
}
// In Fragment — submit list updates, only changed rows animate
val adapter = UserListAdapter(
onItemClick = { user -> navigateToDetail(user) },
onLongClick = { user -> showOptions(user); true }
)
binding.recyclerView.adapter = adapter
// Observe and submit new list — DiffUtil handles the rest
viewModel.users.observe(viewLifecycleOwner) { users ->
adapter.submitList(users)
}
Which Pattern Should You Use?
| Pattern | Best For | Avoid When |
|---|---|---|
| Lambda in constructor | Simple single action per item | Multiple different click types needed |
| Interface callback | Multiple click actions (click + long press + delete) | Simple single action — overkill |
| Multiple zones | Different parts of item do different things | Simple list items with one action |
| ListAdapter + DiffUtil | All new projects — always | Never — always prefer over plain Adapter |
Best Practices
- Always pass the item to the callback, not the View — the old Java pattern passed a
Viewand calledgetTag(). In Kotlin, pass the typed data object directly. No tags, no casting, compiler-safe. - Set click listeners in bind(), not onCreateViewHolder() — ViewHolders are reused for different items. If you set the listener in
onCreateViewHolder(), it captures a stale item reference. Always set it inbind()where the current item is known. - Use ListAdapter over RecyclerView.Adapter —
ListAdapterwithDiffUtilgives you free smooth animations and prevents full list redraws. There's no reason to use plainRecyclerView.Adapterin new code. - Don't call notifyDataSetChanged() — it redraws every item regardless of what changed. Use
submitList()withListAdapterinstead, which only redraws the rows that actually changed. - Use ripple effect on clickable items — add
android:background="?attr/selectableItemBackground"to your item root view for the Material ripple. Without it, items feel unresponsive even when clicks work.
Frequently Asked Questions
How do I add a click listener to RecyclerView items in Kotlin?
Pass a lambda to your adapter constructor and call it in bind() using binding.root.setOnClickListener { onItemClick(item) }. This passes the typed item directly — no getTag(), no casting.
Why doesn't RecyclerView have setOnItemClickListener?
It was intentionally removed to give full control — you can set independent listeners on different parts of an item, handle long press, and pass typed data instead of raw Views. The flexibility is the feature.
What is the difference between RecyclerView.Adapter and ListAdapter?
ListAdapter adds automatic DiffUtil support. Call submitList() with a new list and it only animates changed rows in the background. Plain RecyclerView.Adapter requires manual notifyItem* calls. Always use ListAdapter for new projects.
How do I handle long click on RecyclerView items?
Use setOnLongClickListener on binding.root in bind(). Return true to consume the event, false to let it propagate. Pass the item to the callback so the caller knows which item was long-pressed.
- Lambda in constructor — simplest, best for one action per item
- Interface callback — best when you need click + long click + child view clicks
- Multiple zones — different parts of item trigger different actions
- ListAdapter + DiffUtil — always use for new projects, free animations
- Always set listeners in
bind()— not inonCreateViewHolder() - Pass the typed item to callback — not the View
- Add
selectableItemBackgroundripple to item root for tactile feedback - Never call
notifyDataSetChanged()— usesubmitList()instead