When building apps with long lists of data, efficient pagination is key to ensuring smooth performance and a great user experience. Jetpack's Paging Library is a powerful solution for managing complex data-loading scenarios, but it may be more than necessary for simpler use cases. For scenarios involving straightforward offset- or query-based data retrieval, a custom lightweight solution can offer greater flexibility, ease of implementation, and maintainability.
In this blog post, I’ll show you how to implement smooth and efficient pagination in Jetpack Compose without relying on the Paging 3 library. Instead, we’ll leverage Firestore’s native query capabilities alongside Jetpack Compose’s LazyColumn to create a clean, lightweight, and easy-to-understand solution tailored to simpler use cases.
The Jetpack Paging Library is powerful, but it has its complexities and a steeper learning curve. Here are some scenarios where this custom approach shines:
By the end of this tutorial, you’ll have a working implementation that can dynamically load more data as the user scrolls through a list.
To get started, ensure your project includes the necessary dependencies for Firebase, Hilt, and Jetpack Compose. You can follow these steps to setup firebase project and include required dependencies.
Add these to your build.gradle
file:
// Firebase
implementation(platform("com.google.firebase:firebase-bom:33.6.0"))
implementation("com.google.firebase:firebase-common-ktx:21.0.0")
implementation("com.google.firebase:firebase-firestore-ktx:25.1.1")
// Hilt for Dependency Injection
implementation("com.google.dagger:hilt-android:2.51.1")
kapt("com.google.dagger:hilt-android-compiler:2.51.1")
implementation("androidx.hilt:hilt-navigation-compose:1.2.0")
// Coil for Image Loading
implementation("io.coil-kt:coil-compose:2.7.0")
These libraries will enable Firestore integration, dependency injection with Hilt, and image rendering with Coil.
To interact with Firestore, define a Movie
data model and a MovieService
for fetching data.
The Movie
class represents a movie document in Firestore:
data class Movie(
val id: String = UUID.randomUUID().toString(),
val title: String = "",
val posterUrl: String = "",
val description: String = "",
var createdAt: Long = System.currentTimeMillis()
)
We will use the createdAt
field for sorting and pagination.
We will handle the Firestore queries to fetch the initial dataset and subsequent pages using the MovieService
:
@Singleton
class MovieService @Inject constructor(
db: FirebaseFirestore
) {
private val movieRef = db.collection("movies")
fun insertMovieDetails(
movie: Movie
) {
movieRef.add(movie)
}
suspend fun getMovies(
lastCreatedAt: Long,
loadMore: Boolean = false
): List<Movie> {
return movieRef
.whereGreaterThan("createdAt", lastCreatedAt)
.orderBy("createdAt", Query.Direction.ASCENDING)
.limit(10) // Limit to 10 items per page
.get().await().documents.mapNotNull {
it.toObject(Movie::class.java)
}
}
}
The MoviesListViewModel will handle the pagination logic and expose the list of movies to the UI. It will also manage the loading state and trigger data fetching when needed.
When the app is launched for the first time, we’ll insert some sample movie data into Firestore. This data will be used to demonstrate pagination. And then comment out the insertMovieDetails function.
Here’s the ViewModel implementation:
@HiltViewModel
class MoviesListViewModel @Inject constructor(
private val movieService: MovieService
): ViewModel() {
val moviesList = MutableStateFlow<List<Movie>>(emptyList())
private val hasMoreMovies = MutableStateFlow(true)
val showLoader = MutableStateFlow(false)
// Insert sample movie data into Firestore
// Comment out this function after the first run
fun insertMovieData(
movieList: List<Movie>
) = viewModelScope.launch {
withContext(Dispatchers.IO) {
movieList.forEach { movie ->
delay(1000)
movieService.insertMovieDetails(movie)
moviesList.value += movie
}
}
}
// Fetch movie details from Firestore
// Load more movies if loadMore is true
fun fetchMovieDetails(
lastCreatedAt: Long,
loadMore: Boolean = false
) = viewModelScope.launch(Dispatchers.IO) {
if (loadMore && !hasMoreMovies.value) return@launch
showLoader.tryEmit(true) // Show loading indicator
if (loadMore) delay(3000) // Simulate loading delay
val movies = movieService.getMovies(lastCreatedAt, loadMore) // Fetch movies
moviesList.tryEmit((moviesList.value + movies).distinctBy { it.id }) // Update the list of movies with unique items
hasMoreMovies.tryEmit(movies.isNotEmpty()) // Check if there are more movies to load
showLoader.tryEmit(false) // Hide loading indicator
}
// Load more movies when the user scrolls to the bottom
// Triggered by the LazyColumn's reachedBottom state
fun loadMoreMovies() {
val lastCreatedAt = moviesList.value.last().createdAt
fetchMovieDetails(lastCreatedAt, loadMore = true)
}
}
The UI consists of a LazyColumn
that displays the list of movies and triggers data loading when the user scrolls to the bottom.
@Composable
fun MoviesListView(paddingValue: PaddingValues) {
val viewmodel = hiltViewModel<MoviesListViewModel>()
val moviesList by viewmodel.moviesList.collectAsState()
val showLoader by viewmodel.showLoader.collectAsState()
// Called only once to insert sample movie data on first run
/*val sampleMovies = remember {
mutableStateOf(MovieUtils.movies)
}
LaunchedEffect(Unit) {
viewmodel.insertMovieData(sampleMovies.value)
}*/
LaunchedEffect(Unit) {
// Fetch movie details when the screen is launched
viewmodel.fetchMovieDetails(System.currentTimeMillis())
}
val lazyState = rememberLazyListState()
// Check if the user has scrolled to the bottom of the list
val reachedBottom by remember {
derivedStateOf {
lazyState.reachedBottom() // Custom extension function to check if the user has reached the bottom
}
}
LaunchedEffect(reachedBottom) {
// Load more movies when the user reaches the bottom of the list and there are more movies to load
if (reachedBottom && moviesList.isNotEmpty()) {
viewmodel.loadMoreMovies()
}
}
LazyColumn(
state = lazyState,
modifier = Modifier
.fillMaxSize()
.padding(paddingValues = paddingValue)
) {
itemsIndexed(moviesList) { _, movie ->
Box(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
.border(
width = 1.dp,
color = Color.Gray
)
) {
MovieCard(movie = movie)
}
}
// Show loading indicator at the end of the list when loading more movies
item {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
.heightIn(min = 20.dp), contentAlignment = Alignment.Center
) {
if (showLoader) {
CircularProgressIndicator()
}
Spacer(
modifier = Modifier
.fillMaxWidth()
.height(20.dp)
)
}
}
}
}
@Composable
private fun MovieCard(movie: Movie) {
val imageLoader = LocalContext.current.imageLoader.newBuilder()
.logger(DebugLogger())
.build()
Box(
modifier = Modifier
.size(200.dp)
.background(Color.Black, shape = MaterialTheme.shapes.large)
) {
Image(
painter = rememberAsyncImagePainter(model = movie.posterUrl, imageLoader = imageLoader),
contentDescription = null,
modifier = Modifier
.fillMaxSize()
)
}
}
The reachedBottom extension function checks if the user has scrolled to the bottom of the list. This function is called in a LaunchedEffect block to load more movies when the user reaches the end of the list.
fun LazyListState.reachedBottom(): Boolean {
val visibleItemsInfo = layoutInfo.visibleItemsInfo // Get the visible items
return if (layoutInfo.totalItemsCount == 0) {
false // Return false if there are no items
} else {
val lastVisibleItem = visibleItemsInfo.last() // Get the last visible item
val viewportHeight =
layoutInfo.viewportEndOffset +
layoutInfo.viewportStartOffset // Calculate the viewport height
// Check if the last visible item is the last item in the list and fully visible
// This indicates that the user has scrolled to the bottom
(lastVisibleItem.index + 1 == layoutInfo.totalItemsCount &&
lastVisibleItem.offset + lastVisibleItem.size <= viewportHeight)
}
}
And that’s it! You’ve created a simple yet effective pagination system in Jetpack Compose using Firestore queries and LazyColumn.
In some applications, fetching and displaying data from multiple sources is a common requirement. For example, you might need to combine and display a list of movies and TV shows in a single feed. This can be challenging when implementing pagination, as you need to ensure that the data is seamlessly merged and presented in an interleaved manner.
In this example, we fetch paginated data from two Firestore collections, movies and tvShows, and combine them into a single feed. We also ensure that the feed is updated dynamically as users load more content.
To handle both data types uniformly, we create a common interface, FeedItem, with shared properties like id and createdAt.
interface FeedItem {
val id: String
val createdAt: Long
}
Each specific type of item (e.g., Movie or TVShow) implements this interface:
data class Movie(
override val id: String = UUID.randomUUID().toString(),
val title: String = "",
val posterUrl: String = "",
val description: String = "",
override var createdAt: Long = System.currentTimeMillis()
) : FeedItem
data class TVShow(
override val id: String = UUID.randomUUID().toString(),
val title: String = "",
val posterUrl: String = "",
val description: String = "",
override var createdAt: Long = System.currentTimeMillis()
) : FeedItem
Each collection in Firestore gets its own service class. These classes handle data retrieval and insertion.
MovieService: We will keep the existing `MovieService` class for fetching movie data as implemented in the previous example.
TVShowService: Fetches TV shows from Firestore with pagination.
class TVShowService @Inject constructor(
db: FirebaseFirestore
) {
private val tvShowRef = db.collection("tvShows")
fun insertTVShowDetails(tvShow: TVShow) {
tvShowRef.add(tvShow)
}
suspend fun getTVShows(lastCreatedAt: Long, loadMore: Boolean = false): List<TVShow> {
val tvShows = if (loadMore) {
tvShowRef
.whereGreaterThan("createdAt", lastCreatedAt)
.orderBy("createdAt", Query.Direction.ASCENDING)
.limit(10)
.get().await().documents.mapNotNull {
it.toObject(TVShow::class.java)
}
} else {
tvShowRef
.whereLessThan("createdAt", lastCreatedAt)
.orderBy("createdAt", Query.Direction.ASCENDING)
.limit(10)
.get().await().documents.mapNotNull {
it.toObject(TVShow::class.java)
}
}
return tvShows
}
}
The CombinedFeedViewModel combines data from both sources and maintains the merged feed in a StateFlow.
@HiltViewModel
class CombinedFeedViewModel @Inject constructor(
private val movieService: MovieService,
private val tvShowService: TVShowService
): ViewModel() {
val combinedFeed = MutableStateFlow<List<FeedItem>>(emptyList()) // Holds combined Movies and TVShows
private val hasMoreMovies = MutableStateFlow(true)
private val hasMoreTVShows = MutableStateFlow(true)
val showLoader = MutableStateFlow(false)
fun fetchCombinedFeed(
lastMovieCreatedAt: Long,
lastTVShowCreatedAt: Long = System.currentTimeMillis(),
loadMore: Boolean = false
) = viewModelScope.launch(Dispatchers.IO) {
if (loadMore && !hasMoreMovies.value && !hasMoreTVShows.value) return@launch
showLoader.tryEmit(true) // Show loading indicator
// Fetch data from both sources
val movies = movieService.getMovies(lastMovieCreatedAt, loadMore)
val tvShows = tvShowService.getTVShows(lastTVShowCreatedAt, loadMore)
// Combine and interleave the feeds
val interleavedFeed = mergeAndInterleave(
combinedFeed.value.filterIsInstance<Movie>(),
movies,
combinedFeed.value.filterIsInstance<TVShow>(),
tvShows
)
combinedFeed.tryEmit(interleavedFeed.distinctBy { it.id })
hasMoreMovies.tryEmit(movies.isNotEmpty())
hasMoreTVShows.tryEmit(tvShows.isNotEmpty())
showLoader.tryEmit(false) // Hide loading indicator
}
private fun <T> mergeAndInterleave(
oldList1: List<T>,
newList1: List<T>,
oldList2: List<T>,
newList2: List<T>
): List<T> {
val list1 = (oldList1 + newList1).toMutableList()
val list2 = (oldList2 + newList2).toMutableList()
val result = mutableListOf<T>()
while (list1.isNotEmpty() || list2.isNotEmpty()) {
if (list1.isNotEmpty()) result.add(list1.removeAt(0))
if (list2.isNotEmpty()) result.add(list2.removeAt(0))
}
return result
}
fun loadMore() {
fetchCombinedFeed(
lastMovieCreatedAt = combinedFeed.value.filterIsInstance<Movie>().lastOrNull()?.createdAt ?: 0L,
lastTVShowCreatedAt = combinedFeed.value.filterIsInstance<TVShow>().lastOrNull()?.createdAt ?: 0L,
loadMore = true
)
}
}
Here, mergeAndInterleave combines items from both sources, ensuring an interleaved output. The loadMore function fetches the next batch of data from both collections and updates the feed.
Now that we have successfully fetched and interleaved data from multiple sources in the ViewModel, let's focus on how to display this data in the UI. The UI will showcase a dynamic feed combining Movies and TV Shows while supporting pagination.
Key UI Features:
- Dynamic Feed: Displays a list of interleaved Movie and TVShow items, identified with badges.
- Pagination: Automatically fetches more data when the user scrolls to the bottom.
- Loader Display: Shows a loading indicator during data fetching.
Below is the implementation:
@Composable
fun CombinedFeedView(paddingValue: PaddingValues) {
val viewModel = hiltViewModel<MoviesListViewModel>()
val combinedFeed by viewModel.combinedFeed.collectAsState()
val showLoader by viewModel.showLoader.collectAsState()
// Initialize feed on first render
LaunchedEffect(Unit) {
viewModel.fetchCombinedFeed(System.currentTimeMillis())
}
val lazyState = rememberLazyListState()
// Detect when the user scrolls to the bottom
val reachedBottom by remember {
derivedStateOf {
lazyState.reachedBottom()
}
}
LaunchedEffect(reachedBottom) {
if (reachedBottom && combinedFeed.isNotEmpty()) {
viewModel.loadMore()
}
}
LazyColumn(
state = lazyState,
modifier = Modifier
.fillMaxSize()
.padding(paddingValues = paddingValue)
) {
// Render each feed item
itemsIndexed(combinedFeed) { _, item ->
when (item) {
is Movie -> ItemCard(
badgeText = "Movie",
title = item.title,
description = item.description,
posterUrl = item.posterUrl,
createdAt = item.createdAt
)
is TVShow -> ItemCard(
badgeText = "TV Show",
title = item.title,
description = item.description,
posterUrl = item.posterUrl,
createdAt = item.createdAt
)
else -> {}
}
}
// Display a loader at the bottom of the feed if fetching more data
item {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
.heightIn(min = 20.dp),
contentAlignment = Alignment.Center
) {
if (showLoader) {
CircularProgressIndicator()
}
}
}
}
}
Helper Composables:
ItemCard Composable:
@Composable
private fun ItemCard(
badgeText: String,
title: String,
description: String,
posterUrl: String,
createdAt: Long
) {
val imageLoader = LocalContext.current.imageLoader.newBuilder()
.logger(DebugLogger())
.build()
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
.border(
width = 1.dp,
color = Color.Gray
)
) {
Text(
text = badgeText,
style = MaterialTheme.typography.body2,
color = Color.White,
backgroundColor = Color.Gray,
modifier = Modifier
.padding(4.dp)
.align(Alignment.End)
)
PosterBackgroundWithOverlay(
posterUrl = posterUrl,
imageLoader = imageLoader,
modifier = Modifier
.fillMaxWidth()
.height(200.dp)
)
Text(
text = title,
style = MaterialTheme.typography.h6,
modifier = Modifier.padding(8.dp)
)
Text(
text = description,
style = MaterialTheme.typography.body2,
modifier = Modifier.padding(8.dp)
)
Text(
text = "Released on: ${Instant.ofEpochMilli(createdAt).atZone(ZoneId.systemDefault()).toLocalDate()}",
style = MaterialTheme.typography.caption,
modifier = Modifier.padding(8.dp)
)
}
}
PosterBackgroundWithOverlay Composable:
@Composable
private fun PosterBackgroundWithOverlay(
posterUrl: String,
imageLoader: ImageLoader,
modifier: Modifier = Modifier
) {
Box(
modifier = modifier
) {
Image(
painter = rememberAsyncImagePainter(model = posterUrl, imageLoader = imageLoader),
contentDescription = null,
modifier = Modifier
.fillMaxSize()
)
Box(
modifier = Modifier
.fillMaxSize()
.background(
brush = Brush.verticalGradient(
colors = listOf(Color.Transparent, Color.Black),
startY = 0f,
endY = 200f
)
)
)
}
}
With this UI, the app now provides a seamless experience for browsing combined Movie and TV Show feeds with automatic pagination. This approach can easily be extended to other types of content sources by adhering to the same FeedItem interface structure.
With this method, you can create smooth, efficient pagination in Jetpack Compose without relying on complex libraries. This approach is especially suitable for apps using Firestore or APIs that support offsets or continuation tokens. By combining Firestore queries with Jetpack Compose’sLazyColumn
, you get a customizable solution tailored to your needs.
Whether you need...