DEV Community

Cover image for 6 Things You Might Not Know About RecyclerView
Arooran Thanabalasingam
Arooran Thanabalasingam

Posted on

6 Things You Might Not Know About RecyclerView

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

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)
    }
    // ...
}

Doesn't it look beautiful?
Screenshot

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

Oldest comments (0)