The entire code base was uploaded to GitHub Repo so that you can better comprehend the various concepts. The chapters are built on each other. Nonetheless, if you are familiar with a certain concept, that one can be skipped. Furthermore, a separate git branch was created for each chapter. So you can easily jump back and forth between the chapters.
pixel@fedora:~/repo $ git branch
basic-setup
layoutManager
clickHandling-interface
clickHandling-functionType
multiViewTypes
loadMore
dataBinding
diffUtil
Table of Contents
- Basic Setup
- Set LayoutManager declaratively
- Click Handling
- Multiple View Types
- Load More / Infinite Scrolling
- Data Binding
- DiffUtil
- References
Basic Setup
Add the recyclerview to your dependencies in build.gradle (:app)
:
dependencies {
// ...
implementation 'androidx.recyclerview:recyclerview:1.1.0'
}
Our model looks as follows:
data class Todo(
val id: UUID,
val description: String,
val isDone: Boolean,
val dueDate: Date
)
interface TodoRepository {
fun getOne(id: UUID): Todo?
fun getMany(beginAt: Int, count: Int): List<Todo>
fun insert(item: Todo): Boolean
fun remove(id: UUID): Boolean
fun totalSize(): Int
}
Our RecyclerAdapter and the corresponding ViewHolder are set up as follows:
data class TodoVH(val root: View): RecyclerView.ViewHolder(root){
val description: TextView = root.findViewById(R.id.row_description)
val id: TextView = root.findViewById(R.id.row_id)
val checkBox: CheckBox = root.findViewById(R.id.row_checkbox)
fun bindData(item: Todo){
description.text = item.description
// display only the first 8 characters
id.text = item.id.toString().take(8)
}
}
class TodoAdapter(val data: List<Todo>): RecyclerView.Adapter<TodoVH>() {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): TodoVH {
val rootView = LayoutInflater.from(parent.context)
.inflate(R.layout.row_default, parent, false)
return TodoVH(rootView)
}
override fun onBindViewHolder(holder: TodoVH, position: Int) {
holder.bindData(data[position])
}
override fun getItemCount(): Int = data.size
}
Lastly our Activity:
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
val todoRepo = DefaultTodoRepo()
val initialData = todoRepo.getMany(0, 10)
val todoAdapter = TodoAdapter(initialData)
val recyclerView: RecyclerView = findViewById(R.id.recyclerview)
recyclerView.apply {
adapter = todoAdapter
layoutManager = LinearLayoutManager(context)
setHasFixedSize(true)
}
}
}
Set LayoutManager declaratively
Instead of explicitly instantiating a LayoutManager in the Activity,
recyclerView.apply {
adapter = todoAdapter
layoutManager = LinearLayoutManager(context) //<-- Remove this line
setHasFixedSize(true)
}
we could declaratively set a LayoutManager using the fully-qualified name in the XML layout.
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerview"
android:layout_width="0dp"
android:layout_height="0dp"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
...
/>
Click Handling
Since the onBindViewHolder
method is used to populate the views while scrolling, do not set the View.OnClickListener
inside the onBindViewHolder
method. Otherwise there will be a lot of unnecessary setOnClickListener
calls. It is wiser to set the View.OnClickListener
inside the onCreateViewHolder
method or in the constructor of the ViewHolder.
Interface Approach
Let's declare first a custom interface:
interface OnItemClickListener {
fun onItemClick(item: Todo, position: Int)
}
Invoke our custom clickListener
when the row view was clicked:
data class TodoVH(
val root: View,
val clickListener: OnItemClickListener
): RecyclerView.ViewHolder(root), View.OnClickListener {
// ...
lateinit var todo: Todo
init {
root.setOnClickListener(this)
}
fun bindData(data: Todo){
// ...
todo = data
}
override fun onClick(v: View?) {
clickListener.onItemClick(todo, adapterPosition)
}
}
Pass the clickListener
down from Adapter to ViewHolder:
class TodoAdapter(val data: List<Todo>, val clickListener: OnItemClickListener): RecyclerView.Adapter<TodoVH>() {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): TodoVH {
val rootView = LayoutInflater.from(parent.context)
.inflate(R.layout.row_default, parent, false)
return TodoVH(rootView, clickListener)
}
// ...
Implement the interface in MainActivity:
class MainActivity : AppCompatActivity(), OnItemClickListener {
override fun onCreate(savedInstanceState: Bundle?) {
// ...
val todoAdapter = TodoAdapter(initialData, this)
// ...
}
override fun onItemClick(item: Todo, position: Int) {
Toast.makeText(this, item.description, Toast.LENGTH_SHORT).show()
}
}
Function Type Approach
Let's first declare our function type:
typealias OnItemClick = (Todo, Int) -> Unit
Invoke our onItemClick
when the row view was clicked:
data class TodoVH(
val root: View,
val onItemClick: OnItemClick
): RecyclerView.ViewHolder(root) {
// ...
lateinit var todo: Todo
init {
// Pass a lambda instead of interface
root.setOnClickListener{ onItemClick(todo, adapterPosition) }
}
fun bindData(data: Todo){
// ...
todo = data
}
}
Pass the onItemClick
down from Adapter to ViewHolder:
class TodoAdapter(val data: List<Todo>, val onItemClick: OnItemClick): RecyclerView.Adapter<TodoVH>() {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): TodoVH {
val rootView = LayoutInflater.from(parent.context)
.inflate(R.layout.row_default, parent, false)
return TodoVH(rootView, onItemClick)
}
// ...
Simply implement as lambda in MainActivity:
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
// ...
val todoAdapter = TodoAdapter(initialData){todo, position ->
Toast.makeText(this, todo.description, Toast.LENGTH_SHORT).show()
}
// ...
}
}
Multiple View Types
RecyclerView has the ability to render different types of row view. In our example we like to show different icons based on the Todo's type.
Let's extend our Todo model with type:
enum class TodoType(val value: Int) { Work(0), Home(1), Hobby(2) }
data class Todo(
val id: UUID,
val description: String,
val isDone: Boolean,
val dueDate: Date,
val type: TodoType
)
First we need to create 3 icons. (Right click on the drawable folder -> New -> Vector Asset) Then add an ImageView in our row layout. Duplicate this row layout twice so that we have for each type an separate row layout. Don't forget to change the drawable in each layout.
<ImageView
android:id="@+id/imageView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
...
app:srcCompat="@drawable/ic_work" />
In order to render different view types, we have to provide own logic in the getItemViewType
method. Since we have unique values for each Todo type, the type value is simply passed on. In the onCreateViewHolder
method, the corresponding layout is created based on the viewType
.
class TodoAdapter(val data: List<Todo>, val onItemClick: OnItemClick): RecyclerView.Adapter<TodoVH>() {
override fun getItemViewType(position: Int): Int {
return data[position].type.value
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): TodoVH {
val layout = when(viewType) {
TodoType.Work.value -> R.layout.row_work
TodoType.Home.value -> R.layout.row_home
else -> R.layout.row_hobby
}
val rootView = LayoutInflater.from(parent.context).inflate(layout, parent, false)
return TodoVH(rootView, onItemClick)
}
// ...
}
By the way, you could also add header and footer to your recyclerview with this approach.
Load More / Infinite Scrolling
The basic idea is that we use a custom scroll listener to check whether the last item is already visible when scrolling down. When yes, we check if there are more items in our repository available. If that is also true, we display the loading row and we let the new data load. As soon as the data is loaded, it is inserted and the loading row is hidden.
Our scroll listener:
class InfiniteScrollListener(
val hasMoreItems: (currentSize: Int)->Boolean,
val loadMoreItems: (currentSize: Int)->Unit
): RecyclerView.OnScrollListener(){
var isLoading = false
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
super.onScrolled(recyclerView, dx, dy)
val layoutManager = recyclerView.layoutManager as LinearLayoutManager
val hasScrolledDown = dy > 0
if(!hasScrolledDown) { return }
val currentSize = layoutManager.itemCount
if(!isLoading && hasMoreItems(currentSize)){
isLoading = true
loadMoreItems(currentSize)
}
}
}
We introduce a marker interface in order to unify the types LoadItem
and Todo
. This allows us to put both types in a list.
sealed class Item
object LoadItem: Item()
data class Todo(...): Item()
Extend the adapter so that it can show/hide the loading row and insert new data set. Note that we also adapted the type parameters of list and adapter.
const val LOAD_TYPE = -1
data class LoadVH(val root: View): RecyclerView.ViewHolder(root)
// Note that we changed to `MutableList<Item>` and to `RecyclerView.ViewHolder`
class TodoAdapter(val data: MutableList<Item>, val onItemClick: OnItemClick): RecyclerView.Adapter<RecyclerView.ViewHolder>() {
override fun getItemViewType(position: Int): Int {
val item = data[position]
return when(item){
is Todo -> item.type.value
is LoadItem -> LOAD_TYPE
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
// ...
val rootView = LayoutInflater.from(parent.context)
.inflate(layout, parent, false)
if (viewType == LOAD_TYPE){ return LoadVH(rootView) }
return TodoVH(rootView, onItemClick)
}
// ...
fun showLoading(){
data.add(LoadItem)
notifyItemInserted(data.size -1)
}
fun hideLoading(){
data.removeIf{i -> i == LoadItem}
notifyItemRemoved(data.size -1)
}
fun insertData(newData: List<Todo>){
val begin = data.size
val end = begin + newData.size - 1
data.addAll(newData)
notifyItemRangeInserted(begin, end)
}
}
With the Handler
, the network delay is simulated. The implementations for hasMoreItems
and loadMoreItems
look as follows:
class MainActivity : AppCompatActivity() {
private lateinit var scrollListener: InfiniteScrollListener
private lateinit var todoAdapter: TodoAdapter
private val todoRepo = DefaultTodoRepo()
private val PAGE_SIZE = 5
fun loadMoreItems(currentSize: Int){
todoAdapter.showLoading()
// artificial delay
Handler().postDelayed({
val newData = todoRepo.getMany(currentSize, PAGE_SIZE)
todoAdapter.insertData(newData)
scrollListener.isLoading = false
todoAdapter.hideLoading()
}, 4000)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
val initialData = todoRepo.getMany(0, 10).map { it as Item }.toMutableList()
todoAdapter = TodoAdapter(initialData){_, _ -> }
scrollListener = InfiniteScrollListener(
{currentSize -> todoRepo.totalSize() > currentSize},
this::loadMoreItems
)
val recyclerView: RecyclerView = findViewById(R.id.recyclerview)
recyclerView.apply {
adapter = todoAdapter
setHasFixedSize(true)
addOnScrollListener(scrollListener)
}
}
}
Data Binding
It is expected that your are a bit familiar with data binding and MVVM architecture.
Add the following lines in your build.gradle (:app)
:
//...
apply plugin: 'kotlin-kapt'
android {
// ...
dataBinding {
enabled true
}
}
dependencies {
//...
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.2.0"
implementation "androidx.lifecycle:lifecycle-livedata-ktx:2.2.0"
}
Let's move every business logic to the ViewModel. Note that we don't expose the mutable livedata.
class MainViewModel : ViewModel(), LoadMoreListener{
private val PAGE_SIZE = 5
private val todoRepo: TodoRepository = DefaultTodoRepo()
private var _data: MutableLiveData<List<Item>> = MutableLiveData(emptyList())
val data: LiveData<List<Item>> = _data
private var _isLoadingVisible: MutableLiveData<Boolean> = MutableLiveData(false)
val isLoadingVisible: LiveData<Boolean> = _isLoadingVisible
init {
val initialData = todoRepo.getMany(0,10)
_data.value = initialData
}
override fun loadMore(currentSize: Int) {
val isLoading = _isLoadingVisible.value!!
if (!isLoading && todoRepo.totalSize() > currentSize) {
_isLoadingVisible.postValue(true)
// artificial delay
Handler().postDelayed({
val newData = todoRepo.getMany(currentSize, PAGE_SIZE)
val oldData = _data.value ?: emptyList()
_data.postValue(oldData + newData)
_isLoadingVisible.postValue(false)
}, 4000)
}
}
}
We need to refactor the loadMoreItems
from a function type to an interface because the function type causes errors with the binding adapter. Furthermore, we moved the hasMoreItems
check within the loadMore
method (see code snippet above).
interface LoadMoreListener{
fun loadMore(currentSize: Int)
}
class InfiniteScrollListener(
val loadMoreListener: LoadMoreListener
): RecyclerView.OnScrollListener(){
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
super.onScrolled(recyclerView, dx, dy)
val layoutManager = recyclerView.layoutManager as LinearLayoutManager
val hasScrolledDown = dy > 0
if(!hasScrolledDown) { return }
val currentSize = layoutManager.itemCount
loadMoreListener.loadMore(currentSize)
}
}
We slightly adapted the TodoAdapter:
class TodoAdapter(var data: List<Item>): RecyclerView.Adapter<RecyclerView.ViewHolder>() {
//...
fun setLoading(isVisible: Boolean){
if(isVisible) {
data = data + listOf(LoadItem)
notifyItemInserted(data.size - 1)
} else {
data = data.filter { i -> i != LoadItem }
notifyItemRemoved(data.size - 1)
}
}
fun updateData(newData: List<Item>){
data = newData
notifyDataSetChanged()
}
}
Like the extension method allows us to extend an existing class with additional method in Kotlin. Similarly, the binding adapter allows us to define additional attributes for an existing view in XML. Note that the first parameter is the extending view itself. The other parameters are the new attributes. They should match in length with the annotation parameters above.
@BindingAdapter(value = ["data", "loadingVisibility","loadMoreItems"])
fun modifyAdapter(
recyclerView: RecyclerView,
data: LiveData<List<Item>>,
isLoadingVisible: LiveData<Boolean>,
loadMoreItems: LoadMoreListener
){
if (recyclerView.adapter == null){
recyclerView.setHasFixedSize(true)
recyclerView.adapter = TodoAdapter(data.value ?: emptyList())
val scrollListener = InfiniteScrollListener(loadMoreItems)
recyclerView.addOnScrollListener(scrollListener)
}else{
val todoAdapter = recyclerView.adapter as TodoAdapter
val items = data.value ?: emptyList()
todoAdapter.updateData(items)
todoAdapter.setLoading(isLoadingVisible.value ?: false)
}
}
Now we can simply wire up our RecyclerView with our ViewModel. Isn't that cool?
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">
<data>
<variable
name="viewmodel"
type="in.abaddon.demorv.MainViewModel" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<androidx.recyclerview.widget.RecyclerView
...
app:data="@{viewmodel.data}"
app:loadingVisibility="@{viewmodel.isLoadingVisible}"
app:loadMoreItems="@{viewmodel}"
/>
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>
Finally our Activity:
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val activityMainBinding: ActivityMainBinding =
DataBindingUtil.setContentView(this, R.layout.activity_main)
val mainViewModel = ViewModelProvider(this).get(MainViewModel::class.java)
activityMainBinding.viewmodel = mainViewModel
// it's necessary because we use LiveData
activityMainBinding.lifecycleOwner = this
}
}
DiffUtil
In the previous chapter, we adapted the updateDate
method so that notifyDataSetChanged()
is called every time. Since the RecylerView cannot know which items were modified, all the visible items are recreated. Therefore, the notifyDataSetChanged()
call is very expensive.
fun updateData(newData: List<Item>){
data = newData
notifyDataSetChanged() // <-- expensive call
}
That is why Android offers a helper class named DiffUtil
to detect the changes. In order to use the DiffUtil, we need a custom class which extends DiffUtil.Callback
. The areItemsTheSame
is used to check whether two items should represent the same thing. The areContentsTheSame
is used to check if the two items have the same data.
class TodoDiffCallback(val old: List<Item>, val new: List<Item>): DiffUtil.Callback(){
override fun getOldListSize(): Int = old.size
override fun getNewListSize(): Int = new.size
// Compare the identifiers
override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
val oldItem = old[oldItemPosition]
val newItem = new[newItemPosition]
return when {
oldItem is Todo && newItem is Todo -> oldItem.id == newItem.id
oldItem is LoadItem && newItem is LoadItem -> true
else -> false
}
}
// Compare the contents
override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
val oldItem = old[oldItemPosition]
val newItem = new[newItemPosition]
return when {
oldItem is Todo && newItem is Todo -> oldItem == newItem
oldItem is LoadItem && newItem is LoadItem -> true
else -> false
}
}
}
Using TodoDiffCallback, we can compute the diff. Thanks to the diff, only the modified items are updated.
class TodoAdapter(var data: List<Item>): RecyclerView.Adapter<RecyclerView.ViewHolder>() {
// ...
fun updateData(newData: List<Item>){
val diffCallback = TodoDiffCallback(data, newData)
val diffResult = DiffUtil.calculateDiff(diffCallback)
data = newData
diffResult.dispatchUpdatesTo(this)
}
}
References
- RecyclerView Item Click Listener the Right Way by Ngenge Senior
- Android RecyclerView with multiple view type (multiple view holder) by Droid By Me
- How to add Load More / Infinite Scrolling in Android using Kotlin by John Codeos
- Binding adapters by Android
- The powerful tool DiffUtil in RecyclerView by Himanshu Singh
- Cover Image by Annie Spratt from Unsplash
Top comments (0)