Welcome back to the Paging 3.0 MAD Skills series! In the previous article, we went over the Paging library, saw how it fits into the app’s architecture, and we integrated it into the data layer of the app. We did this using a PagingSource to fetch our data, and then used it, along with a PagingConfig to create a Pager object that provided a Flow<PagingData> for UI consumption. In this article I’ll be covering how to actually consume the Flow<PagingData> in your UI.

Preparing PagingData for the UI

The app currently has the ViewModel exposing the information needed to render the UI in the UiState data class, which contains a searchResult field; an in-memory cache for result searches that survives configuration changes.

data class UiState(
    val query: String,
    val searchResult: RepoSearchResult
)

sealed class RepoSearchResult {
    data class Success(val data: List<Repo>) : RepoSearchResult()
    data class Error(val error: Exception) : RepoSearchResult()
}
Initial UiState Definition

With Paging 3.0, we drop the searchResult val from the UiState, opting instead to replace it with a Flow of PagingData<Repo> exposed separately from the UiState. This new Flow will serve the same purpose as the searchResult: provide a list of items to be rendered by the UI.

A private method “searchRepo()” is added to the ViewModel, which calls the Repository to provide a PagingData Flow from the Pager. We can then call this method to create our Flow<PagingData<Repo>> based on the search terms the user enters. We also make use of the cachedIn operator on the resulting PagingData Flow which caches it for quicker reuse using the ViewModelScope.

PagingData Flow integration from the repository

It’s important to expose the PagingData Flow independent of other Flows. This is because the PagingData itself is a mutable type, and maintains its own internal stream of data that updates over time.

With the Flows that comprise the fields of the UiState fully defined, we can combine them into a StateFlow of UiState, which can then be exposed to, and consumed by the UI alongside the Flow of PagingData. With the above, we are now ready to start consuming our Flows in the UI.

class SearchRepositoriesViewModel(
    …
) : ViewModel() {

    val state: StateFlow<UiState>

    val pagingDataFlow: Flow<PagingData<Repo>>
    
    init {
        …

        pagingDataFlow = searches
            .flatMapLatest { searchRepo(queryString = it.query) }
            .cachedIn(viewModelScope)

        state = combine(...)
    }

}
Exposing the PagingData Flow to the UI. Note the use of the cachedIn operator

Consuming PagingData in the UI

The first thing we do is switch the RecyclerView Adapter from a ListAdapter to a PagingDataAdapter. A PagingDataAdapter is a RecyclerView Adapter optimized for diffing and aggregating updates from PagingData to make sure changes in the backing data set are propagated as efficiently as possible.

// Before
// class ReposAdapter : ListAdapter<Repo, RepoViewHolder>(REPO_COMPARATOR) {
//     …
// }

// After
class ReposAdapter : PagingDataAdapter<Repo, RepoViewHolder>(REPO_COMPARATOR) {
    …
}
Switching from a ListAdapter to a PagingDataAdapter

Next, we start to collect from the PagingData Flow, so we can bind its emissions to the PagingDataAdapter using the submitData suspending function.

   private fun ActivitySearchRepositoriesBinding.bindList(
        …
        pagingData: Flow<PagingData<Repo>>,
    ) {
        …
        lifecycleScope.launch {
            pagingData.collectLatest(repoAdapter::submitData)
        }

    }
Consuming PagingData with a PagingDataAdapter. Note the use of the collectLatest operator

Also, as a user experience perk, we want to make sure that when the user searches for something new, they are taken to the top of the list to show the first search results. We want to do this when we’re confident when we’ve finished loading and have presented data in the UI. We achieve this by taking advantage of the loadStateFlow exposed by the PagingDataAdapter and the “hasNotScrolledForCurrentSearch’’ field in the UiState used for tracking if the user has manually scrolled the list themselves. The combination of these two creates a flag to let us know if we can trigger an auto scroll.

Since the load states provided by the loadStateFlow are synchronous with what is displayed in the UI, we can confidently scroll to the top of the list once the load state flow notifies us we are not loading for each new query.

   private fun ActivitySearchRepositoriesBinding.bindList(
        repoAdapter: ReposAdapter,
        uiState: StateFlow<UiState>,
        pagingData: Flow<PagingData<Repo>>,
        …
    ) {
        …
        val notLoading = repoAdapter.loadStateFlow
            // Only emit when REFRESH LoadState for the paging source changes.
            .distinctUntilChangedBy { it.source.refresh }
            // Only react to cases where REFRESH completes i.e., NotLoading.
            .map { it.source.refresh is LoadState.NotLoading }

        val hasNotScrolledForCurrentSearch = uiState
            .map { it.hasNotScrolledForCurrentSearch }
            .distinctUntilChanged()

        val shouldScrollToTop = combine(
            notLoading,
            hasNotScrolledForCurrentSearch,
            Boolean::and
        )
            .distinctUntilChanged()

        lifecycleScope.launch {
            shouldScrollToTop.collect { shouldScroll ->
                if (shouldScroll) list.scrollToPosition(0)
            }
        }
    }
Implementing auto scroll to the top for new queries

Adding Headers and Footers

Another advantage of the Paging Library is the ability to display progress indicators either at the top or bottom of the list with the help of the LoadStateAdapter. This implementation of a RecyclerView.Adapter is automatically notified of changes in the Pager as it loads data which enables it to insert items at the top or bottom of the list as needed.

The best part is you don’t even need to change your existing PagingDataAdapter. The withLoadStateHeaderAndFooter extension conveniently wraps your existing PagingDataAdapter with both the header and footer!

   private fun ActivitySearchRepositoriesBinding.bindState(
        uiState: StateFlow<UiState>,
        pagingData: Flow<PagingData<Repo>>,
        uiActions: (UiAction) -> Unit
    ) {
        val repoAdapter = ReposAdapter()
        list.adapter = repoAdapter.withLoadStateHeaderAndFooter(
            header = ReposLoadStateAdapter { repoAdapter.retry() },
            footer = ReposLoadStateAdapter { repoAdapter.retry() }
        )
    }
Headers and Footers

The arguments of the withLoadStateHeaderAndFooter function take definitions of LoadStateAdapters for both the header and footer. The LoadStateAdapter’s in turn host their own ViewHolders, which are bound with the latest load state making it easy to define the behavior of the views. We can even pass arguments to let us retry loading in case there’s an error, which I’ll cover more in the next article.

Next Up

With that we’ve bound our paging data to the UI! For a quick recap, we:

  • Integrated our Paging in the UI layer using the PagingDataAdapter
  • Used the LoadStateFlow exposed by the PagingDataAdapter to guarantee we only auto scroll to the top of the list when the Pager is done loading
  • Used the withLoadStateHeaderAndFooter() to add progress bars to UI when fetching data

Thanks for reading along! Stay tuned, and see you in the next one where we’ll be looking at Paging from the database as a single source of truth, and taking a closer look at the LoadStateFlow!

By TJ
Source Medium

Previous Apple Opens Developer Academy In Detroit, Creating New Opportunities For Careers In The iOS App Economy
Next Apply Special Effects To Images With The CameraX Extensions API