Introduction
When building apps with long lists of data, the Jetpack Paging Library is often the go-to solution. However, it can be overkill for scenarios where you need basic pagination or when working with APIs and databases that provide simple offset or query-based data retrieval.
In this blog post, I’ll show you how to implement smooth pagination in Jetpack Compose without using the Paging 3 library. Instead, we’ll use Firestore queries and Compose’s LazyColumn to achieve an elegant, lightweight solution that is easier to understand and adapt.
Why Use This Approach?
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:
- Simplicity: You have basic pagination needs and prefer a more straightforward solution.
- Control: You want full customization over how and when data is fetched and displayed.
- Firestore Optimization: This approach takes advantage of Firestore’s native query capabilities, making it ideal for apps using Firebase.
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.
Step 1: Setting Up Dependencies
To get started, ensure your project includes the necessary dependencies for Firebase, Hilt, and Jetpack Compose. You can follow these steps to set up your Firebase project and include the 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.
Step 2: Firestore Integration
To interact with Firestore, define a Movie
data model and a MovieService
for fetching data.
Define the Movie Model
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.
Create the Movie Service
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)
}
}
}
- We will use insertMovieDetails function in the first run of app to setup initial/sample movie data in firestore.
- The getMovies function fetches the next page of movies based on the lastCreatedAt timestamp. It queries Firestore for movies created after the lastCreatedAt value, orders them by createdAt, and limits the results to 10 items per page, making it easy to implement pagination.
- The await() function is an extension function that converts a Task to a suspend function.
Step 3: Setting Up the ViewModel
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 insertMovieData function inserts sample movie data into Firestore. This function is used only once to set up the initial dataset.
- The fetchMovieDetails function fetches the next page of movies from Firestore based on the lastCreatedAt timestamp. It updates the moviesList and hasMoreMovies state flows accordingly.
- The loadMoreMovies function is called when the user scrolls to the bottom of the list. It fetches the next page of movies by calling fetchMovieDetails with the lastCreatedAt value of the last movie in the list.
- The showLoader state flow is used to display a loading indicator while fetching data.
- The hasMoreMovies state flow tracks whether there are more movies to load.
- The moviesList state flow holds the list of movies displayed in the UI.
- The distinctBy function ensures that only unique movies are added to the list.
Step 4: Building the Composable UI
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 MoviesListView composable displays the list of movies in a LazyColumn. It fetches the initial dataset when the screen is launched and loads more movies when the user scrolls to the bottom.
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.
Want to dive deeper into testing this pagination method and uncover its key advantages?
Head over to the full guide on the Canopas blog to get all the details and enhance your Jetpack Compose development skills!
If you like what you read, be sure to hit 💖 button! — as a writer it means the world!
I encourage you to share your thoughts in the comments section below. Your input not only enriches our content but also fuels our motivation to create more valuable and informative articles for you.
Happy coding!👋
Top comments (0)