How To Create Easy Pagination In Jetpack Compose

Learn to build smooth, efficient pagination in Jetpack Compose using Firestore queries and LazyColumn—a lightweight, flexible alternative to the Paging 3 library for simple data-loading scenarios.
Dec 2 2024 · 10 min read

Introduction

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.

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 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.

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.

Bonus: Managing Pagination with Multiple Data Sources

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.
 

Step 1: Defining a Common Interface

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

Step 2: Creating Data Services

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
    }
}

Step 3: ViewModel Implementation

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.

Step 4: UI Implementation: Displaying a Combined 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: Displays individual feed items (Movies or TV Shows) with a title, description, release date, and badge.
  • PosterBackgroundWithOverlay: Displays the poster image with a gradient overlay for text readability.
  • Pagination Helper: LazyListState.reachedBottom() determines if the user has scrolled to the bottom of the list.

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.

Advantages of This Approach

  1. Lightweight: No need for the Paging Library or extra dependencies.
  2. Customizable: Full control over the pagination logic and UI behavior.
  3. Optimized for Firestore: Uses Firestore’s native query capabilities for efficient data fetching.
  4. Simple to Implement: Easy to understand and adapt for different use cases.

Conclusion

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.

Popular Articles

 


Code, Build, Repeat.
Stay updated with the latest trends and tutorials in Android, iOS, and web development.
megh-l image
Megh Lath
Android developer | Sharing knowledge of Jetpack Compose & android development
megh-l image
Megh Lath
Android developer | Sharing knowledge of Jetpack Compose & android development
canopas-logo
We build products that customers can't help but love!
Get in touch

Whether you need...

  • *
    High-performing mobile apps
  • *
    Bulletproof cloud solutions
  • *
    Custom solutions for your business.
Bring us your toughest challenge and we'll show you the path to a sleek solution.
Talk To Our Experts
footer
Follow us on
2024 Canopas Software LLP. All rights reserved.