Jetpack Compose: Handling Long Lists with Paging

In modern Android development, dealing with long lists of data efficiently is crucial for providing a smooth user experience. When displaying a large dataset, loading all the data at once can lead to performance issues and slow UI rendering. Jetpack Compose offers powerful tools for handling long lists efficiently, and combining it with the Paging library can significantly enhance performance. This post explores how to use the Paging library with Jetpack Compose to handle long lists gracefully.

What is Paging?

The Paging library is part of Android Jetpack and is designed to load and display data incrementally—on demand—from a larger dataset. Instead of loading all the data at once, Paging loads data in smaller chunks as the user scrolls through the list. This approach reduces the initial load time, conserves resources, and provides a seamless scrolling experience.

Why Use Paging with Jetpack Compose?

  • Improved Performance: Reduces initial load time and memory usage.
  • Seamless Scrolling: Loads data in chunks as the user scrolls, providing a smooth experience.
  • Efficient Resource Utilization: Conserves bandwidth and battery by only loading necessary data.
  • Easy Integration: Combines seamlessly with Jetpack Compose’s declarative UI approach.

How to Implement Paging in Jetpack Compose

To implement Paging with Jetpack Compose, follow these steps:

Step 1: Add Dependencies

Ensure you have the necessary dependencies in your build.gradle file:


dependencies {
    implementation("androidx.paging:paging-runtime-ktx:3.2.1")
    implementation("androidx.paging:paging-compose:3.2.1")
    implementation("androidx.compose.runtime:runtime-livedata:\$compose_version")
}
  • paging-runtime-ktx: Provides the Paging library runtime components.
  • paging-compose: Provides integration of Paging with Jetpack Compose.
  • runtime-livedata: Allows using LiveData in Compose.

Step 2: Define a PagingSource

Create a PagingSource class to define how data is fetched. A PagingSource is responsible for loading data from a source (e.g., network or database) and providing it to the Paging library.


import androidx.paging.PagingSource
import androidx.paging.PagingState
import kotlinx.coroutines.delay

private const val STARTING_KEY = 0

class MyPagingSource : PagingSource<Int, String>() {
    override suspend fun load(params: LoadParams<Int>): LoadResult<Int, String> {
        val page = params.key ?: STARTING_KEY
        val pageSize = params.loadSize

        return try {
            // Simulate network delay
            delay(1000)

            val data = List(pageSize) { "Item ${page * pageSize + it}" }
            val nextKey = if (data.isEmpty()) null else page + 1
            val prevKey = if (page == STARTING_KEY) null else page - 1

            LoadResult.Page(
                data = data,
                prevKey = prevKey,
                nextKey = nextKey
            )
        } catch (e: Exception) {
            LoadResult.Error(e)
        }
    }

    override fun getRefreshKey(state: PagingState<Int, String>): Int? {
        return state.anchorPosition?.let { anchorPosition ->
            state.closestPageToPosition(anchorPosition)?.prevKey?.plus(1)
                ?: state.closestPageToPosition(anchorPosition)?.nextKey?.minus(1)
        }
    }
}

Key points:

  • The load function fetches data based on the provided LoadParams.
  • Simulate a delay to represent network latency.
  • Returns a LoadResult.Page with the data, previous key, and next key.
  • Handles exceptions and returns LoadResult.Error in case of failure.

Step 3: Create a ViewModel

Create a ViewModel to expose the PagingData to your Composable. Use Pager to generate the Flow<PagingData<YourDataType>>.


import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.cachedIn
import kotlinx.coroutines.flow.Flow

class MyViewModel : ViewModel() {
    val items: Flow<PagingData<String>> = Pager(
        PagingConfig(pageSize = 20)
    ) {
        MyPagingSource()
    }.flow.cachedIn(viewModelScope)
}

Here, Pager is used to create a Flow of PagingData, and cachedIn(viewModelScope) ensures the data is cached to survive configuration changes.

Step 4: Use collectAsLazyPagingItems in Compose

In your Composable, collect the PagingData as LazyPagingItems using the collectAsLazyPagingItems() extension function. Use this with a LazyColumn or LazyRow.


import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.paging.compose.collectAsLazyPagingItems
import androidx.paging.compose.itemKey
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.material.CircularProgressIndicator
import androidx.compose.paging.LoadState

@Composable
fun MyListScreen() {
    val viewModel: MyViewModel = viewModel()
    val items = viewModel.items.collectAsLazyPagingItems()

    LazyColumn(
        contentPadding = PaddingValues(16.dp),
        verticalArrangement = Arrangement.spacedBy(8.dp),
        modifier = Modifier.fillMaxSize()
    ) {
        items(
            count = items.itemCount,
            key = items.itemKey { it } // Use itemKey to improve performance
        ) { index ->
            val item = items[index]
            if (item != null) {
                Text(text = item, modifier = Modifier.padding(8.dp))
            } else {
                // Handle the case where item is null
                Text(text = "Loading...", modifier = Modifier.padding(8.dp))
            }
        }

        items.apply {
            when {
                loadState.refresh is LoadState.Loading -> {
                    item {
                        Box(
                            modifier = Modifier
                                .fillMaxWidth()
                                .padding(16.dp),
                            contentAlignment = Alignment.Center
                        ) {
                            CircularProgressIndicator()
                        }
                    }
                }

                loadState.append is LoadState.Loading -> {
                    item {
                        Box(
                            modifier = Modifier
                                .fillMaxWidth()
                                .padding(16.dp),
                            contentAlignment = Alignment.Center
                        ) {
                            CircularProgressIndicator()
                        }
                    }
                }

                loadState.refresh is LoadState.Error -> {
                    val e = loadState.refresh as LoadState.Error
                    item {
                        Text(
                            text = "Error: ${e.error.localizedMessage}",
                            modifier = Modifier.padding(8.dp)
                        )
                    }
                }

                loadState.append is LoadState.Error -> {
                    val e = loadState.append as LoadState.Error
                    item {
                        Text(
                            text = "Error: ${e.error.localizedMessage}",
                            modifier = Modifier.padding(8.dp)
                        )
                    }
                }
            }
        }
    }
}

Important notes:

  • collectAsLazyPagingItems() collects the PagingData as LazyPagingItems, which can be efficiently used with LazyColumn or LazyRow.
  • Use items.itemKey for stable item keys, optimizing recomposition performance.
  • Check for null items using an if (item != null), which avoids potential errors during loading/refreshing.
  • Display loading indicators, errors or empty states, by using loadState.refresh, and loadState.append.

Complete Example


// PagingSource
import androidx.paging.PagingSource
import androidx.paging.PagingState
import kotlinx.coroutines.delay

private const val STARTING_KEY = 0

class MyPagingSource : PagingSource<Int, String>() {
    override suspend fun load(params: LoadParams<Int>): LoadResult<Int, String> {
        val page = params.key ?: STARTING_KEY
        val pageSize = params.loadSize

        return try {
            // Simulate network delay
            delay(1000)

            val data = List(pageSize) { "Item ${page * pageSize + it}" }
            val nextKey = if (data.isEmpty()) null else page + 1
            val prevKey = if (page == STARTING_KEY) null else page - 1

            LoadResult.Page(
                data = data,
                prevKey = prevKey,
                nextKey = nextKey
            )
        } catch (e: Exception) {
            LoadResult.Error(e)
        }
    }

    override fun getRefreshKey(state: PagingState<Int, String>): Int? {
        return state.anchorPosition?.let { anchorPosition ->
            state.closestPageToPosition(anchorPosition)?.prevKey?.plus(1)
                ?: state.closestPageToPosition(anchorPosition)?.nextKey?.minus(1)
        }
    }
}

// ViewModel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.cachedIn
import kotlinx.coroutines.flow.Flow
import androidx.paging.PagingData

class MyViewModel : ViewModel() {
    val items: Flow<PagingData<String>> = Pager(
        PagingConfig(pageSize = 20)
    ) {
        MyPagingSource()
    }.flow.cachedIn(viewModelScope)
}

// Compose UI
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.paging.compose.collectAsLazyPagingItems
import androidx.paging.compose.itemKey
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.material.CircularProgressIndicator
import androidx.compose.paging.LoadState
import androidx.compose.ui.tooling.preview.Preview

@Composable
fun MyListScreen() {
    val viewModel: MyViewModel = viewModel()
    val items = viewModel.items.collectAsLazyPagingItems()

    LazyColumn(
        contentPadding = PaddingValues(16.dp),
        verticalArrangement = Arrangement.spacedBy(8.dp),
        modifier = Modifier.fillMaxSize()
    ) {
        items(
            count = items.itemCount,
            key = items.itemKey { it } // Use itemKey to improve performance
        ) { index ->
            val item = items[index]
            if (item != null) {
                Text(text = item, modifier = Modifier.padding(8.dp))
            } else {
                // Handle the case where item is null
                Text(text = "Loading...", modifier = Modifier.padding(8.dp))
            }
        }

        items.apply {
            when {
                loadState.refresh is LoadState.Loading -> {
                    item {
                        Box(
                            modifier = Modifier
                                .fillMaxWidth()
                                .padding(16.dp),
                            contentAlignment = Alignment.Center
                        ) {
                            CircularProgressIndicator()
                        }
                    }
                }

                loadState.append is LoadState.Loading -> {
                    item {
                        Box(
                            modifier = Modifier
                                .fillMaxWidth()
                                .padding(16.dp),
                            contentAlignment = Alignment.Center
                        ) {
                            CircularProgressIndicator()
                        }
                    }
                }

                loadState.refresh is LoadState.Error -> {
                    val e = loadState.refresh as LoadState.Error
                    item {
                        Text(
                            text = "Error: ${e.error.localizedMessage}",
                            modifier = Modifier.padding(8.dp)
                        )
                    }
                }

                loadState.append is LoadState.Error -> {
                    val e = loadState.append as LoadState.Error
                    item {
                        Text(
                            text = "Error: ${e.error.localizedMessage}",
                            modifier = Modifier.padding(8.dp)
                        )
                    }
                }
            }
        }
    }
}

@Preview(showBackground = true)
@Composable
fun PreviewMyListScreen() {
    MyListScreen()
}

Conclusion

Using Paging with Jetpack Compose enables you to efficiently handle long lists, providing a smoother user experience with improved performance. By loading data in smaller chunks and integrating seamlessly with Compose’s declarative UI, you can create responsive and user-friendly Android applications. Implementing the Paging library involves defining a PagingSource, creating a ViewModel to manage the PagingData, and using collectAsLazyPagingItems in your Compose UI to display the data efficiently.