Pagination with Paging 3 in Android Example

The Paging 3 library helps you to load and display data from a larger dataset from local storage or from API. This will helps your app to use both network and system memory more efficiently. Paging 3 library is designed to fit into the recommended Android app architecture, integrate cleanly with other Jetpack components, and provide first-class Kotlin support.

Official Documentation: Paging library overview  |  Android Developers

Benefits of using the Paging 3 library

  • Provides in-memory caching of the paged data that assures the systematic use of device resources.
  • Prevents duplication of the API request, ensuring that your app uses network bandwidth and system resources efficiently.
  • Configurable RecyclerView adapters automatically request data as the user scrolls toward the end of the loaded data.
  • Finest support for Kotlin coroutines and Flow, as well as LiveData and RxJava.
  • Built-in functionality to add loading state headers, footers, and list separators.
  • Built-in support for error handling, including refresh and retry capabilities.

Paging Library Components

To implement paging functionality in our android app, we will use the following Paging library components:

PagingSource

PagingSource object defines a source of data and how to retrieve data from that source. A PagingSource object can load data from any single source, including network sources and local databases.

PagingSource takes two parameters, a key, and a value. The Value parameter is the type of data that will be loaded and the Key defines what data to load.

To create a pagingSource, need to create a DataSource class and extend the PagingSource<K, V> with key and value parameters. Also, an override of the load() function should be implemented to retrieve data from the data source. load() is a suspend function, so you can call other suspend functions here like a network call.

Also, override the getRefreshKey() The refresh key is used for subsequent refresh calls to PagingSource.load().

class PagingSource(): PagingSource<Int, Movie>() {
    
    override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Movie> {
       return try {
           LoadResult.Page()
        } catch (e: Exception) {
            LoadResult.Error(e)
        }
    }

    override fun getRefreshKey(state: PagingState<Int, Movie>): Int? {
        return null
    }

}
  • If the load() request is successful, return the character list wrapped in a LoadResult.Page object and add the previous and next keys if they are available.
  • If the request fails, return the exception wrapped in a LoadResult.Error an object containing info about the exception.

Pager

A pager is a container for paginated data. Three parameters are needed to create an instance. These are:

  • PagingSource is our data source class created by extending PagingSource<k, V>.
  • PagingConfig defines how to get data from the PagingSource like page size, prefetch distance, etc.
  • InitialKey is optional to start loading with a default key.
Pager(config = PagingConfig(
                pageSize = NETWORK_PAGE_SIZE,
                enablePlaceholders = false,
                initialLoadSize = 2
            ),
            pagingSourceFactory = {
                PagingSource()
            }
        , initialKey = 1
        ).liveData

PagingDataAdapter

a RecyclerView.Adapter that presents PagingData in a RecyclerView. The PagingDataAdapter listens to internal PagingData loading events as pages are loaded and uses DiffUtil on a background thread to compute fine-grained updates as updated content is received in the form of new PagingData objects.

class PagerAdapter: PagingDataAdapter<Movie, RecyclerView.ViewHolder>(Comparator) {
    
    

    override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
        
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {

    }

    object Comparator: DiffUtil.ItemCallback<Movie>() {
        override fun areItemsTheSame(oldItem: Movie, newItem: Movie): Boolean {
            // Id is unique.
        }

        override fun areContentsTheSame(oldItem: Movie, newItem: Movie): Boolean {
        
        }
    }
}

Implementing Paging 3 in your app

Setup the dependencies

Add the following dependencies to your app-level build.gradle file to import Paging 3 components.

dependencies {
  implementation "androidx.paging:paging-runtime:3.1.1"
}

and we will also add the support for Retrofit and Gson as we are going to use Gson as a convertor factory for Retrofit.

To learn more about retrofit, check the Retrofit android example in kotlin

implementation "com.squareup.retrofit2:retrofit:2.9.0"
implementation "com.squareup.okhttp3:okhttp:4.9.0"
implementation "com.squareup.okhttp3:logging-interceptor:4.7.2"
implementation "com.squareup.retrofit2:converter-gson:2.9.0"
//Coroutine
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.2"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.4.2"
implementation 'com.google.code.gson:gson:2.8.6'

Create API service

I used the endpoint for getting a list of top-rated movies from https://api.themoviedb.org. To work with themoviedb.org API you need to login and generate api_key to use.

API:

https://api.themoviedb.org/3/movie/top_rated

Response:

{
  "page": 5,
  "results": [
    {
      "adult": false,
      "backdrop_path": "/h31SOVlekuHXsMWVGxI8nPPfY82.jpg",
      "genre_ids": [
        18,
        37
      ],
      "id": 335,
      "original_language": "it",
      "original_title": "C'era una volta il West",
      "overview": "As the railroad builders advance unstoppably through the Arizona desert on their way to the sea, Jill arrives in the small town of Flagstone with the intention of starting a new life.",
      "popularity": 40.158,
      "poster_path": "/qbYgqOczabWNn2XKwgMtVrntD6P.jpg",
      "release_date": "1968-12-21",
      "title": "Once Upon a Time in the West",
      "video": false,
      "vote_average": 8.3,
      "vote_count": 3352
    },
    {
      "adult": false,
      "backdrop_path": "/jLq0ol1f0ZKXni9R9GsPBcyPrNN.jpg",
      "genre_ids": [
        18
      ],
      "id": 18148,
      "original_language": "ja",
      "original_title": "東京物語",
      "overview": "The elderly Shukishi and his wife, Tomi, take the long journey from their small seaside village to visit their adult children in Tokyo. Their elder son, Koichi, a doctor, and their daughter, Shige, a hairdresser, don't have much time to spend with their aged parents, and so it falls to Noriko, the widow of their younger son who was killed in the war, to keep her in-laws company.",
      "popularity": 14.085,
      "poster_path": "/g2YbTYKpY7N2yDSk7BfXZ18I5QV.jpg",
      "release_date": "1953-11-03",
      "title": "Tokyo Story",
      "video": false,
      "vote_average": 8.3,
      "vote_count": 747
    }
  ],
  "total_pages": 495,
  "total_results": 9883
}

and we create the corresponding data classes for the above JSON response. Our Movie data class looks like this,

data class MovieResponse(val page: Int, val results: List<Movie>)

data class Movie(val original_title: String, val poster_path: String, val overview: String)

Let’s create a RetrofitService interface with the getTopRatedMovies function which takes a query parameter that will take the api_key and page number from which we have to fetch the data.

interface RetrofitService {

    @GET("movie/top_rated")
    suspend fun getTopRatedMovies(
        @Query("api_key") api_key: String,
        @Query("language") language: String,
        @Query("page") page: Int
    ): Response<MovieResponse>

    companion object {
        var retrofitService: RetrofitService? = null
        fun getInstance() : RetrofitService {
            if (retrofitService == null) {
                val retrofit = Retrofit.Builder()
                    .baseUrl("https://api.themoviedb.org/3/")
                    .addConverterFactory(GsonConverterFactory.create())
                    .build()
                retrofitService = retrofit.create(RetrofitService::class.java)
            }
            return retrofitService!!
        }

    }
}

Implement the PagingSource

MoviePagingSource takes a primary constructor parameter RetrofitService. MoviePagingSource acts here as a repository and the load function gets the data from the API.

In the MoviePagingSource, we take two parameters one of integer type and the other of the data type we have to load on the list item. The integer parameter represents the page number here. Since the load function is a suspend function, we can call other suspend functions inside it without any issues which we created in RetrofitService.

Now, we update the MovieDataSource like,

class MoviePagingSource(private val apiService: RetrofitService): PagingSource<Int, Movie>() {


    override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Movie> {

        return try {
            val position = params.key ?: 1
            val response = apiService.getTopRatedMovies("e8d648003bd11b5c498674fbd4905525","en-US",position)
            LoadResult.Page(data = response.body()!!.results, prevKey = if (position == 1) null else position - 1,
                nextKey = position + 1)
        } catch (e: Exception) {
            LoadResult.Error(e)
        }

    }

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

}

Here, we get the page number from params and assign it to the next page variable using param.key and if it returns null, we set a default value of 1. We also do the API call and get assign the response to the response variable using RetrofitService which we passed as a constructor parameter to MoviePagingSource class.

If the getTopRatedMovies() request is successful, return the character list wrapped in a LoadResult.Page object and add the previous and next keys if they are available.
If the request fails, return the exception wrapped in a LoadResult.Error object containing info about the exception.

Build and configure PagingData

Pager is used to configure the behavior of Paging and how it should load its data. We are passing the PagingConfig with the pageSize.

class MainRepository constructor(private val retrofitService: RetrofitService) {

    fun getAllMovies(): LiveData<PagingData<Movie>> {

        return Pager(
            config = PagingConfig(
                pageSize = NETWORK_PAGE_SIZE,
                enablePlaceholders = false,
                initialLoadSize = 2
            ),
            pagingSourceFactory = {
                MoviePagingSource(retrofitService)
            }
        , initialKey = 1
        ).liveData
    }

}

initialLoadSize specifics the number of items loaded at once from the MovieDataSource. Recommended is to always keep the page size to more than the visible items on the screen.

And lastly, we will convert the data as live data by adding .liveData

Request your data in the ViewModel

now we just need to call our getMovieList() method in our ViewModel. cachedIn() is used to persist the data beyond configuration changes. The best place to do this is a ViewModel, using the viewModelScope.

class MainViewModel constructor(private val mainRepository: MainRepository) : ViewModel() {

    val errorMessage = MutableLiveData<String>()

    fun getMovieList(): LiveData<PagingData<Movie>> {
        return mainRepository.getAllMovies().cachedIn(viewModelScope)
    }

}

Creating PagingDataAdapter

Normally, RecyclerView uses RecyclerView.Adapter or ListAdapter but for Paging 3 we use PagingDataAdapter but the behavior is like a normal adapter.

PagingDataAdapter takes a DiffUtil callback, as a parameter to its primary constructor which helps the PagingDataAdapter to update the items if they are changed or updated. And DiffUtil callback is used because they are more performant.

class MoviePagerAdapter: PagingDataAdapter<Movie, MoviePagerAdapter.MovieViewHolder>(MovieComparator) {

    override fun onBindViewHolder(holder: MovieViewHolder, position: Int) {
        val movie = getItem(position)!!
        holder.view.name.text = movie.original_title
        Glide.with(holder.itemView.context).load("https://image.tmdb.org/t/p/w300"+movie.poster_path).into(holder.view.imageview)
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MovieViewHolder {
        val inflater = LayoutInflater.from(parent.context)
        val binding = AdapterMovieBinding.inflate(inflater, parent, false)
        return MovieViewHolder(binding)
    }

    class MovieViewHolder(val view: AdapterMovieBinding): RecyclerView.ViewHolder(view.root) {

    }

    object MovieComparator: DiffUtil.ItemCallback<Movie>() {
        override fun areItemsTheSame(oldItem: Movie, newItem: Movie): Boolean {
            // Id is unique.
            return oldItem.original_title == newItem.original_title
        }

        override fun areContentsTheSame(oldItem: Movie, newItem: Movie): Boolean {
            return oldItem == newItem
        }
    }
}

Setup RecyclerView to the PagingData

Finally, we need to get the data from the PagingDataAdapter and set the data into the recyclerview. We will collect the data from the getMovieList() function inside the lifecycleScope and update the data we fetched from the API and display it in the UI.

Check More About Recyclerview: Android Recyclerview Search Filter Example – Howtodoandroid

class MainActivity : AppCompatActivity() {
    lateinit var viewModel: MainViewModel
    private val adapter = MoviePagerAdapter()
    lateinit var binding: ActivityMainBinding

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)
        val retrofitService = RetrofitService.getInstance()
        val mainRepository = MainRepository(retrofitService)
        binding.recyclerview.adapter = adapter

        viewModel = ViewModelProvider(
            this,
            MyViewModelFactory(mainRepository)
        ).get(MainViewModel::class.java)

        viewModel.errorMessage.observe(this) {
            Toast.makeText(this, it, Toast.LENGTH_SHORT).show()
        }
        
        lifecycleScope.launch {
            viewModel.getMovieList().observe(this@MainActivity) {
                it?.let {
                    adapter.submitData(lifecycle, it)
                }
            }
        }


    }

}

Getting the States of the Paging data

Now, while loading the data we might also want to show the progress view, and when we have loaded the data we should hide the progress view. We can use errors inside the addLoadStateListener if all the state methods like refresh, append, and prepend are instances of LoadState.Error.

adapter.addLoadStateListener { loadState ->
            // show empty list
            if (loadState.refresh is LoadState.Loading ||
                loadState.append is LoadState.Loading)
                binding.progressDialog.isVisible = true
            else {
                binding.progressDialog.isVisible = false
                // If we have an error, show a toast
                val errorState = when {
                    loadState.append is LoadState.Error -> loadState.append as LoadState.Error
                    loadState.prepend is LoadState.Error ->  loadState.prepend as LoadState.Error
                    loadState.refresh is LoadState.Error -> loadState.refresh as LoadState.Error
                    else -> null
                }
                errorState?.let {
                    Toast.makeText(this, it.error.toString(), Toast.LENGTH_LONG).show()
                }

            }
        }

Conclusion

I liked the paging 3 libraries because it’s a very fast way to create paging for the list of items. Thanks for reading. Please try this and let me know your feedback.

You can download this sample from GITHUB.

Leave a Reply

Your email address will not be published.