A social media timeline is one of the most demanding feeds to build: it must load new posts at the top, paginate older posts at the bottom, support pull-to-refresh, show local optimistic updates, and remain fast with thousands of items.
Paging 3: The Core Tool
Paging 3 is Jetpack's pagination library. It defines a PagingSource that loads pages of data and exposes a PagingData<T> stream that LazyColumn consumes directly.
// PagingSource: loads one page at a time
class PostPagingSource(
private val api: FeedApi,
private val dao: PostDao
) : PagingSource<String, Post>() { // String = cursor key
override fun getRefreshKey(state: PagingState<String, Post>): String? = null
override suspend fun load(params: LoadParams<String>): LoadResult<String, Post> = try {
val cursor = params.key // null = first page
val response = api.getTimeline(cursor = cursor, limit = params.loadSize)
LoadResult.Page(
data = response.posts,
prevKey = null, // feed doesn't load newer via paging — use pull-to-refresh
nextKey = response.nextCursor // null = no more pages
)
} catch (e: IOException) {
LoadResult.Error(e)
} catch (e: HttpException) {
LoadResult.Error(e)
}
}
Repository: Offline-First with RemoteMediator
RemoteMediator coordinates network + Room database — load from network, cache to Room, serve Room to the UI:
@OptIn(ExperimentalPagingApi::class)
class PostRemoteMediator(
private val api: FeedApi,
private val db: AppDatabase
) : RemoteMediator<Int, PostEntity>() {
override suspend fun load(
loadType: LoadType,
state: PagingState<Int, PostEntity>
): MediatorResult {
val page = when (loadType) {
LoadType.REFRESH -> 1
LoadType.PREPEND -> return MediatorResult.Success(endOfPaginationReached = true)
LoadType.APPEND -> {
val lastItem = state.lastItemOrNull()
?: return MediatorResult.Success(endOfPaginationReached = false)
db.remoteKeyDao().getKey(lastItem.id)?.nextPage
?: return MediatorResult.Success(endOfPaginationReached = true)
}
}
return try {
val response = api.getTimeline(page = page, limit = state.config.pageSize)
db.withTransaction {
if (loadType == LoadType.REFRESH) {
db.postDao().clearAll()
db.remoteKeyDao().clearAll()
}
db.postDao().insertAll(response.posts.map { it.toEntity() })
db.remoteKeyDao().insertAll(response.posts.map {
RemoteKey(postId = it.id, nextPage = page + 1)
})
}
MediatorResult.Success(endOfPaginationReached = response.posts.isEmpty())
} catch (e: IOException) {
MediatorResult.Error(e)
}
}
}
// Repository
class FeedRepository(private val db: AppDatabase, private val api: FeedApi) {
@OptIn(ExperimentalPagingApi::class)
fun getTimeline(): Flow<PagingData<Post>> = Pager(
config = PagingConfig(pageSize = 20, enablePlaceholders = false),
remoteMediator = PostRemoteMediator(api, db),
pagingSourceFactory = { db.postDao().pagingSource() } // Room-backed source
).flow
}
ViewModel and UI
class FeedViewModel(private val repository: FeedRepository) : ViewModel() {
val posts: StateFlow<PagingData<Post>> = repository.getTimeline()
.cachedIn(viewModelScope) // ← critical: cache across recompositions
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), PagingData.empty())
}
@Composable
fun FeedScreen(viewModel: FeedViewModel = viewModel()) {
val posts = viewModel.posts.collectAsLazyPagingItems()
val pullRefreshState = rememberPullRefreshState(
refreshing = posts.loadState.refresh is LoadState.Loading,
onRefresh = { posts.refresh() }
)
Box(Modifier.pullRefresh(pullRefreshState)) {
LazyColumn {
items(
count = posts.itemCount,
key = posts.itemKey { it.id } // stable keys prevent flicker on refresh
) { index ->
val post = posts[index]
if (post != null) {
PostCard(post = post)
} else {
PostPlaceholder() // placeholder while loading
}
}
// Loading indicator at bottom
when (val appendState = posts.loadState.append) {
is LoadState.Loading -> item { CircularProgressIndicator() }
is LoadState.Error -> item {
RetryButton(onRetry = { posts.retry() })
}
else -> {}
}
}
PullRefreshIndicator(
refreshing = posts.loadState.refresh is LoadState.Loading,
state = pullRefreshState,
modifier = Modifier.align(Alignment.TopCenter)
)
}
}
Key Takeaways
| Concept | Rule |
|---|---|
PagingSource | Load-on-demand source; return LoadResult.Page with next key |
RemoteMediator | Sync network → Room; UI always reads from Room |
cachedIn(viewModelScope) | Cache PagingData in ViewModel — survive recomposition |
collectAsLazyPagingItems() | Bridge PagingData to Compose LazyColumn |
key = posts.itemKey { it.id } | Stable keys prevent RecyclerView / LazyColumn flicker on refresh |
posts.refresh() | Pull-to-refresh resets to page 1 |
posts.retry() | Retry last failed page load |