Android Paging

What you'll learn

  • What the main components of Paging 3.0 are.
  • How to add Paging 3.0 to your project.
  • How to add a header or footer to your list using the Paging 3.0 API.
  • How to add list separators using the Paging 3.0 API.

What you'll build

In this codelab, you start with a sample app that already displays a list of GitHub repositories. Whenever the user scrolls to the end of the displayed list, a new network request is triggered and its result is displayed on the screen.

You will add code through a series of steps, to achieve the following:

  • Migrate to the Paging library components.
  • Add a loading status header and footer to your list.
  • Show loading progress between every new repository search.
  • Add separators in your list.

Here's what your app will look like in the end:

What you'll need

In this step, you will download the code for the entire codelab and then run a simple example app.

To get you started as quickly as possible, we have prepared a starter project for you to build on.

If you have git installed, you can simply run the command below. (You can check by typing git --version in the terminal / command line and verify it executes correctly.)

 git clone https://github.com/googlecodelabs/android-paging

The initial state is on the master branch. For certain steps you can see the solution as follows:

  • Branch step5-9_paging_3.0 - you'll find the solution for steps 5 - 9, where we're adding Paging 3.0 to our project.
  • Branch step10_loading_state_footer - you'll find the solution for step 10, where we're adding a footer that displays a loading state.
  • Branch step11_loading_state - you'll find the solution for step 11, where we're adding a display for the loading state between queries.
  • Branch step12_separators - you'll find the solution for step 12, where we're adding separators to our app.

If you do not have git, you can click the following button to download all the code for this codelab:

Download source code

  1. Unzip the code, and then open the project Android Studio version 3.6.1 or newer.
  2. Run the app run configuration on a device or emulator.

The app runs and displays a list of GitHub repositories similar to this one:

The app allows you to search GitHub for repositories whose name or description contains a specific word. The list of repositories is displayed in descending order based on the number of stars, then alphabetically by name.

The app follows the architecture recommended in the "Guide to app architecture". Here's what you will find in each package:

  • api - Github API calls, using Retrofit.
  • data - the repository class, responsible for triggering API requests and caching the responses in memory.
  • model - the Repo data model and RepoSearchResult, a class that is used by the UI to observe both search results data and network errors.
  • ui - classes related to displaying an Activity with a RecyclerView.

The GithubRepository class retrieves the list of repository names from the network every time the user scrolls towards the end of the list, or when the user searches for a new repository. The list of results for a query is kept in memory in the GithubRepository in a ConflatedBroadcastChannel and exposed as a Flow.

SearchRepositoriesViewModel requests the data from GithubRepository and exposes it to the SearchRepositoriesActivity. Because we want to ensure that we're not requesting the data multiple times on configuration change (e.g. rotation), we're converting the Flow to LiveData in the ViewModel using the liveData() builder method. That way, the LiveData caches the latest list of results in memory, and when the SearchRepositoriesActivity gets recreated, the content of the LiveData will be displayed on the screen.

From a usability perspective, we have the following issues:

  • The user has no information on the list loading state: they see an empty screen when they search for a new repository or just an abrupt end of the list while more results for the same query are being loaded.
  • The user can't retry a failed query.

From an implementation perspective, we have the following issues:

  • The list grows unbounded in memory, wasting memory as the user scrolls.
  • We have to convert our results from Flow to LiveData to cache them, increasing the complexity of our code.
  • If our app needed to show multiple lists, we'd see that there is a lot of boilerplate to write for each list.

Let's find out how the Paging library can help us with these issues and what components it includes.

The Paging library makes it easier for you to load data incrementally and gracefully within your app's UI. The Paging API provides support for many of the functionalities that you would otherwise need to implement manually when you need to load data in pages:

  • Keeps track of the keys to be used for retrieving the next and previous page.
  • Automatically requests the correct page when the user has scrolled to the end of the list.
  • Ensures that multiple requests aren't triggered at the same time.
  • Allows you to cache data: if you're using Kotlin, this is done in a CoroutineScope; if you're using Java, this can be done with LiveData.
  • Tracks loading state and allows you to display it in a RecyclerView list item or elsewhere in your UI, and easily retry failed loads.
  • Allows you to execute common operations like map or filter on the list that will be displayed, independently of whether you're using Flow, LiveData, or RxJava Flowable or Observable.
  • Provides an easy way of implementing list separators.

The Guide to app architecture proposes an architecture with the following main components:

  • A local database that serves as a single source of truth for data presented to the user and manipulated by the user.
  • A web API service.
  • A repository that works with the database and the web API service, providing a unified data interface.
  • A ViewModel that provides data specific to the UI.
  • The UI, which shows a visual representation of the data in the ViewModel.

The Paging library works with all of these components and coordinates the interactions between them, so that it can load "pages" of content from a data source and display that content in the UI.

This codelab introduces you to the Paging library and its main components:

  • PagingData - a container for paginated data. Each refresh of data will have a separate corresponding PagingData.
  • PagingSource - a PagingSource is the base class for loading snapshots of data into a stream of PagingData.
  • Pager.flow - builds a Flow<PagingData>, based on a PagingConfig and a function that defines how to construct the implemented PagingSource.
  • PagingDataAdapter - a RecyclerView.Adapter that presents PagingData in a RecyclerView. The PagingDataAdapter can be connected to a Kotlin Flow, a LiveData, an RxJava Flowable, or an RxJava Observable. 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.

In this codelab, you will implement examples of each of the components described above.

The PagingSource implementation defines the source of data and how to retrieve data from that source. The PagingData object queries data from the PagingSource in response to loading hints that are generated as the user scrolls in a RecyclerView.

Currently, the GithubRepository has a lot of the responsibilities of a data source that the Paging library will handle once we're done adding it:

  • Loads the data from GithubService, ensuring that multiple requests aren't triggered at the same time.
  • Keeps an in-memory cache of the retrieved data.
  • Keeps track of the page to be requested.

To build the PagingSource you need to define the following:

  • The type of the paging key - in our case, the Github API uses 1-based index numbers for pages, so the type is Int.
  • The type of data loaded - in our case, we're loading Repo items.
  • Where is the data retrieved from - we're getting the data from GithubService. Our data source will be specific to a certain query, so we need to make sure we're also passing the query information to GithubService.

So, in the data package, let's create a PagingSource implementation called GithubPagingSource:

class GithubPagingSource(
        private val service: GithubService,
        private val query: String
) : PagingSource<Int, Repo>() { 
    override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Repo> {
        TODO("Not yet implemented")
    }
}

We'll see that PagingSource requires us to implement a load() function—this will be called to trigger the async load. The LoadParams object keeps information related to the load operation, including the following:

  • Key of the page to be loaded. If this is the first time that load is called, LoadParams.key will be null. In this case, you will have to define the initial page key. For our project, you'll have to move GITHUB_STARTING_PAGE_INDEX constant from GithubRepository to your PagingSource implementation since this is the initial page key.
  • Load size - the requested number of items to load.

The load function returns a LoadResult. This will replace the usage of RepoSearchResult in our app, as LoadResult can take one of the following types:

  • LoadResult.Page, if the result was successful.
  • LoadResult.Error, in case of error.

When constructing the LoadResult.Page, pass null for nextKey or prevKey if the list can't be loaded in the corresponding direction.. For example, in our case, we could consider that if the network response was successful but the list was empty, we don't have any data left to be loaded; so the nextKey can be null.

Based on all of this information, we should be able to implement the load() function!

The GithubPagingSource implementation looks like this:

// GitHub page API is 1 based: https://developer.github.com/v3/#pagination
private const val GITHUB_STARTING_PAGE_INDEX = 1

class GithubPagingSource(
        private val service: GithubService,
        private val query: String
) : PagingSource<Int, Repo>() {

    override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Repo> {
        val position = params.key ?: GITHUB_STARTING_PAGE_INDEX
        val apiQuery = query + IN_QUALIFIER
        return try {
            val response = service.searchRepos(apiQuery, position, params.loadSize)
            val repos = response.items 
            LoadResult.Page(
                    data = repos,
                    prevKey = if (position == GITHUB_STARTING_PAGE_INDEX) null else position - 1,
                    nextKey = if (repos.isEmpty()) null else position + 1
            )
        } catch (exception: IOException) {
            return LoadResult.Error(exception)
        } catch (exception: HttpException) {
            return LoadResult.Error(exception)
        }
    }
}

In our current implementation, we use a Flow<RepoSearchResult> in the GitHubRepository to get the data from the network and pass it to the ViewModel. The ViewModel then transforms it into a LiveData and exposes it to the UI. Whenever we get to the end of the displayed list and more data is loaded from the network, the Flow<RepoSearchResult> will contain the entire list of previously retrieved data for that query in addition to the latest data.

RepoSearchResult encapsulates both the success and error cases. The success case holds the repository data. The error case contains the Exception reason. With Paging 3.0 we don't need the RepoSearchResult anymore, as the library models both the success and error cases with LoadResult. Feel free to delete RepoSearchResult as in the next few steps we'll replace it.

To construct the PagingData, we first need to decide what API we want to use to pass the PagingData to other layers of our app:

  • Kolin Flow - use Pager.flow.
  • LiveData - use Pager.liveData.
  • RxJava Flowable - use Pager.flowable.
  • RxJava Observable - use Pager.observable.

As we're already using Flow in our app, we'll continue with this approach; but instead of using Flow<RepoSearchResult>, we'll use Flow<PagingData<Repo>>.

No matter which PagingData builder you use, you'll have to pass the following parameters:

  • PagingConfig. This class sets options regarding how to load content from a PagingSource such as how far ahead to load, the size request for the initial load, and others. The only mandatory parameter you have to define is the page size—how many items should be loaded in each page. By default, Paging will keep in memory all the pages you load. To ensure that you're not wasting memory as the user scrolls, set the maxSize parameter in PagingConfig.
  • A function that defines how to create the PagingSource. In our case, we'll create a new GithubPagingSource for each new query.

Let's modify our GithubRepository!

Update GithubRepository.getSearchResultStream:

  • Remove the suspend modifier.
  • Return Flow<PagingData<Repo>>.
  • Construct Pager.
fun getSearchResultStream(query: String): Flow<PagingData<Repo>> {
    return Pager(
          config = PagingConfig(pageSize = NETWORK_PAGE_SIZE),
          pagingSourceFactory = { GithubPagingSource(service, query) }
    ).flow
}

Cleanup GithubRepository

Paging 3.0 does a lot of things for us:

  • Handles in-memory cache.
  • Requests data when the user is close to the end of the list.

This means that everything else in our GithubRepository can be removed, except getSearchResultStream and the companion object where we defined the NETWORK_PAGE_SIZE. Your GithubRepository should now look like this:

class GithubRepository(private val service: GithubService) {

    fun getSearchResultStream(query: String): Flow<PagingData<Repo>> {
        return Pager(
                config = PagingConfig(pageSize = NETWORK_PAGE_SIZE),
                pagingSourceFactory = { GithubPagingSource(service, query) }
        ).flow
    }

    companion object {
        private const val NETWORK_PAGE_SIZE = 50
    }
}

You should now have compile errors in the SearchRepositoriesViewModel. Let's see what changes need to be made there!

From our SearchRepositoriesViewModel we expose a repoResult: LiveData<RepoSearchResult>. The role of repoResult is to be an in-memory cache for result searches that survives configuration changes. With Paging 3.0 we don't need to convert our Flow to LiveData anymore. Instead, SearchRepositoriesViewModel will have a private Flow<PagingData<Repo>> member that serves the same purpose that repoResult did..

Instead of using a LiveData object for each new query, we can just use a String. This will help us ensure that whenever we get a new search query that is the same as the current query, we will just return the existing Flow. We only need to call repository.getSearchResultStream() if the new search query is different.

Flow<PagingData> has a handy cachedIn() method that allows us to cache the content of a Flow<PagingData> in a CoroutineScope. Since we're in a ViewModel, we will use the androidx.lifecycle.viewModelScope.

We'll rewrite most of SearchRepositoriesViewModel to leverage the built-in functionality from Paging 3.0. Your SearchRepositoriesViewModel will look like this:

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

    private var currentQueryValue: String? = null

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

    fun searchRepo(queryString: String): Flow<PagingData<Repo>> {
        val lastResult = currentSearchResult
        if (queryString == currentQueryValue && lastResult != null) {
            return lastResult
        }
        currentQueryValue = queryString
        val newResult: Flow<PagingData<Repo>> = repository.getSearchResultStream(queryString)
                .cachedIn(viewModelScope)
        currentSearchResult = newResult
        return newResult
    }
}

Now, let's see what changes we made to SearchRepositoriesViewModel:

  • Added the new query String and search result Flow members.
  • Updated the searchRepo() method with the functionality described previously.
  • Removed queryLiveData and repoResult as their purpose is covered by Paging 3.0 and Flow.
  • Removed the listScrolled() since the Paging library will handle this for us.
  • Removed the companion object because VISIBLE_THRESHOLD is no longer needed.
  • Removed repoLoadStatus, since Paging 3.0 has a mechanism for tracking the load status as well, as we'll see in the next step.

To bind a PagingData to a RecycleView, use a PagingDataAdapter. The PagingDataAdapter gets notified whenever the PagingData content is loaded and then it signals the RecyclerView to update.

Update the ui.ReposAdapter to work with a PagingData stream:

  • Right now, ReposAdapter implements ListAdapter. Make it implement PagingDataAdapter instead. The rest of the class body remains unchanged:
class ReposAdapter : PagingDataAdapter<Repo, RecyclerView.ViewHolder>(REPO_COMPARATOR) {
// body is unchanged

We've been making a lot of changes so far, but now we're just one step away from being able to run the app—we just need to connect the UI!

Let's update SearchRepositoriesActivity to work with Paging 3.0. To be able to work with Flow<PagingData>, we need to launch a new coroutine. We will do that in the lifecycleScope, which is responsible for canceling the request when the activity is recreated.

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

Let's create a new search function that gets a query as a parameter. The function should do the following:

  • Cancel the previous search job.
  • Launch a new job in lifecycleScope.
  • Call viewModel.searchRepo.
  • Collect the PagingData result.
  • Pass the PagingData to the ReposAdapter by calling adapter.submitData(pagingData).
private var searchJob: Job? = null

private fun search(query: String) {
   // Make sure we cancel the previous job before creating a new one
   searchJob?.cancel()
   searchJob = lifecycleScope.launch {
       viewModel.searchRepo(query).collectLatest {
           adapter.submitData(it)
       }
   }
}

The search function should be called in SearchRepositoriesActivity in the onCreate() method, In updateRepoListFromInput(), replace the viewModel and adapter calls with searchRepo:

private fun updateRepoListFromInput() {
    binding.searchRepo.text.trim().let {
        if (it.isNotEmpty()) {
            binding.list.scrollToPosition(0)
            search(it.toString())
        }
    }
}

Currently, we use an OnScrollListener attached to the RecyclerView to know when to trigger more data. We can let the Paging library handle list scrolling for us. Remove the setupScrollListener() method and all references to it.

Let's also remove repoResult usage. This is what your activity should look like:

@ExperimentalCoroutinesApi
class SearchRepositoriesActivity : AppCompatActivity() {

    private lateinit var binding: ActivitySearchRepositoriesBinding
    private lateinit var viewModel: SearchRepositoriesViewModel
    private val adapter = ReposAdapter()

    private var searchJob: Job? = null

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

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivitySearchRepositoriesBinding.inflate(layoutInflater)
        val view = binding.root
        setContentView(view)

        // get the view model
        viewModel = ViewModelProvider(this, Injection.provideViewModelFactory())
                .get(SearchRepositoriesViewModel::class.java)

        // add dividers between RecyclerView's row items
        val decoration = DividerItemDecoration(this, DividerItemDecoration.VERTICAL)
        binding.list.addItemDecoration(decoration)

        initAdapter()
        val query = savedInstanceState?.getString(LAST_SEARCH_QUERY) ?: DEFAULT_QUERY
        search(query)
        initSearch(query)
    }

    override fun onSaveInstanceState(outState: Bundle) {
        super.onSaveInstanceState(outState)
        outState.putString(LAST_SEARCH_QUERY, binding.searchRepo.text.trim().toString())
    }

    private fun initAdapter() {
        binding.list.adapter = adapter
    }

    private fun initSearch(query: String) {
        binding.searchRepo.setText(query)

        binding.searchRepo.setOnEditorActionListener { _, actionId, _ ->
            if (actionId == EditorInfo.IME_ACTION_GO) {
                updateRepoListFromInput()
                true
            } else {
                false
            }
        }
        binding.searchRepo.setOnKeyListener { _, keyCode, event ->
            if (event.action == KeyEvent.ACTION_DOWN && keyCode == KeyEvent.KEYCODE_ENTER) {
                updateRepoListFromInput()
                true
            } else {
                false
            }
        }
    }

    private fun updateRepoListFromInput() {
        binding.searchRepo.text.trim().let {
            if (it.isNotEmpty()) {
                binding.list.scrollToPosition(0)
                search(it.toString())
            }
        }
    }

    private fun showEmptyList(show: Boolean) {
        if (show) {
            binding.emptyList.visibility = View.VISIBLE
            binding.list.visibility = View.GONE
        } else {
            binding.emptyList.visibility = View.GONE
            binding.list.visibility = View.VISIBLE
        }
    }

    companion object {
        private const val LAST_SEARCH_QUERY: String = "last_search_query"
        private const val DEFAULT_QUERY = "Android"
    }
}

Our app should compile and run, but without the loading state footer and the Toast that displays on error. In the next step, we will see how to display the loading state footer.

You can find the full code for the steps done so far in branch step5-9_paging_3.0.

In our app, we want to be able to display a footer based on the load status: when the list is loading, we want to show a progress spinner. In case of an error, we want to show the error and a retry button.

The header/footer that we need to build follows the idea of a list that needs to be appended either at the beginning (as a header) or at the end (as a footer) of the actual list of items we're displaying. The header/footer is a list with only one element: a view that displays a progress bar or an error with a retry button, based on the Paging LoadState.

As displaying a header/footer based on the loading state and implementing a retry mechanism are common tasks, the Paging 3.0 API helps us with both of these.

For header/footer implementation we'll use a LoadStateAdapter. This implementation of RecyclerView.Adapter is automatically notified of changes in load state. It makes sure that only Loading and Error states lead to items being displayed and notifies the RecyclerView when an item is removed, inserted, or changed, depending on the LoadState.

For the retry mechanism we use adapter.retry(). Under the hood, this method ends up calling your PagingSource implementation for the right page. The response will be automatically propagated via Flow<PagingData>.

Let's see what our header/footer implementation looks like!

Like with any list, we have 3 files to create:

  • The layout file containing the UI elements for displaying progress, the error and the retry button
  • The ViewHolder file making the UI items visible based on the Paging LoadState
  • The adapter file defining how to create and bind the ViewHolder. Instead of extending a RecyclerView.Adapter, we will extend LoadStateAdapter from Paging 3.0.

Create the view layout

Create the repos_load_state_footer_view_item layout for our repo load state. It should have a ProgressBar, a TextView (to display the error), and a retry Button. The necessary strings and dimensions are already declared in the project.

<?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"
    xmlns:tools="http://schemas.android.com/tools"
    android:orientation="vertical"
    android:padding="8dp">
    <TextView
        android:id="@+id/error_msg"
        android:textColor="?android:textColorPrimary"
        android:textSize="@dimen/error_text_size"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="center"
        android:textAlignment="center"
        tools:text="Timeout"/>
    <ProgressBar
        android:id="@+id/progress_bar"
        style="?android:attr/progressBarStyle"
        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"/>
</LinearLayout>

Create the ViewHolder

Create a new ViewHolder called ReposLoadStateViewHolder in the ui folder. It should receive a retry function as a parameter, to be called when the retry button is pressed. Create a bind() function that receives the LoadState as a parameter and sets the visibility of each view depending on the LoadState. To set the visibility, we're using the toVisibility() method defined in UiUtils.kt. An implementation of ReposLoadStateViewHolder using ViewBinding looks like this:

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

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

    fun bind(loadState: LoadState) {
        if (loadState is LoadState.Error) {
            binding.errorMsg.text = loadState.error.localizedMessage
        }
        binding.progressBar.visibility = toVisibility(loadState is LoadState.Loading)
        binding.retryButton.visibility = toVisibility(loadState !is LoadState.Loading)
        binding.errorMsg.visibility = toVisibility(loadState !is LoadState.Loading)
    }

    companion object {
        fun create(parent: ViewGroup, retry: () -> Unit): ReposLoadStateViewHolder {
            val view = LayoutInflater.from(parent.context)
                    .inflate(R.layout.repos_load_state_footer_view_item, parent, false)
            val binding = ReposLoadStateFooterViewItemBinding.bind(view)
            return ReposLoadStateViewHolder(binding, retry)
        }
    }
}

Create the LoadStateAdapter

Create a ReposLoadStateAdapter that extends LoadStateAdapter in the ui folder as well. The adapter should receive the retry function as a parameter, since the retry function will be passed to the ViewHolder when constructed.

As with any Adapter, we need to implement the onBind() and onCreate() methods. LoadStateAdapter makes it easier as it passes the LoadState in both of these functions. In onBindViewHolder(), bind your ViewHolder. In onCreateViewHolder(), define how to create the ReposLoadStateViewHolder based on the parent ViewGroup and the retry function:

class ReposLoadStateAdapter(private val retry: () -> Unit) : LoadStateAdapter<ReposLoadStateViewHolder>() {
    override fun onBindViewHolder(holder: ReposLoadStateViewHolder, loadState: LoadState) {
        holder.bind(loadState)
    }

    override fun onCreateViewHolder(parent: ViewGroup, loadState: LoadState): ReposLoadStateViewHolder {
        return ReposLoadStateViewHolder.create(parent, retry)
    }
}

Now that we have all of the elements of our footer, let's bind them to our list. To do this, the PagingDataAdapter has 3 useful methods:

  • withLoadStateHeader - if we only want to display a header—this should be used when your list only supports adding items at the beginning of the list.
  • withLoadStateFooter - if we only want to display a footer—this should be used when your list only supports adding items at the end of the list.
  • withLoadStateHeaderAndFooter—if we want to display a header and a footer - if the list can be paged in both directions.

Update the SearchRepositoriesActivity.initAdapter() method and call withLoadStateHeaderAndFooter() on the adapter. As a retry function, we can call adapter.retry().

private fun initAdapter() {
    binding.list.adapter = adapter.withLoadStateHeaderAndFooter(
            header = ReposLoadStateAdapter { adapter.retry() },
            footer = ReposLoadStateAdapter { adapter.retry() }
    )
}

Since we have an infinite scrolling list, one easy way to get to see the footer is by putting your phone or emulator in airplane mode and scrolling until the end of the list.

Let's run the app!

You can find the full code for the steps done so far in branch step10_loading_state_footer.

You might have noticed that whenever you search for a new query, the current query result stays on screen until we get a network response. That's bad user experience! Instead we should display a progress bar or a retry button.

Let's update our activity_search_repositories.xml to include these UI elements, instead of the emptyList TextView:

<androidx.constraintlayout.widget.ConstraintLayout
    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"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".ui.SearchRepositoriesActivity">
    <com.google.android.material.textfield.TextInputLayout
        android:id="@+id/input_layout"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_marginEnd="8dp"
        android:layout_marginStart="8dp"
        android:layout_marginTop="8dp"
        android:layout_marginLeft="8dp"
        android:layout_marginRight="8dp"
        style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent">
        <EditText
            android:id="@+id/search_repo"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:hint="@string/search_hint"
            android:imeOptions="actionSearch"
            android:inputType="textNoSuggestions"
            android:selectAllOnFocus="true"
            tools:text="Android"/>
    </com.google.android.material.textfield.TextInputLayout>

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/list"
        android:layout_width="0dp"
        android:layout_height="0dp"
        android:paddingVertical="@dimen/row_item_margin_vertical"
        android:scrollbars="vertical"
        app:layoutManager="LinearLayoutManager"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/input_layout"
        tools:ignore="UnusedAttribute"/>

    <ProgressBar
        android:id="@+id/progress_bar"
        style="?android:attr/progressBarStyle"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_gravity="center"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintBottom_toBottomOf="parent"/>

    <Button
        android:id="@+id/retry_button"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="@string/retry"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintBottom_toBottomOf="parent"/>

</androidx.constraintlayout.widget.ConstraintLayout>

Our retry button should trigger a reload of the PagingData. To do this, we call adapter.retry() in the onClickListener implementation, like we did for the header/footer:

// SearchRepositoriesActivity.kt

override fun onCreate(savedInstanceState: Bundle?) {
    ...
    binding.retryButton.setOnClickListener { adapter.retry() }
}

To fix our functionality, we need to react to load state changes in our SearchRepositoriesActivity. For this, we will use the PagingDataAdapter.addLoadStateListener() method. This callback notifies us every time there's a change in the load state via a CombinedLoadStates object. CombinedLoadStates allows us to get the load state for the 3 different types of load operations:

  • CombinedLoadStates.refresh - represents the load state for loading the PagingData for the first time.
  • CombinedLoadStates.prepend - represents the load state for loading data at the start of the list.
  • CombinedLoadStates.append - represents the load state for loading data at the end of the list.

Since we only want our progress bar to be displayed when we have a new query, we need to rely on CombinedLoadStates.refresh and on the LoadState: Loading or Error. Also, one piece of functionality we commented out in a previous step was displaying a Toast when we got an error, so let's make sure we bring that in as well. To display the error message we will have to check whether CombinedLoadStates.prepend or CombinedLoadStates.append is an instance of LoadState.Error and retrieve the error message from the error.

Let's update our SearchRepositoriesActivity.initAdapter method to have this functionality:

private fun initAdapter() {
    binding.list.adapter = adapter.withLoadStateHeaderAndFooter(
            header = ReposLoadStateAdapter { adapter.retry() },
            footer = ReposLoadStateAdapter { adapter.retry() }
    )
    adapter.addLoadStateListener { loadState ->
        if (loadState.refresh !is LoadState.NotLoading) {
            // We're refreshing: either loading or we had an error
            // So we can hide the list
            binding.list.visibility = View.GONE
            binding.progressBar.visibility = toVisibility(loadState.refresh is LoadState.Loading)
            binding.retryButton.visibility = toVisibility(loadState.refresh is LoadState.Error)
        } else {
            // We're not actively refreshing
            // So we should show the list
            binding.list.visibility = View.VISIBLE
            binding.progressBar.visibility = View.GONE
            binding.retryButton.visibility = View.GONE
            // 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
                }
                else -> {
                    null
                }
            }
            errorState?.let {
                Toast.makeText(
                        this,
                        "\uD83D\uDE28 Wooops ${it.error}",
                        Toast.LENGTH_LONG
                ).show()
            }
        }
    }
}

Make sure you remove the showEmptyList() method, as we no longer need it.

Now let's run the app and check out how it works!

That's it! With the current setup, the Paging library components are the ones triggering the API requests at the right time, handling the in-memory cache, and displaying the data. Run the app and try searching for repositories.

You can find the full code for the steps done so far in branch step11_loading_state.

One way to improve your list's readability is to add separators. For example, in our app, since the repositories are ordered by number of stars descending, we could have separators every 10k stars. To help implement this, the Paging 3.0 API allows inserting separators into PagingData.

Adding separators in PagingData will lead to the modification of the list we display on our screen. We no longer display just Repo objects but also separator objects. Therefore, we have to change the UI model we're exposing from the ViewModel from Repo to another type that can encapsulate both types: RepoItem and SeparatorItem. Next, we'll have to update our UI to support separators:

  • Add a layout and ViewHolder for separators.
  • Update RepoAdapter to support creating and binding both separators and repositories.

Let's take this step by step and see what the implementation looks like.

Change the UI model

Currently SearchRepositoriesViewModel.searchRepo() returns Flow<PagingData<Repo>>. To support both repositories and separators, we'll create a UiModel sealed class in the same file with SearchRepositoriesViewModel. We can have 2 types of UiModel objects: RepoItem and SeparatorItem.

sealed class UiModel {
    data class RepoItem(val repo: Repo) : UiModel()
    data class SeparatorItem(val description: String) : UiModel()
}

Because we want to separate repositories based on 10k stars, let's create an extension property on RepoItem that rounds up the number of stars for us:

private val UiModel.RepoItem.roundedStarCount: Int
    get() = this.repo.stars / 10_000

Insert separators

SearchRepositoriesViewModel.searchRepo() should now return Flow<PagingData<UiModel>>. Make currentSearchResult the same type.

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

    private var currentQueryValue: String? = null

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

    fun searchRepo(queryString: String): Flow<PagingData<UiModel>> {
        ... 
    }
}

Let's see how the implementation changes! Currently, repository.getSearchResultStream(queryString) returns a Flow<PagingData<Repo>>, so the first operation we need to add is to transform each Repo into a UiModel.Repo. To do this, we can use the Flow.map operator and then map each PagingData to build a new UiModel.Repo from the current Repo item, resulting in a Flow<PagingData<UiModel.Repo>>:

...
val newResult: Flow<PagingData<UiModel>> = repository.getSearchResultStream(queryString)
                .map { pagingData -> pagingData.map { UiModel.RepoItem(it) } }
... 

Now we can insert the separators! For each emission of the Flow, we'll call PagingData.insertSeparators(). This method returns a PagingData containing each original element, with an optional separator that you will generate, given the elements before and after. In boundary conditions (at the beginning or end of the list) the respective before or after elements will be null. If a separator doesn't need to be created, return null.

Because we're changing the type of PagingData elements from UiModel.Repo to UiModel, make sure you explicitly set the type arguments of the insertSeparators() method.

Here's what the searchRepo() method should look like:

fun searchRepo(queryString: String): Flow<PagingData<UiModel>> {
    val lastResult = currentSearchResult
    if (queryString == currentQueryValue && lastResult != null) {
        return lastResult
    }
    currentQueryValue = queryString
    val newResult: Flow<PagingData<UiModel>> = repository.getSearchResultStream(queryString)
            .map { pagingData -> pagingData.map { UiModel.RepoItem(it) } }
            .map {
                it.insertSeparators<UiModel.RepoItem, UiModel> { before, after ->
                    if (after == null) {
                        // we're at the end of the list
                        return@insertSeparators null
                    }

                    if (before == null) {
                        // we're at the beginning of the list
                        return@insertSeparators UiModel.SeparatorItem("${after.roundedStarCount}0.000+ stars")
                    }
                    // check between 2 items
                    if (before.roundedStarCount > after.roundedStarCount) {
                        if (after.roundedStarCount >= 1) {
                            UiModel.SeparatorItem("${after.roundedStarCount}0.000+ stars")
                        } else {
                            UiModel.SeparatorItem("< 10.000+ stars")
                        }
                    } else {
                        // no separator
                        null
                    }
                }
            }
            .cachedIn(viewModelScope)
    currentSearchResult = newResult
    return newResult
}

Support multiple view types

SeparatorItem objects need to be displayed in our RecyclerView. We're only displaying a string here, so let's create a separator_view_item layout with a TextView in the res/layout folder:

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout 
    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"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:background="@color/separatorBackground">

    <TextView
        android:id="@+id/separator_description"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:padding="@dimen/row_item_margin_horizontal"
        android:textColor="@color/separatorText"
        android:textSize="@dimen/repo_name_size"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        tools:text="10000+ stars" />
</androidx.constraintlayout.widget.ConstraintLayout>

Let's create a SeparatorViewHolder in the ui folder, where we just bind a string to the TextView:

class SeparatorViewHolder(view: View) : RecyclerView.ViewHolder(view) {
    private val description: TextView = view.findViewById(R.id.separator_description)

    fun bind(separatorText: String) {
        description.text = separatorText
    }

    companion object {
        fun create(parent: ViewGroup): SeparatorViewHolder {
            val view = LayoutInflater.from(parent.context)
                    .inflate(R.layout.separator_view_item, parent, false)
            return SeparatorViewHolder(view)
        }
    }
}

Update ReposAdapter to support a UiModel instead of a Repo:

  • Update the PagingDataAdapter parameter from Repo to UiModel.
  • Implement a UiModel comparator and replace the REPO_COMPARATOR with it.
  • Create the SeparatorViewHolder and bind it with the description of UiModel.SeparatorItem.

Here's what your final ReposAdapter will look like:

class ReposAdapter : PagingDataAdapter<UiModel, ViewHolder>(UIMODEL_COMPARATOR) {

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
        return if (viewType == R.layout.repo_view_item) {
            RepoViewHolder.create(parent)
        } else {
            SeparatorViewHolder.create(parent)
        }
    }

    override fun getItemViewType(position: Int): Int {
        return when (getItem(position)) {
            is UiModel.RepoItem -> R.layout.repo_view_item
            is UiModel.SeparatorItem -> R.layout.separator_view_item
            null -> throw UnsupportedOperationException("Unknown view")
        }
    }

    override fun onBindViewHolder(holder: ViewHolder, position: Int) {
        val uiModel = getItem(position)
        uiModel.let {
            when (uiModel) {
                is UiModel.RepoItem -> (holder as RepoViewHolder).bind(uiModel.repo)
                is UiModel.SeparatorItem -> (holder as SeparatorViewHolder).bind(uiModel.description)
            }
        }
    }

    companion object {
        private val UIMODEL_COMPARATOR = object : DiffUtil.ItemCallback<UiModel>() {
            override fun areItemsTheSame(oldItem: UiModel, newItem: UiModel): Boolean {
                return (oldItem is UiModel.RepoItem && newItem is UiModel.RepoItem &&
                        oldItem.repo.fullName == newItem.repo.fullName) ||
                        (oldItem is UiModel.SeparatorItem && newItem is UiModel.SeparatorItem &&
                                oldItem.description == newItem.description)
            }

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

That's it! When running the app you should be able to see the separators!

You can find the full code for the steps done so far in branch step12_separators.

Now that we have added all the components, let's recap what we've learned!

  • The PagingSource asynchronously loads the data from a source you define.
  • The Pager.flow creates a Flow<PagingData> based on a configuration and a function that defines how to instantiate the PagingSource.
  • The Flow emits a new PagingData whenever new data is loaded by the PagingSource.
  • The UI observes the changed PagingData and uses a PagingDataAdapter to update the RecyclerView that presents the data.
  • To retry a failed load from the UI, use the PagingDataAdapter.retry method. Under the hood, the Paging library will trigger the PagingSource.load() method.
  • To add separators to your list, create a high-level type with separators as one of the supported types. Then use the PagingData.insertSeparators() method to implement your separator generation logic.
  • To display the load state as header or footer, use PagingDataAdapter.withLoadStateHeaderAndFooter() method and implement a LoadStateAdapter. If you want to execute other actions based on the load state, use the PagingDataAdapter.addLoadStateListener() callback.