Paging 3 with Compose
The previous lesson's api.getCommunityRecipes() quietly assumed the whole feed fits in
one response. That's fine for a few dozen recipes, but RecipeBox's real community feed has grown to
tens of thousands of entries — fetching all of them in a single request would be slow, waste
bandwidth, and use far more memory than the screen could ever show at once. Paging 3
is Jetpack's library for loading a large, scrollable dataset a page at a time, and it plugs directly
into a Compose LazyColumn.
Why Loading Everything at Once Breaks Down
A single List<Recipe> holding 20,000 community recipes means one enormous
network response, one enormous JSON parse, and 20,000 Recipe objects sitting in memory
— for a screen that shows perhaps 8 of them on screen at a time. Paging 3 fixes
this at the source: it requests only a page's worth of data (say, 20 items) at a time, requests the
next page automatically as the user scrolls near the end of what's loaded, and only keeps a bounded
window of pages in memory at once.
PagingSource: Describing How to Load One Page
A PagingSource is the piece that knows how to fetch a single page given a page key
(here, a page number the backend understands) and how to figure out the keys for the previous and
next pages:
CommunityRecipePagingSource.kt
class CommunityRecipePagingSource(
private val api: RecipeApiService
) : PagingSource<Int, Recipe>() {
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Recipe> {
val page = params.key ?: 1
return try {
val response = api.getCommunityRecipes(page = page, pageSize = params.loadSize)
LoadResult.Page(
data = response,
prevKey = if (page == 1) null else page - 1,
nextKey = if (response.isEmpty()) null else page + 1
)
} catch (e: Exception) {
LoadResult.Error(e)
}
}
override fun getRefreshKey(state: PagingState<Int, Recipe>): Int? {
return state.anchorPosition?.let { anchor ->
state.closestPageToPosition(anchor)?.prevKey?.plus(1)
}
}
}
nextKey = null once the backend returns an empty page tells Paging 3 it has reached
the end of the feed and to stop requesting further pages. getRefreshKey tells Paging 3
which page to reload around if the whole list needs to refresh — for example after a pull-to-refresh
— so the user's scroll position is preserved instead of snapping back to page one.
Pager: Wiring the PagingSource into a Flow of PagingData
The ViewModel builds a Pager around the PagingSource, configures page
size, and exposes the result as a Flow<PagingData<Recipe>> — cached across
configuration changes with cachedIn(viewModelScope) so rotating the screen doesn't
re-fetch every page from scratch:
CommunityFeedViewModel.kt
class CommunityFeedViewModel(
private val api: RecipeApiService
) : ViewModel() {
val recipePagingData: Flow<PagingData<Recipe>> = Pager(
config = PagingConfig(pageSize = 20, prefetchDistance = 5),
pagingSourceFactory = { CommunityRecipePagingSource(api) }
).flow.cachedIn(viewModelScope)
}
prefetchDistance = 5 tells Paging 3 to start loading the next page once the user has
scrolled to within 5 items of the end of what's currently loaded — the next page is usually ready
before the user even reaches the bottom.
collectAsLazyPagingItems() Inside a LazyColumn
The Compose integration artifact (androidx.paging:paging-compose) provides
collectAsLazyPagingItems(), which turns that Flow<PagingData<Recipe>>
into a LazyPagingItems<Recipe> you can hand straight to a LazyColumn's
items():
CommunityFeedScreen.kt
@Composable
fun CommunityFeedScreen(
viewModel: CommunityFeedViewModel = viewModel()
) {
val recipes = viewModel.recipePagingData.collectAsLazyPagingItems()
LazyColumn(contentPadding = PaddingValues(16.dp)) {
items(count = recipes.itemCount) { index ->
val recipe = recipes[index]
if (recipe != null) {
RecipeRow(recipe = recipe)
}
}
when (val appendState = recipes.loadState.append) {
is LoadState.Loading -> {
item {
Box(modifier = Modifier.fillMaxWidth().padding(16.dp), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
}
}
is LoadState.Error -> {
item {
Column(
modifier = Modifier.fillMaxWidth().padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(text = "Couldn't load more recipes")
Button(onClick = { recipes.retry() }) {
Text("Retry")
}
}
}
}
else -> Unit
}
}
}
recipes.itemCount and recipes[index] are how LazyPagingItems
plugs into items() — indexing past what's currently loaded returns null
and quietly triggers Paging 3 to load the page that index belongs to, which is why the composable
handles a nullable recipe. recipes.loadState.append reflects the state of
loading more pages onto the end of the list specifically — separate from
loadState.refresh, which reflects the very first page load — so a failure appending
page 6 shows an inline retry row at the bottom without disturbing the 5 pages already displayed
above it.