The Ops Community ⚙️

Cover image for How To Create Easy Pagination In Jetpack Compose
Nandani Sharma
Nandani Sharma

Posted on • Originally published at canopas.com

How To Create Easy Pagination In Jetpack Compose

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")
Enter fullscreen mode Exit fullscreen mode

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()
)
Enter fullscreen mode Exit fullscreen mode

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)
            }
    }
}
Enter fullscreen mode Exit fullscreen mode
  • 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)
    }
}
Enter fullscreen mode Exit fullscreen mode
  • 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()
        )
    }
}
Enter fullscreen mode Exit fullscreen mode
  • 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)
    }
}
Enter fullscreen mode Exit fullscreen mode

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)