Skip to content

Pagination with Paging3 library and an alternative approach in Jetpack Compose

Published: at 04:00 PM (9 min read)

Paging is a widely used UX approach in modern application development that provides users with an intuitive and efficient way to navigate through large amounts of data. From news feeds to photo galleries and music libraries, paging offers a seamless way to scroll through a vast array of content, making the user experience much more enjoyable.

In this blog post, we will delve deep into the topic of pagination with Jetpack Compose, exploring different approaches to implementing this popular UX technique. We will also discuss the various pros and cons of each solution, giving you a comprehensive understanding of which approach is most appropriate for your specific needs.

Table of contents

Open Table of contents

Introduction

When building new applications, such as photo galleries or music libraries, implementing pagination correctly is essential for providing a smooth and seamless user experience. With Jetpack Compose, it’s easier than ever to build a dynamic and engaging user interface with built-in pagination.

Whether you’re a seasoned developer or just getting started with Jetpack Compose, this article will provide you with a wealth of guidance on implementing pagination and taking your application to the next level.

Let’s dive in and explore pagination with Jetpack Compose! 📖

Building Crypto prices application with Jetpack Compose

In this article, we will build an application to show a list of trending cryptocurrencies. The data for this long list will be fetched from the Coingecko API.

The following demonstration shows how the application looks when it is complete:

Compose Crypto - Home

The project follows modern development practices, utilizing MVVM and Clean Architecture. The data will be fetched from the Repository layer and passed through the ViewModel to the UI.

As users scroll up to see more items on the Trending 🔥 section, an API request will be sent to fetch the next page of data and add it to the end of the current showing list.

Let’s move on to the next section to learn about implementing pagination with two approaches. 🏃‍♂️

📌 The source code is public on Github

Without Paging3

Implementing pagination without Paging3 is more straightforward with a few steps. The main idea of this approach is observing the last index of the visible items and then triggering the loading of the next page of content data. 💡

@Composable
private fun HomeScreenContent(
    ...
    showTrendingCoinsLoading: LoadingState, // Observe the loading state to show the indicator
    trendingCoins: List<CoinItemUiModel>, // Data models to display on UI
    onTrendingCoinsLoadMore: () -> Unit = {} // Trigger loading more callback whenever reach end of list
    ...
) {

...

}

TrendingItem - Preview

    private const val LIST_ITEM_LOAD_MORE_THRESHOLD = 0
    ...
    val trendingCoinsLastIndex = trendingCoins.lastIndex
    val trendingCoinsState = rememberLazyListState()
    ...
    LazyColumn(state = trendingCoinsState) {
        itemsIndexed(trendingCoins) { index, coin ->
            if (index + LIST_ITEM_LOAD_MORE_THRESHOLD >= trendingCoinsLastIndex) {
                SideEffect {
                    Timber.d("onTrendingCoinsLoadMore at index: $index, lastIndex: $trendingCoinsLastIndex")
                    onTrendingCoinsLoadMore.invoke()
                }
            }

            Box(
                modifier = Modifier.padding(
                start = Dp16, end = Dp16, bottom = Dp16
                )
            ) {
                TrendingItem(
                    modifier = ...
                    coinItem = coin,
                    onItemClick = { onTrendingItemClick.invoke(coin) }
                )
            }
        }
    }
if (showTrendingCoinsLoading == LoadingState.LoadingMore) {
    item {
        CircularProgressIndicator(
            modifier = Modifier
                .fillMaxWidth()
                .wrapContentWidth(align = Alignment.CenterHorizontally)
                .padding(bottom = Dp16),
        )
    }
}

With Paging3

In this section, we are going to implement pagination with Paging3 which supports handling loading and displaying paged data.

implementation "androidx.paging:paging-compose:3.3.2"

💡 Feel free to replace with a newer version of this dependency.

class CoinPagingSource() : PagingSource<Int, CoinItem>()

In the demonstration project, the CoinPagingSourcein in the :domain module need to be inherited the PagingSource from androidx.paging library; it requires to add the dependency to build.gradle (:domain)

implementation “androidx.paging:paging-common:3.3.2”

class CoinPagingSource(
    private val coinRepository: CoinRepository,
    private val query: Query
) : PagingSource<Int, CoinItem>() {

    data class Query(
        val currency: String,
        val order: String,
        val priceChangeInHour: String
    )
}

There are 2 override functions: getRefreshKey(...) & load(...) to return paging data to display on the UI.

override fun getRefreshKey(state: PagingState<Int, CoinItem>): Int? {
    // Try to find the page key of the closest page to anchorPosition, from
    // either the prevKey or the nextKey, but you need to handle nullability
    // here:
    //  * prevKey == null -> anchorPage is the first page.
    //  * nextKey == null -> anchorPage is the last page.
    //  * both prevKey and nextKey null -> anchorPage is the initial page, so
    //    just return null.
    return state.anchorPosition?.let { anchorPosition ->
        state.closestPageToPosition(anchorPosition)?.prevKey?.plus(
            1
        ) ?: state.closestPageToPosition(anchorPosition)?.nextKey?.minus(
            1
        )
    }
}
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, CoinItem> = withContext(Dispatchers.IO) {
    suspendCoroutine { continuation ->
        runBlocking {
            val page = params.key ?: STARTING_PAGE_INDEX
            val size = params.loadSize
            coinRepository.getCoins(
                currency = query.currency,
                priceChangePercentage = query.priceChangeInHour,
                itemOrder = query.order,
                page = page,
                itemPerPage = size
            ).catch {
                continuation.resume(LoadResult.Error(it))
            }.collectLatest { items ->
                val prevKey = if (page == STARTING_PAGE_INDEX) null else page - 1
                val nextKey = if (items.isEmpty()) null else page + 1

                val result = if (params.placeholdersEnabled) {
                    val itemsBefore = page * size
                    val itemsAfter = itemsBefore + items.size
                    LoadResult.Page(
                        data = items,
                        prevKey = prevKey,
                        nextKey = nextKey,
                        itemsAfter = if (itemsAfter > size) size else itemsAfter,
                        itemsBefore = if (page == STARTING_PAGE_INDEX) 0 else itemsBefore,
                    )
                } else {
                    LoadResult.Page(
                        data = items,
                        prevKey = prevKey,
                        nextKey = nextKey
                    )
                }
                continuation.resume(result)
            }
        }
    }
}
val page = params.key ?: STARTING_PAGE_INDEX
PagingConfig(
    pageSize = itemPerPage, // Number of items per page
    enablePlaceholders = true, // Show the loading placeholder on requesting API
    prefetchDistance = LIST_ITEM_LOAD_MORE_THRESHOLD, // Remaining items to trigger preload
    initialLoadSize = itemPerPage // Number of items on initial load
)
Pager(
    config = PagingConfig(
        ...
		),
    pagingSourceFactory = {
        ...
    }
).flow
private const val LIST_ITEM_LOAD_MORE_THRESHOLD = 2

class GetTrendingCoinsPaginationUseCase @Inject constructor(private val repository: CoinRepository) {

    data class Input(
        val currency: String,
        val order: String,
        val priceChangeInHour: String,
        val itemPerPage: Int
    )

    fun execute(input: Input): Flow<PagingData<CoinItem>> {
        return with(input) {
            Pager(
                config = PagingConfig(
                    pageSize = itemPerPage,
                    enablePlaceholders = true,
                    prefetchDistance = LIST_ITEM_LOAD_MORE_THRESHOLD,
                    initialLoadSize = itemPerPage
                ),
                pagingSourceFactory = {
                    CoinPagingSource(
                        repository,
                        CoinPagingSource.Query(
                            currency = currency,
                            order = order,
                            priceChangeInHour = priceChangeInHour
                        )
                    )
                }
            ).flow
        }
    }
}
private val _trendingCoins = MutableStateFlow<PagingData<CoinItemUiModel>>(PagingData.empty())
override val trendingCoins: StateFlow<PagingData<CoinItemUiModel>>
    get() = _trendingCoins
override fun getTrendingCoins() {
    execute {
        getTrendingCoinsPaginationUseCase.execute(
            GetTrendingCoinsPaginationUseCase.Input(
                currency = FIAT_CURRENCY,
                order = MY_COINS_ORDER,
                priceChangeInHour = MY_COINS_PRICE_CHANGE_IN_HOUR,
                itemPerPage = MY_COINS_ITEM_PER_PAGE
            )
        ).catch { e ->
            _trendingCoinsError.emit(e)
        }.cachedIn(
            viewModelScope
        ).collect { coins ->
            val newCoinList = coins.map { it.toUiModel() }
            _trendingCoinsPagination.emit(newCoinList)
        }
    }
}
@Composable
fun HomeScreen(
    viewModel: HomeViewModel = hiltViewModel(),
    ....
) {
    ...
    val trendingCoinsPagination = viewModel.trendingCoinsPagination.collectAsLazyPagingItems()
    ...
}
@Composable
private fun HomeScreenContent(
    ...
    trendingCoinsPagination: LazyPagingItems<CoinItemUiModel>,
    ...
) {

...

}

LazyPagingItems - LoadState

items(
    count = trendingCoinsPagination.itemCount,
    contentType = trendingCoinsPagination.itemContentType(),
    key = trendingCoinsPagination.itemKey { it.id },
) { index ->
    Box(
        modifier = Modifier.padding(
            start = Dp16, end = Dp16, bottom = Dp16
        )
    ) {
        TrendingItem(
            modifier = ...,
            coinItem = trendingCoinsPagination[index],
            ...
        )
    }
}

Let’s run the project and take a look on the outcome 🎉

Comparision

This comparison summarizes the pros and cons of using Paging3 compared to the approach without it:

Without Paging3With Paging3
Complexity
Support Difference data source (Remote and local)
Loading placeholder
Preview

Without Paging3:

With Paging3:

Conclusion

While Paging3 provides many benefits, it does require a significant amount of boilerplate code to handle communication between the Repository layer and UI layer. This added complexity can make implementation more challenging and time-consuming. Additionally, testing with Paging3 can be cumbersome and may require significant effort to mock data source functionality. These challenges can be especially difficult for developers who are new to Paging3 or have limited experience with pagination. 💪

Despite these challenges, however, many developers prefer to use Paging3 due to its support for different data sources (such as remote and local), loading placeholdersa. Ultimately, the decision to use Paging3 will depend on the specific needs and resources of your project. ✌️

References

https://betterprogramming.pub/jetpack-compose-pagination-287ea6e782e3

https://medium.com/androiddevelopers/introduction-to-paging-3-0-in-the-mad-skills-series-648f77231121

https://developer.android.com/topic/libraries/architecture/paging/v3-paged-data

https://developer.android.com/topic/libraries/architecture/paging/v3-migration#benefits


Previous Post
Modern libraries to build Android application