The Paging 3 library, a part the new set of Android Jetpack libraries, provides a robust way of paginating large sets of data in Android whether it is loaded from a room database or from a network layer.

The library provides 3 different utilities for loading paginated data:

  • Solely from a local Room DB.
  • Solely from a webservice.
  • From a webservice while storing it in a Room Database for offline use (combination of the above).

In this tutorial, we will focus on implementing pagination only for a local Room Databases using Paging 3.

If you are interested in just the code for the project, please check out this GitHub repository.

Prerequisites

What is pagination?

Pagination is a way of breaking down huge pieces of data into smaller chunks and serving them one at a time. This improves the user experience as the entirety of the data doesn’t need to be loaded all at once. As soon as the user scrolls to the bottom of the screen, the next page of data is loaded.

What are we building?

Throughout this tutorial, we will implement pagination for a large dataset of texts stored in a Room Database in an Android app.

We will build a basic application which shows a list of items in a RecyclerView using Paging 3. The data is loaded with a delay of 1 second to simulate data being loaded (for demo purpose only).

There will be a progress bar at the end of the RecyclerView.

Here’s a short demo of the app:

Expected knowledge

This tutorial expects you to have intermediate level knowledge in Android app development. You should have a basic understanding of the concepts of Room Databases, RecyclerView and how to use them although I will explain what’s going on in brief.

You are not expected to have any knowledge of Paging 3!

Setup

We will be using an Empty Activity as a starter project. If you don’t know how to create a new Android studio project, do read this article.

The package name I have for my app is com.example.paginationdemo.

Next, we need some dependencies in our app to use the libraries we need. Open app/build.gradle file and add these lines to the dependencies and plugins block:

plugins {
    // ...other plugins
    id 'kotlin-kapt'
}

dependencies {
    // ...other dependencies

    implementation "androidx.paging:paging-runtime:3.1.1"

    implementation "androidx.core:core-ktx:1.7.0"
    implementation 'androidx.activity:activity-ktx:1.4.0'

    implementation "androidx.room:room-ktx:2.4.2"
    kapt "androidx.room:room-compiler:2.4.2"
}

Also enable viewBinding inside app/build.gradle to make accessing views easier:

android {
    // ...other config

    viewBinding {
        enabled = true
    }
}

With all the prerequisites clear, let’s jump into the tutorial now!

1. Creating the Room Database

Needed Files

The first step to begin would be to create a Room Database that will hold the data we will be using.

To do this, first create a db package, to make things organized. Create two files in the db package:

  • ItemDatabase.kt: This will be the actual database implementation.
  • ItemDao.kt: This is the Data Access Object(DAO) that will be used to access the data in the database.

Then we make the actual Item.kt data holder class inside a new package called model.

We will implement these files in a short moment. These are the files we just added:

Files to be added to the project

Class Implementations

Item.kt

package com.example.dbpagingdemo.model

import ...

@Entity(tableName = "items")
data class Item(
    @PrimaryKey(autoGenerate = true)
    val id: Int = 0,
    val name: String
)

This is a pretty basic data class. There is an autogenerated primary key id that identifies different items in the db. The name field is the string data for a specific item.

ItemDao.kt

package com.example.dbpagingdemo.db

import ...

@Dao
interface ItemDao {
    @Query("SELECT * FROM items ORDER BY id ASC LIMIT :limit OFFSET :offset")
    suspend fun getPagedList(limit: Int, offset: Int): List<Item>

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insert(item: Item): Long
}

The ItemDao is an abstract class whose implementation will be generated by Room.

This class contains the functions needed to run queries in the database. Room provides useful annotations to ease the process of writing these functions.

The getPagedList() function returns a list of items after filtering it with the limit and offset parameters. This returns small chunks of data that will later be loaded by the Paging 3 library to be shown in the UI.

The insert() function inserts an item into the database.

ItemDatabase.kt

package com.example.dbpagingdemo.db

import ...

@Database(entities = [Item::class], version = 1, exportSchema = false)
abstract class ItemDatabase : RoomDatabase() {
    abstract fun itemDao(): ItemDao

    companion object {
        private var INSTANCE: ItemDatabase? = null

        fun getInstance(context: Context): ItemDatabase {
            return INSTANCE ?: synchronized(this) {
                val instance = Room.databaseBuilder(
                    context,
                    ItemDatabase::class.java,
                    "item_database"
                ).addCallback(object : RoomDatabase.Callback() {
                    override fun onCreate(db: SupportSQLiteDatabase) {
                        super.onCreate(db)

                        INSTANCE?.let { database ->
                            CoroutineScope(Dispatchers.IO).launch {
                                (0..100).forEach {
                                    database.itemDao().insert( Item(0, "Item $it") )
                                }
                            }
                        }
                    }
                }).build()

                INSTANCE = instance
                instance
            }
        }
    }
}

Here, we make the ItemDatabase abstract because Room will auto-generate it for us. Inside the class’s companion object block, we define a function that returns the instance of the database.

The getInstance() function is interesting! We put up this INSTANCE returning logic to avoid race conditions. Basically, if two pieces of code try to access the same resource at the same time, it causes race conditions. For generalized objects it is okay, but for something like Databases- we never want to cause conflicts between data.

That’s the reason we either return INSTANCE of the Room Database if it is not null (the ?: elvis operator), or if it is we then add the logic to build the database, assign it to the instance and then return it.

We add a callback of type RoomDatabase.Callback() to execute some code once the database is created. We use this to pre-populate the database with some fake preloaded data.

The (0..100).forEach() loop goes over the range and adds Item objects to the database, with the name Item <number> for each iteration. The id passed is 0 because this will be autogenerated by Room anyways.

2. Setup RecyclerView

Defining Layouts

For the RecyclerView, we need to update our MainActivity to show a RecyclerView instead of the auto-generated text view.

activity_main.xml

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical">

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/recyclerView"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" />

</LinearLayout>

This is a basic layout - we use LinearLayout to make things easier as we don’t need a complex UI for this demo anyways.

We add a full screen RecyclerView to the layout. The grid manager is set to LinearLayoutManager to stack the RecyclerView items in a linear direction (in our case, vertical).

item.xml

We need to generate a new layout that serves as a container for the RecyclerView items. Make a minimal item.xml layout which just shows a text view. Again, use LinearLayout as it doesn’t need to be complex. Don’t make the LinearLayout full screen as that can cause a lot of whitespace.

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:layout_margin="8dp"
    android:orientation="vertical">

    <TextView
        android:id="@+id/textView"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:textSize="18sp" />

</LinearLayout>

Making a RecyclerView Adapter

We need a RecyclerView adapter to show the data in the RecyclerView. Create a MainAdapter.kt class that inherits from PagingDataAdapter. It is a special adapter provided by the Paging 3 library that will help us to show the data in the RecyclerView. It also supports header and footer items in RecyclerView that we will use in a moment.

Code the MainAdapter like this:

package com.example.dbpagingdemo

import ...

class MainAdapter : PagingDataAdapter<Item, MainAdapter.MainViewHolder>(DIFF_CALLBACK) {

    companion object {
        val DIFF_CALLBACK = object : DiffUtil.ItemCallback<Item>() {
            override fun areItemsTheSame(oldItem: Item, newItem: Item): Boolean =
                oldItem.id == newItem.id

            override fun areContentsTheSame(oldItem: Item, newItem: Item): Boolean =
                oldItem == newItem
        }
    }

    inner class MainViewHolder(val binding: ItemBinding) : RecyclerView.ViewHolder(binding.root)

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MainViewHolder {
        return MainViewHolder(
            ItemBinding.inflate(LayoutInflater.from(parent.context), parent, false)
        )
    }

    override fun onBindViewHolder(holder: MainViewHolder, position: Int) {
        val item = getItem(position)

        holder.binding.apply {
            textView.text = item?.name
        }
    }
}

To the superclass PagingDataAdapter, we pass in the Item data class as that is the type of data we want to show in the RecyclerView and MainViewHolder as the type of view holder we want to use.

The DIFF_CALLBACK is used by the Paging library to determine if the data in the RecyclerView has changed or not.

This callback uses item comparisons to determine data changes and apply changes to RecyclerView only for the changed items. This is a very efficient way to update the RecyclerView as compared to rebuilding the RecyclerView every time the data changes.

We instantiate and bind data to the view holder in the onCreateViewHolder() and onBindViewHolder() functions respectively.

That’s it for the RecyclerView adapter!

3. Build the Pagination Logic

Setting up PagingSource

The PagingSource defines the actual implementation for getting the data from the Room Database and returning loaded data pages, which would be sent to the RecyclerView adapter.

Make a package pagination and define a MainPagingSource.kt class like this:

MainPagingSource.kt

package com.example.dbpagingdemo.pagination

class MainPagingSource(
    private val dao: ItemDao
) : PagingSource<Int, Item>() {
    override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Item> {
        val page = params.key ?: 0

        return try {
            val entities = dao.getPagedList(params.loadSize, page * params.loadSize)

            // simulate page loading
            if (page != 0) delay(1000)

            LoadResult.Page(
                data = entities,
                prevKey = if (page == 0) null else page - 1,
                nextKey = if (entities.isEmpty()) null else page + 1
            )
        } catch (e: Exception) {
            LoadResult.Error(e)
        }
    }

    override fun getRefreshKey(state: PagingState<Int, Item>): Int? {
        return state.anchorPosition?.let { anchorPosition ->
            val anchorPage = state.closestPageToPosition(anchorPosition)
            anchorPage?.prevKey?.plus(1) ?: anchorPage?.nextKey?.minus(1)
        }
    }
}

The class inherits from PagingSource:

  • We pass an Int as the first parameter since the pages are differentiated based on pages (1st Page, 2nd Page, and so on).
  • We pass an Item as the second parameter since the data we are handling here is of type Item.

Each class that inherits from PagingSource needs to override load() and getRefreshKey() functions.

  • The load() function gets details about the page we need to load, loads it, and returns the page with the needed meta-data. It also handles errors that might occur.
  • The getRefreshKey() method of the PagingSource class is used to get the key of the page that will be passed into the params for load() function. This is calculated on subsequent refreshes/invalidation of the data after the initial load.

The load() function calls getPagedList() from the dao by passing in values of limit and offset from the params. We will define these parameters in MainViewModel while accessing data.

If the page load was successful, we return LoadResult.Page() with the data and meta information for the next and preivious pages if the db call was successful, else we return LoadResult.Error with the exception that occurred.

This is basically it for the PagingSource!

Setting up LoadStateAdapter

The LoadStateAdapter is used to show the loading state of the RecyclerView. Suppose a user scrolls to the bottom of the screen and the next pagination data isn’t loaded yet. If there’s no visual indication for this, it creates confusion for the user.

LoadStateAdapter helps us to show the visual info on the loading state of the RecyclerView. In our case, we will show a progress bar while the next page is loading.

To do that, create a load_state_view.xml layout with a progress bar like this:

load_state_view.xml

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout 
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:gravity="center"
    android:orientation="vertical">

    <ProgressBar
        android:id="@+id/progress"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content" />

</LinearLayout>

Next, create a MainLoadStateAdapter.kt class inside the pagination package.

MainLoadStateAdapter.kt

package com.example.dbpagingdemo

import ...

class MainLoadStateAdapter : LoadStateAdapter<MainLoadStateAdapter.LoadStateViewHolder>() {

    inner class LoadStateViewHolder(val binding: LoadStateViewBinding) :
        RecyclerView.ViewHolder(binding.root)

    override fun onCreateViewHolder(parent: ViewGroup, loadState: LoadState): LoadStateViewHolder {
        return LoadStateViewHolder(
            LoadStateViewBinding.inflate(LayoutInflater.from(parent.context), parent, false)
        )
    }

    override fun onBindViewHolder(holder: LoadStateViewHolder, loadState: LoadState) {
        holder.binding.apply {
            progress.isVisible = loadState is LoadState.Loading
        }
    }
}

We basically bind the progress bar from load_state_view.xml to the LoadStateViewHolder class. The key thing to notice here is the progress bar is visible only if loadState is LoadState.Loading.

The onBindViewHolder() inside MainLoadStateAdapter is called every time the RecyclerView is invalidated, which provides the possibility to show and hide the progress bar based on the load state.

4. Attaching Data to the UI

Setting up the ViewModel

The MainViewModel should access the data from the PagingSource, and pass it to the RecyclerView adapter.

Let’s build the MainViewModel class.

MainViewModel.kt

package com.example.paginationdemo

import ...

class MainViewModel(
    private val dao: ItemDao
): ViewModel() {

    val data = Pager(
        PagingConfig(
            pageSize = 20,
            enablePlaceholders = false,
            initialLoadSize = 20
        ),
    ) {
        MainPagingSource(dao)
    }.flow.cachedIn(viewModelScope)
    
}

class MainViewModelFactory(
    private val dao: ItemDao
) : ViewModelProvider.Factory {
    override fun <T : ViewModel> create(modelClass: Class<T>): T {
        if(modelClass.isAssignableFrom(MainViewModel::class.java)) {
            @Suppress("UNCHECKED_CAST")
            return MainViewModel(dao) as T
        }
        throw IllegalArgumentException("Unknown ViewModel class")
    }
}

Inside the MainViewModel, we hold to create a variable data that is a Pager object, to which we pass the PagingConfig and PagingSource objects. This is the place where we define our pagination configuration.

We define that the pageSize should be 20. This means that the Paging 3 library will load 20 values at a time from the data. The initialLoadSize is also set to 20 because if unset, the paging library loads 3 * pageSize for the first load.

We pass a lambda that defines a lambda and pass in our MainPagingSource class. Paging 3 handles calling the load() function for us as and when needed by the PagingAdapter, so we don’t need to worry about the hassle of calling it manually.

Then we access the flow property so that we can collect the new emissions of data. We also cache these in viewModelScope for memory optimizations.

Finishing up MainActivity

We now need to bind everything up through the MainActivity. Setup the MainActivity as below:

package com.example.paginationdemo

import ...

class MainActivity : AppCompatActivity() {

    private lateinit var binding: ActivityMainBinding
    private lateinit var dao: ItemDao
    private val viewModel: MainViewModel by viewModels { MainViewModelFactory(dao) }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        dao = ItemDatabase.getInstance(this).itemDao()

        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)

        val adapter = MainAdapter()
        binding.recyclerView.adapter = adapter.withLoadStateFooter(
            MainLoadStateAdapter()
        )

        lifecycleScope.launch {
            viewModel.data.collectLatest {
                adapter.submitData(it)
            }
        }
    }
}

We define the binding, dao and viewModel variables and instantiate the needed ones in onCreate().

Then the magic happens! We assign the MainAdapter we built and also pass in the MainLoadStateAdapter() in the withLoadStateFooter() function. This appropriately handles showing the progress bar while the next page is loading, and also hides it with animation when the next page is loaded / no next page is available.

We also launch a coroutine to collect the emissions of data from the flow property of the viewModel and pass it to the adapter. This way our adapter will be updated with the new data as and when it is emitted.

It’s time to run our application! You might not see data at the first run because the database itself is being created. But from the second run, the app should work as expected:

Demo of the application we will build

Wrapping Up

This was a brief intro on how to paginate data from a Room Database using the Paging 3 library. In this tutorial, we covered the setup for situations when we just have a local data source to get the data from.

The Paging 3 library supports more use cases like loading the paginated data from a network, or a combination of network and local data sources. You can learn more about the library from the documentation.

I hope you enjoyed reading this in-depth tutorial and hope you learned something new!

Happy Coding!


Join discussions at our discord server >
Did you learn something new today? Spread the word!

Contribute to the Genics Blog!

Genics Blog is a purely open source publication. Authors at Genics post highly resourceful content on varied topics relevant to the developer community. Join us and start publishing today!

Contribute

Related content