DEV Community

loading...

Android: Definitive guide to Paging 3

Douglas Fornaro
・7 min read

Overview

Paging 3 is a library from the Android Jetpack that helps you load and display pages of data from a larger amount of data from local or remote data source. This approach allows your app to use efficiently the bandwidth and system resources once the user may not see all the data loaded at once.

Advantages

  • In-memory cache.
  • Makes your RecyclerView's Adapter to automatically request new data when the user scrolls toward the end.
  • Flow and LiveData support.
  • Error handling, refresh and retry capabilities.

Architecture

The Paging library integrates directly into the recommended Android app architecture. The library's components operate in three layers of the app:

  • Repository
  • ViewModel
  • View

https://developer.android.com/topic/libraries/architecture/images/paging3-library-architecture.svg

Repository

In the Repository layer there is the PagingSource. The PagingSource defines a source of data and how to retrieve data from it. Also, can load from remote or local data sources.

In addiction, in the Repository layer there is the RemoteMediator. The RemoteMediator handles paging from a layered data source.

ViewModel

The Pager component provides a public API for constructing instances of PagingData that are exposed to the View, based on a PagingSource and a PagingConfig configuration.

View

In the View layer there is the PagingDataAdapter, a RecyclerView adapter that handle the paginated data.

Implementation

For our implementation of the Paging 3, we are going to use the Github API https://api.github.com/users/google/repos which can be used the parameters page and per_page to handle pagination like this: https://api.github.com/users/google/repos?page=1&per_page=20.

This will be the final result:

Alt Text

To keep it simples we'll model our Repo class as simple as possible to keep all the focus on the Paging library. This will be our Repo class:

data class Repo(
    val fullName: String
)
Enter fullscreen mode Exit fullscreen mode

The GithubApi that will be used to retrieve data is:

interface GithubApi {

    @GET("users/{username}/repos")
    suspend fun fetchRepos(
        @Path("username") username: String,
        @Query("page") page: Int,
        @Query("per_page") size: Int
    ): List<Repo>
}
Enter fullscreen mode Exit fullscreen mode

Setup

Add the following to your app's build.gradle.

dependencies {
    def paging_version = "3.0.0-beta03"

    implementation "androidx.paging:paging-runtime-ktx:$paging_version"
}
Enter fullscreen mode Exit fullscreen mode

Data Source

Let's start building our PagingSource which will load more and more data once the scroll toward the end.

private const val INITIAL_PAGE = 1

class GithubRepoPagingSource(
    private val api: GithubApi,
        private val username: String
) : PagingSource<Int, Repo>() {
    override fun getRefreshKey(state: PagingState<Int, Repo>): Int? {
        TODO("Not yet implemented")
    }

    override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Repo> {
        TODO("Not yet implemented")
    }
}
Enter fullscreen mode Exit fullscreen mode

The Key and Value parameters from PagingSource are the type of key which define what data to load (Int to represent a page) and the type of data loaded by this PagingSource, respectively.

This GithubRepoPagingSource receives a GithubAPI where the data is retrieved and a username to fetch the repos.

The load() function will be called by the Paging to fetch more data to be displayed to the user and can have this implementation:

override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Repo> {
        return try {
            val page = params.key ?: INITIAL_PAGE
            val response = api.fetchRepos(username, page, params.loadSize)
            LoadResult.Page(
                data = response,
                prevKey = if (page == INITIAL_PAGE) null else page - 1,
                nextKey = if (response.isEmpty()) null else page + 1
            )
        } catch (e: Exception) {
            LoadResult.Error(e)
        }
    }
Enter fullscreen mode Exit fullscreen mode

The refresh key is used for subsequent refresh calls to PagingSource.load() (the first call is initial load which uses initialKey provided by Pager). A refresh happens whenever the Paging library wants to load new data to replace the current list, e.g., on swipe to refresh or on invalidation due to database updates, config changes, process death, etc. Typically, subsequent refresh calls will want to restart loading data centered around PagingState.anchorPosition which represents the most recently accessed index.

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

Repository

Now that we already implemented our data source, let's build the Repository.

class GithubRepository(
    private val api: GithubApi
) {
    fun searchRepos(username: String) = Pager(
        pagingSourceFactory = { GithubRepoPagingSource(api, username) },
        config = PagingConfig(
            pageSize = 20
        )
    ).flow
}
Enter fullscreen mode Exit fullscreen mode

Here we have a searchRepos method that returns a Flow<PagingData<Repo>>. In the Pager object we define the GithubRepoPagingSource created previously and a PagingConfig with the pageSize parameter.

ViewModel

In the ViewModel we are going to expose the Flow<PagingData<Repo>>. Here we are avoiding to request the same username if it was previously requested. Also, we are caching the content of the Flow<PagingData>>.

class GithubViewModel(
    private val repository: GithubRepository
) : ViewModel() {

    private var currentUsernameValue: String? = null

    private var currentSearchResult: Flow<PagingData<Repo>>? = null

    fun searchRepos(username: String): Flow<PagingData<Repo>> {
        val lastResult = currentSearchResult
        if (username == currentUsernameValue && lastResult != null) {
            return lastResult
        }
        currentUsernameValue = username
        val newResult = repository.searchRepos(username)
            .cachedIn(viewModelScope)
        currentSearchResult = newResult
        return newResult
    }
}
Enter fullscreen mode Exit fullscreen mode

Adapter

Now we are in the View component, let's start with the Adapter and make it works with Paging.

class ReposAdapter : PagingDataAdapter<Repo, ReposAdapter.ViewHolder>(COMPARATOR) {

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = ViewHolder(
        ItemReposBinding.inflate(LayoutInflater.from(parent.context), parent, false)
    )

    override fun onBindViewHolder(holder: ViewHolder, position: Int) {
        getItem(position)?.let { holder.bind(it) }
    }

    class ViewHolder(
        private val binding: ItemReposBinding
    ) : RecyclerView.ViewHolder(binding.root) {
        fun bind(repo: Repo) = with(binding) {
            tvItemRepos.text = repo.fullName
        }
    }

    companion object {
        private val COMPARATOR = object : DiffUtil.ItemCallback<Repo>() {
            override fun areItemsTheSame(oldItem: Repo, newItem: Repo): Boolean =
                oldItem.fullName == newItem.fullName

            override fun areContentsTheSame(oldItem: Repo, newItem: Repo): Boolean =
                oldItem == newItem
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The implementation of the Adapter is pretty much the same as a usual RecyclerView.Adater, however now we need to extend from PagingDataAdapter and pass a COMPARATOR to its constructor.

Activity/Fragment

We need to launch a new coroutine to search for the repos. We'll do that in the lifecyclerScope.

We also want to ensure that whenever the user searches for a new username, the previous query is cancelled. To do this, our Activity/Fragment can hold a reference to a new Job that will be cancelled every time we search for a new username.

private var searchJob: Job? = null

private fun search(username: String) {
   // Make sure we cancel the previous job before creating a new one
   searchJob?.cancel()
   searchJob = lifecycleScope.launch {
       viewModel.searchRepos(username).collect { adapter.submitData(it) }
   }
}
Enter fullscreen mode Exit fullscreen mode

At this point your app is ready and working as expected, however there are more customization that we could do, let's continue and see them!

Display the loading and error state in the footer

For a better experience, we can display a loading state when the list is fetching new data or display an error when something wrong happens.

For this purpose we need to create a new xml file, a new Adapter and ViewHolder, let's see them below:

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

    <TextView
        android:id="@+id/error_msg"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="center"
        android:textAlignment="center"
        android:textSize="22sp"
        tools:text="Timeout" />

    <ProgressBar
        android:id="@+id/progress_bar"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_gravity="center" />

    <Button
        android:id="@+id/retry_button"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="center"
        android:text="@string/retry_button" />
</LinearLayout>
Enter fullscreen mode Exit fullscreen mode
class ReposLoadStateAdapter(
    private val retry: () -> Unit
) : LoadStateAdapter<ReposLoadStateAdapter.ViewHolder>() {

    override fun onBindViewHolder(holder: ViewHolder, loadState: LoadState) = holder.bind(loadState)

    override fun onCreateViewHolder(parent: ViewGroup, loadState: LoadState) = ViewHolder(
        ItemReposLoadStateFooterBinding.inflate(LayoutInflater.from(parent.context), parent, false),
        retry
    )

    class ViewHolder(
            private val binding: ItemReposLoadStateFooterBinding,
        retry: () -> Unit
    ) : RecyclerView.ViewHolder(binding.root) {

        init {
            binding.retryButton.setOnClickListener { retry() }
        }

        fun bind(loadState: LoadState) = with(binding) {
                if (loadState is LoadState.Error) {
                errorMsg.text = loadState.error.localizedMessage
            }
            progressBar.isVisible = loadState is LoadState.Loading
            retryButton.isVisible = loadState is LoadState.Error
            errorMsg.isVisible = loadState is LoadState.Error
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Now that we have our load state adapter done, we need to link to our list.

binding.recyclerView.adapter = adapter.withLoadStateHeaderAndFooter(
        header = ReposLoadStateAdapter { adapter.retry() },
        footer = ReposLoadStateAdapter { adapter.retry() }
)
Enter fullscreen mode Exit fullscreen mode

Empty state

What if we try to load some data but there is no data (list is 0)?

For this purpose we need to be notified when the load state is changed, we'll use tee addLoadStateListener.

adapter.addLoadStateListener { loadState ->
        val isEmptyList = loadState.refresh is LoadState.NotLoading && adapter.itemCount == 0
        showEmptyList(isEmptyList)
}
Enter fullscreen mode Exit fullscreen mode

Load and error state for the Activity/Fragment

For the first request, until now there is no feedback what is happening in the screen, also if happens some problem we won't know because there is no error handling.

Update your Activity/Fragment and add a progress bar and a retry button. Don't forget to set the retry click:

binding.retryButton.setOnClickListener { adapter.retry() }
Enter fullscreen mode Exit fullscreen mode

Let's update the previous addLoadStateListener and update the visibility of the progress bar and retry button.

adapter.addLoadStateListener { loadState ->
        val isEmptyList = loadState.refresh is LoadState.NotLoading && adapter.itemCount == 0
        showEmptyList(isEmptyList)

        // Only show the list if refresh succeeds
        binding.recyclerView.isVisible = loadState.source.refresh is LoadState.NotLoading
        // Show loading spinner during initial load or refresh
        binding.progressBar.isVisible = loadState.source.refresh is LoadState.Loading
        // Show the retry state if initial load or refresh fails
        binding.retryButton.isVisible = loadState.source.refresh is LoadState.Error
}
Enter fullscreen mode Exit fullscreen mode

This is everything we need to start using Paging 3 in our app!

Conclusion

Paging 3 is a pagination library for Android that is build into the recommended Android app architecture. It uses Flow or LiveData for the communication between the layers.

Also, it handles the load, error and empty state in a simple way, giving us more flexibility for that.

Discussion (0)