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:
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. 💡
- The first step is to create a new Composable for the Home screen, which contains the coin 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 fromCoinItemUiModel
to the UI. Here is the composable in preview mode:
- Add the
LazyColumn
inside theHomeScreenContent
to display coin information byitem {}
. WithitemsIndexed
, there is a callback to trigger the current item’s index showing on UI.itemThreshold
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 to show loading indicator at the end of list whenever the loading more triggered.
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 supports handling loading and displaying paged data.
- Firstly, add the dependency to
build.gradle (:app)
implementation "androidx.paging:paging-compose:3.3.2"
💡 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
CoinPagingSourcein
in the:domain
module need to be inherited thePagingSource
fromandroidx.paging
library; it requires to add the dependency tobuild.gradle (:domain)
implementation “androidx.paging:paging-common:3.3.2”
- The
PagingSource
class requires 2 dependencies:CoinRepository
: which will load data from APIQuery
: 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 next PagingSource 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 current latest page index 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
,Flowable
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 aFlow
to provide the stream ofPagingData
to the UI.
private val _trendingCoins = MutableStateFlow<PagingData<CoinItemUiModel>>(PagingData.empty())
override val trendingCoins: StateFlow<PagingData<CoinItemUiModel>>
get() = _trendingCoins
- Then, defining 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 the the first approach without Paging3, the
HomeScreenContent
will receive the items to bind 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 provides 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 on the outcome 🎉
Comparision
This comparison summarizes the pros and cons of using Paging3 compared to the approach without it:
Without Paging3 | With Paging3 | |
---|---|---|
Complexity | ✅ | |
Support Difference data source (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 - Complexity in handling the Preview
- 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 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://developer.android.com/topic/libraries/architecture/paging/v3-paged-data
https://developer.android.com/topic/libraries/architecture/paging/v3-migration#benefits