Introduction
Pagination is a widely used UX approach in modern app development that helps users navigate through large amounts of data intuitively. From news feeds to photo galleries or music libraries, pagination creates a seamless scrolling experience through vast content collections, significantly enhancing user experience.
Jetpack Compose makes building dynamic interfaces with efficient pagination more straightforward than ever. In this article, we’ll explore pagination with Jetpack Compose, comparing different implementation approaches and analyzing the pros and cons of each solution. Whether you’re an experienced developer or just starting with Jetpack Compose, this guide provides valuable insights on implementing pagination to elevate your application.
Let’s dive into pagination with Jetpack Compose! 📖
Building Crypto Price Tracker 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:

Our project follows modern development practices, utilizing MVVM and Clean Architecture. Data flows from the Repository layer through the ViewModel and finally present to the UI.
As users scroll down through the Trending 🔥
section, the app automatically sends API requests to fetch the next page of data, seamlessly adding new items to the end of the current list.
Now, Let’s move to the next section to learn about implementing pagination with two approaches.
📌 Checkout the full source code on Github 🚀
Without Paging3
Implementing pagination without Paging3 is more straightforward with just a few steps. The main idea of this approach is simple: observe the last visible item’s index and trigger loading of the next page when needed. 💡
The first step is to create a new Composable for the Home screen, which contains the coins list:
@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
...
) {
...
}
To display the trending coin items, create a new composable called TrendingItem
. This composable will bind the data from CoinItemUiModel
to the UI. Here is the composable in preview mode:

Add the LazyColumn
inside the HomeScreenContent
to display coin information. With itemsIndexed
, there is a callback to trigger the current item’s index shown on the UI. The threshold constant targets the remaining items to preload when users nearly reach the end of the list.
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) }
)
}
}
}
Add the if
condition to validate the loading state and show loading indicator at the bottom of list whenever the next page of data is being fetched.
if (showTrendingCoinsLoading == LoadingState.LoadingMore) {
item {
CircularProgressIndicator(
modifier = Modifier
.fillMaxWidth()
.wrapContentWidth(align = Alignment.CenterHorizontally)
.padding(bottom = Dp16),
)
}
}
That’s it! 💪 Build the project, then see the result 🪄
With Paging3
In this section, we are going to implement pagination with Paging3 which provides robust support for handling and displaying paginated data. There are some essential parts before starting implement Paging3:
- PagingSource: Defines a source of data and how to retrieve it from a data source such as remote APIs or databases.
- RemoteMediator: Handles paging from layered data sources, like network data with local database caching.
- PagingConfig: Configures how pagination behaves, including page sizes and prefetching distances.
- Pager: Creates flows of PagingData based on your configuration.
- PagingData: A container for paginated data that includes loading state information.
-
LazyPagingItems: Connects with Lazy composable components to quickly present data using the
items{}
built for LazyListScope.
Here we go to integrate Paging3 to the application:
- Firstly, add the dependency to
build.gradle (:app)
implementation "androidx.paging:paging-compose:3.3.6"
Feel free to replace with a newer version of this dependency 🛠️
- Define the PagingSource as a source of data and retrieve data from PagingSource to show in UI.
class CoinPagingSource() : PagingSource<Int, CoinItem>()
In the demonstration project, the CoinPagingSource in the
:domain
module needs to inherit the PagingSource from theandroidx.paging
library; it requires adding the dependency tobuild.gradle (:domain)
.
implementation "androidx.paging:paging-common:3.3.6"
The PagingSource
class requires 2 dependencies:
-
CoinRepository
: which will load data from API -
Query
: which is provide fields for API request
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:
-
getRefreshKey
provides aKey
used for the initialload
for the nextPagingSource
due to invalidation of thisPagingSource
. The Key is provided toload(...)
viaLoadParams.Key
.
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
)
}
}
-
load(params: LoadParams<Int>)
is the callback function to load more data from API
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)
}
}
}
}
- The
key
is used to determine the latest page index that was fetched. On initial load, there is no key added, so the value can benull
, which means that the first page should be1
.
val page = params.key ?: STARTING_PAGE_INDEX
- Paging3 provides PagingConfig to predefine various properties on paging data.
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
)
- The Paging library supports using several stream types, including
Flow
,LiveData
, and theFlowable
andObservable
types fromRxJava
. On this blog post, we are going to useFlow
for the data stream:
Pager(
config = PagingConfig(
...
),
pagingSourceFactory = {
...
}
).flow
- Let’s combine the piece of code above and add into the UseCase:
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
}
}
}
- On the HomeViewModel, let’s provide a Flow to provide the stream of PagingData to the UI.
private val _trendingCoins = MutableStateFlow<PagingData<CoinItemUiModel>>(PagingData.empty())
override val trendingCoins: StateFlow<PagingData<CoinItemUiModel>>
get() = _trendingCoins
- Then, define the new method to invoke the created UseCase to fetch the data.
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)
}
}
}
- On the
HomeScreen
, observe thetrendingCoinsPagination
flow to bind the coin items whenever the new page of data is added.
@Composable
fun HomeScreen(
viewModel: HomeViewModel = hiltViewModel(),
....
) {
...
val trendingCoinsPagination = viewModel.trendingCoinsPagination.collectAsLazyPagingItems()
...
}
- Similar to the first approach without Paging3, the HomeScreenContent will receive the items to bind the coin data on the UI.
@Composable
private fun HomeScreenContent(
...
trendingCoinsPagination: LazyPagingItems<CoinItemUiModel>,
...
) {
...
}
- If you’re curious why there is no loading states with this approach 🤔 Then how would we handle the different state on loading 🤷 The answer is loadState provided through the LazyPagingItems which combined loading states:

- Now, we can use the paging data to bind the data to the UI:
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 at the outcome 🎉
Comparison
This comparison summarizes the pros and cons of using Paging3 compared to the approach without it:
Without Paging3 | With Paging3 | |
---|---|---|
Implementation simplicity | ✅ | |
Enhanced integration & maintenance | ✅ | |
Supports different data sources (remote and local) | ✅ | |
Loading placeholder | ✅ | |
Preview | ✅ | ✅ |
Without Paging3:
- Pros:
- Simple and quick implementation to handle fetching more items
- Provides paging data without wrapping class
- Simple handling of UI Preview
- Cons:
- Complexity in supporting different data sources
- No placeholder when loading items
With Paging3:
- Pros:
- Suitable for handling more complex cases like fetching data from a single data source or combining remote and local databases
- Supports placeholder when loading items, which is a popular UI/UX trend in modern applications
- Cons:
- Requires more boilerplate code, including handling the
PagingSource
class, wrapping data withPagingData
, and providing config to the Pager class
- Requires more boilerplate code, including handling the
Conclusion
While Paging3 provides many benefits, it does require a significant amount of boilerplate code to handle communication between the Data 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, many developers prefer to use Paging3 due to its support for different data sources (such as remote and local) and loading placeholders. Ultimately, the decision to use Paging3 will depend on the specific needs and resources of your project. ✌️