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 
Enter fullscreen mode Exit fullscreen mode

Table of Contents

Basic Setup

Add the recyclerview to your dependencies in build.gradle (:app):

 dependencies { // ... implementation 'androidx.recyclerview:recyclerview:1.1.0' } 
Enter fullscreen mode Exit fullscreen mode

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 } 
Enter fullscreen mode Exit fullscreen mode

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 } 
Enter fullscreen mode Exit fullscreen mode

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) } } } 
Enter fullscreen mode Exit fullscreen mode

Set LayoutManager declaratively

Instead of explicitly instantiating a LayoutManager in the Activity,

recyclerView.apply { adapter = todoAdapter layoutManager = LinearLayoutManager(context) //<-- Remove this line setHasFixedSize(true) } 
Enter fullscreen mode Exit fullscreen mode

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" ... /> 
Enter fullscreen mode Exit fullscreen mode

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) } 
Enter fullscreen mode Exit fullscreen mode

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) } } 
Enter fullscreen mode Exit fullscreen mode

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) } // ... 
Enter fullscreen mode Exit fullscreen mode

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() } } 
Enter fullscreen mode Exit fullscreen mode

Function Type Approach

Let's first declare our function type:

typealias OnItemClick = (Todo, Int) -> Unit 
Enter fullscreen mode Exit fullscreen mode

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 } } 
Enter fullscreen mode Exit fullscreen mode

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) } // ... 
Enter fullscreen mode Exit fullscreen mode

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() } // ... } } 
Enter fullscreen mode Exit fullscreen mode

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 ) 
Enter fullscreen mode Exit fullscreen mode

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" /> 
Enter fullscreen mode Exit fullscreen mode

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) } // ... } 
Enter fullscreen mode Exit fullscreen mode

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) } } } 
Enter fullscreen mode Exit fullscreen mode

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() 
Enter fullscreen mode Exit fullscreen mode

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) } } 
Enter fullscreen mode Exit fullscreen mode

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) } } } 
Enter fullscreen mode Exit fullscreen mode

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" } 
Enter fullscreen mode Exit fullscreen mode

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) } } } 
Enter fullscreen mode Exit fullscreen mode

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) } } 
Enter fullscreen mode Exit fullscreen mode

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() } } 
Enter fullscreen mode Exit fullscreen mode

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) } } 
Enter fullscreen mode Exit fullscreen mode

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> 
Enter fullscreen mode Exit fullscreen mode

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 } } 
Enter fullscreen mode Exit fullscreen mode

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 } 
Enter fullscreen mode Exit fullscreen mode

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 } } } 
Enter fullscreen mode Exit fullscreen mode

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) } } 
Enter fullscreen mode Exit fullscreen mode

References

Top comments (0)