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
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:
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
)
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>
}
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"
}
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")
}
}
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)
}
}
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)
}
}
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
}
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
}
}
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
}
}
}
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) }
}
}
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>
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
}
}
}
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() }
)
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)
}
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() }
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
}
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.
Top comments (0)