DevAndAndroid

Build for humans, not just for screens.


5 steps to use Paging3 library with Jetpack Compose

I had a chance to work with the Paging 3 library, along with Jetpack Compose. This guide shares some of the basics of the paging library, along with a guide on how to implement it in any android app that is using Jetpack Compose.

So I chose to build a demo news feed app to test the paging library. This is the app.

For those of you interested in skipping the article, the GitHub link is here. So let’s begin!

Step 1: Add dependencies

I have used Hilt and KSP (Kotlin Symbol Processing — similar to KAPT) for injecting our data layer with our UI layer. The other libraries used here are Coil for displaying contact URI image.

I have also used Gradle’s version catalogs to declare dependencies in the app.

You can checkout my other articles on version catalogs and Hilt to get a deeper understanding of what is required here. For now, I’ll just share the relevant files used to add these dependencies.

[versions]
agp = "8.7.2"
kotlin = "2.0.21"
coreKtx = "1.15.0"
junit = "4.13.2"
junitVersion = "1.2.1"
espressoCore = "3.6.1"
lifecycleRuntimeKtx = "2.8.7"
activityCompose = "1.9.3"
composeBom = "2024.11.00"
retrofit = "2.11.0"
hilt = "2.52"
hiltNavigationCompose = "1.2.0"
ksp = "2.0.21-1.0.28"
pagingVersion = "3.3.4"
coil = "2.6.0"
okhttp = "4.12.0"
lifecycleRuntimeComposeAndroid = "2.8.7"
mockitoVersion = "5.7.0"
mockitoKotlin = "5.2.1"
coroutineTestVersion = "1.9.0-RC"
mockServerVersion = "4.9.1"
turbineVersion = "1.1.0"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
## Retrofit
retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
retrofit-gson-converter = { group = "com.squareup.retrofit2", name = "converter-gson", version.ref = "retrofit" }
retrofit-mock = { group = "com.squareup.okhttp3", name = "mockwebserver", version.ref = "mockServerVersion" }
## okhttp
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
okhttp-logging-interceptor = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" }
## Hilt
hilt-android-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt" }
hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" }
hilt-android-testing = { group = "com.google.dagger", name = "hilt-android-testing", version.ref = "hilt" }
hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hiltNavigationCompose" }
## Paging
paging = { group = "androidx.paging", name = "paging-runtime", version.ref = "pagingVersion" }
paging-compose = { group = "androidx.paging", name = "paging-compose", version.ref = "pagingVersion" }
## Coil
coil = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" }
androidx-lifecycle-runtime-compose-android = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose-android", version.ref = "lifecycleRuntimeComposeAndroid" }
## Mockito
mockito-core = { group = "org.mockito", name = "mockito-core", version.ref = "mockitoVersion" }
mockito-kotlin = { group = "org.mockito.kotlin", name = "mockito-kotlin", version.ref = "mockitoKotlin" }
coroutine-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutineTestVersion" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
jetbrains-kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.jetbrains.kotlin.android) apply false
alias(libs.plugins.ksp) apply false
alias(libs.plugins.hilt) apply false
alias(libs.plugins.compose.compiler) apply false
}
view raw build.gradle hosted with ❤ by GitHub
import java.util.*
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.jetbrains.kotlin.android)
alias(libs.plugins.ksp)
alias(libs.plugins.hilt)
alias(libs.plugins.compose.compiler)
}
android {
namespace = "com.an.paginglib3_sample"
compileSdk = 35
defaultConfig {
applicationId = "com.an.paginglib3_sample"
minSdk = 24
targetSdk = 35
versionCode = 1
versionName = "1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
useSupportLibrary = true
}
//load the values from .properties file
val keystoreFile = project.rootProject.file("local.properties")
val properties = Properties()
properties.load(keystoreFile.inputStream())
buildConfigField("String", "api_key", properties.getProperty("API_KEY") ?: "")
}
buildFeatures {
buildConfig = true
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}
buildFeatures {
compose = true
}
composeOptions {
kotlinCompilerExtensionVersion = "1.5.1"
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
}
dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.activity.compose)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.ui)
implementation(libs.androidx.ui.graphics)
implementation(libs.androidx.ui.tooling.preview)
implementation(libs.androidx.material3)
// Retrofit
implementation(libs.retrofit)
implementation(libs.retrofit.gson.converter)
implementation(libs.okhttp)
implementation(libs.okhttp.logging.interceptor)
// Hilt
implementation(libs.hilt.android)
implementation(libs.hilt.navigation.compose)
ksp(libs.hilt.android.compiler)
kspAndroidTest(libs.hilt.android.compiler)
androidTestImplementation(libs.hilt.android.testing)
// paging
implementation(libs.paging)
implementation(libs.paging.compose)
// Coil
implementation(libs.coil)
// Android Compose Lifecycle
implementation(libs.androidx.lifecycle.runtime.compose.android)
// Mockito
testImplementation(libs.mockito.core)
testImplementation(libs.mockito.kotlin)
testImplementation(libs.coroutine.test)
// Retrofit testing
testImplementation(libs.retrofit.mock)
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.ui.test.junit4)
debugImplementation(libs.androidx.ui.tooling)
debugImplementation(libs.androidx.ui.test.manifest)
}
view raw build.gradle hosted with ❤ by GitHub

We also need to create a custom Application class and annotate it with @HiltAndroidApp and add it to our Manifest file.

@HiltAndroidApp
class PagingLibApp: Application()
view raw PagingLibApp.kt hosted with ❤ by GitHub

Step 2: Add data layer

Now that we have our dependencies, we need to create a model class that holds our news api data.

data class NewsApiResponse(
val status: String,
val totalResults: Long,
val articles: List<Article>
)
data class Article(
val source: Source,
val author: String,
val title: String,
val description: String,
val url: String,
val urlToImage: String,
val content: String,
val publishedAt: String
)

We should be able to create our rest api service class using Retrofit.

interface NewsApiService {
/**
* We would be using the below url:
* https://newsapi.org/v2/everything?q={query}&apiKey={api}&pageSize={pageSize}&page={page}
* It has four query parameters: query, apiKey, page & pageSize
*/
@GET("/v2/everything")
suspend fun fetchFeed(
@Query("q") q: String,
@Query("apiKey") apiKey: String,
@Query("page") page: Long,
@Query("pageSize") pageSize: Int
): NewsApiResponse
}

We also create a Repository class that simply fetches the list of news feed from the api service. We can use Hilt to inject the api service to our repository.

class NewsRepository @Inject constructor(
private val apiService: NewsApiService
) {
suspend fun fetchNews(
query: String,
nextPage: Long
): NewsApiResponse {
return try {
apiService.fetchFeed(
q = query,
apiKey = BuildConfig.api_key,
pageSize = PAGE_SIZE,
page = nextPage
)
} catch (e: Exception) {
e.printStackTrace()
throw Exception(e)
}
}
}

Step 3: Setup the dataSource

In the Paging 3 library, the data source concept is implemented using either PagingSource or RemoteMediator.

  • PagingSource is the core class that serves as the data source in the Paging 3 library. It defines how the data is loaded for a paginated list. It fetches data incrementally (page by page). It supports bidirectional paging (both forward and backward) and handles errors and retries for data loading.
    • A PagingSource implementation involves overriding the load() function, which loads the data for a given page.
    • LoadParams provides the information needed to load a specific page of data: key: The key to identify the page to load (e.g., the starting position or page number). loadSize: The number of items to load in one page.
    • LoadResult is the result of the data load operation: LoadResult.Page: Contains the loaded data and information about the next/previous page keys. LoadResult.Error: Represents a failure to load data.
  • If we need to handle both local and remote data sources together (e.g., caching data in Room while fetching it from a network), we can use RemoteMediator alongside PagingSource.

In this app, we are only using NewsDataSource.

class NewsDataSource @Inject constructor (
private val repository: NewsRepository,
private val query: String
): PagingSource<Long, Article>() {
/**
* Returns the key for the next page to be loaded when refreshing.
*
* This method is used by the Paging library to determine the starting point
* for loading data when the user performs a refresh action (e.g., swipe-to-refresh).
*
* @param state The current state of the Paging system.
* @return The page key to refresh from or null if no valid refresh key exists.
*/
override fun getRefreshKey(state: PagingState<Long, Article>): Long? {
// Try to find the page key of the closest page to the anchor position.
// The anchor position is the most recently accessed index in the list.
return state.anchorPosition?.let { anchorPosition ->
state.closestPageToPosition(anchorPosition)?.prevKey?.plus(1)
?: state.closestPageToPosition(anchorPosition)?.nextKey?.minus(1)
}
}
/**
* Loads a specific page of data based on the given key.
*
* The Paging library calls this method when it needs to load more data.
* This implementation fetches data from the repository using the `query`
* and the `key` (current page number).
*
* @param params Contains information about the requested load size and key.
* @return A LoadResult object containing either the data or an error.
*/
override suspend fun load(params: LoadParams<Long>): LoadResult<Long, Article> {
return try {
// Determine the next page to load. Defaults to 1 if the key is null.
val nextPage = params.key ?: 1L
// Fetch data from the repository.
val newsResponse = repository.fetchNews(query, nextPage)
// Return the successfully loaded data as a LoadResult.Page.
if (newsResponse.articles.isNotEmpty()) {
LoadResult.Page(
data = newsResponse.articles,
prevKey = if (nextPage == 1L) null else nextPage – 1,
nextKey = nextPage.plus(1)
)
} else {
// If no data is available, return an error with a custom message.
LoadResult.Error(Exception("No results found"))
}
} catch (e: Exception) {
// If an error occurs (e.g., network issue), return a LoadResult.Error.
LoadResult.Error(e)
}
}
}

Step 4: Setup the ViewModel

/**
* ViewModel for managing UI state and business logic related to fetching and displaying paginated news.
* Uses Paging 3 library and Compose-friendly state management.
*/
@HiltViewModel
class NewsViewModel @Inject constructor(
private val repository: NewsRepository // Injected repository for fetching news data.
) : ViewModel() {
// Mutable state flow for managing the search input text.
private val _inputText: MutableStateFlow<String> = MutableStateFlow("movies") // Default query is "movies".
val inputText: StateFlow<String> = _inputText // Exposed as immutable state flow for UI.
/**
* A flow that fetches and paginates news articles based on the current search query.
* – Filters out empty queries.
* – Debounces user input to avoid unnecessary API calls.
* – Uses Paging 3's `Pager` to create paginated data streams.
* – Caches the paginated data in `viewModelScope` for efficient re-use.
*/
@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
val news = inputText
.filter { it.isNotEmpty() } // Ensure the query is not empty.
.debounce(300.milliseconds) // Wait 300ms after the last input before processing to reduce API calls.
.flatMapLatest { query -> // Switch to the latest search query.
Pager(
config = PagingConfig(
pageSize = PAGE_SIZE, // Number of items per page.
prefetchDistance = PREFETCH_DISTANCE, // Number of items to prefetch.
initialLoadSize = PAGE_SIZE, // Initial number of items to load.
),
pagingSourceFactory = {
NewsDataSource(repository, query) // Create a new PagingSource for the given query.
}
).flow
.cachedIn(viewModelScope) // Cache the result in the ViewModel's scope.
}
}

Step 5: Add UI layer

Our MainActivity looks like this:

@AndroidEntryPoint
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
PagingLib3SampleTheme {
val viewModel: NewsViewModel = hiltViewModel()
val context = LocalContext.current
HomeScreen(
viewModel = viewModel,
onItemClicked = { context.openUrl(it) },
onShareButtonClicked = { context.share(it) }
)
}
}
}
}
view raw MainActivity.kt hosted with ❤ by GitHub

We simply call a compose function called HomeScreen() .

@Composable
fun HomeScreen(
viewModel: NewsViewModel,
onItemClicked: (url: String) -> Unit,
onShareButtonClicked: (url: String) -> Unit
) {
val news = viewModel.news.collectAsLazyPagingItems()
val inputText = viewModel.inputText.collectAsState()
val searchWidgetState by viewModel.searchWidgetState
Box(
modifier = Modifier.fillMaxSize()
) {
// Different load states – Loading, Empty State, Pager list state
val loadState = news.loadState
when (loadState.refresh) {
is LoadState.Loading -> {
LoadingItem()
}
is LoadState.Error -> {
val error = (loadState.refresh as LoadState.Error).error
ErrorScreen(errorMessage = error.message ?: error.toString()) {
news.refresh()
}
}
else -> {
// News List
val pagerState = rememberPagerState { news.itemCount }
BookPager(
state = pagerState,
modifier = Modifier.fillMaxWidth(),
orientation = BookPagerOrientation.Vertical,
) { page ->
Box(
modifier = Modifier
.clip(RoundedCornerShape(16.dp)),
) {
news[page]?.let {
NewsItem(
modifier = Modifier.align(Alignment.Center),
article = it,
onItemClicked = onItemClicked,
onShareButtonClicked = onShareButtonClicked
)
}
}
}
}
}
}
}
// Pager list item
@Composable
fun NewsItem(
modifier: Modifier = Modifier,
article: Article,
onItemClicked: (url: String) -> Unit,
onShareButtonClicked: (url: String) -> Unit
) {
Column(
modifier = modifier
.background(MaterialTheme.colorScheme.surface)
.widthIn(max = 480.dp)
.clickable { onItemClicked(article.url) },
) {
Box(
modifier = Modifier
.weight(1f)
.fillMaxWidth()
.background(MaterialTheme.colorScheme.onBackground.copy(alpha = .1f))
) {
// Article image
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data(article.urlToImage)
.crossfade(true)
.build(),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize()
)
Row(
modifier = Modifier
.fillMaxWidth()
.align(Alignment.BottomEnd)
.offset(x = (-20).dp, y = 25.dp)
) {
Spacer(modifier = Modifier.weight(1f))
FilledIconButton(
onClick = { onShareButtonClicked(article.url) },
modifier = Modifier.size(48.dp),
colors = IconButtonColors(
contentColor = MaterialTheme.colorScheme.onBackground,
containerColor = MaterialTheme.colorScheme.onBackground,
disabledContentColor = MaterialTheme.colorScheme.onBackground,
disabledContainerColor = MaterialTheme.colorScheme.onBackground
)
) {
Icon(
imageVector = Icons.Rounded.Share,
contentDescription = null,
tint = MaterialTheme.colorScheme.surface,
modifier = Modifier.padding(8.dp)
)
}
}
}
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
// Source name
Text(
text = article.source.name.uppercase(),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = .56f)
)
// Article title
Text(
text = article.title,
style = MaterialTheme.typography.headlineLarge,
color = MaterialTheme.colorScheme.onSurface
)
Spacer(modifier = Modifier.height(4.dp))
// article content
Text(
text = article.description,
maxLines = 5,
overflow = TextOverflow.Ellipsis,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = .84f)
)
Spacer(modifier = Modifier.height(5.dp))
}
}
}
view raw HomeScreen.kt hosted with ❤ by GitHub

I am using a custom animation here for my pager implementation. You can find that composable here.

And that’s pretty much it! Let me know your thoughts in the comments section.


Bonus! Unit testing PagingSource

We can write unit tests for the components in our data layer to ensure that they load the data from the data sources appropriately. 

/**
* Unit tests for the NewsDataSource class to ensure correct behavior
* when loading paginated data from the repository.
*/
class NewsDataSourceTest {
// Mocked repository to simulate API responses.
private val repository: NewsRepository = mock()
private val query = "movies" // Query used for testing.
private val pageSize = 20 // Number of items per page.
private val firstPage = 1L // Starting page number.
// NewsDataSource under test.
private val dataSource = NewsDataSource(repository, query)
// Predefined list of mock articles to simulate API responses.
private val expectedArticles = listOf(
Article(
source = Source("", ""), author = "", title = "", description = "",
url = "", urlToImage = "", content = "", publishedAt = ""
),
Article(
source = Source("", ""), author = "", title = "", description = "",
url = "", urlToImage = "", content = "", publishedAt = ""
),
Article(
source = Source("", ""), author = "", title = "", description = "",
url = "", urlToImage = "", content = "", publishedAt = ""
)
)
/**
* Test to ensure the paging source returns a successful LoadResult
* when the repository provides the first page of data.
*/
@Test
fun `when api returns first page of news then paging source returns success load result`() = runTest {
// Arrange: Simulate a successful API response for the first page.
setupMockResponse(firstPage)
// Create PagingSource load parameters for the Refresh operation.
val params = PagingSource.LoadParams.Refresh(
key = firstPage,
loadSize = pageSize,
placeholdersEnabled = false
)
// Expected result: Page with data, no previous key, and next page key incremented.
val expected = PagingSource.LoadResult.Page(
data = expectedArticles,
prevKey = null,
nextKey = firstPage + 1
)
// Act: Load the data using the PagingSource.
val actual = dataSource.load(params = params)
// Assert: Verify the actual result matches the expected result.
assertEquals(expected, actual)
}
/**
* Test to verify the paging source returns an error LoadResult
* when the repository returns an error response.
*/
@Test
fun `when api returns error response then paging source returns error load result`() = runTest {
// Arrange: Create PagingSource load parameters for the Refresh operation.
val params = PagingSource.LoadParams.Refresh(
key = firstPage,
loadSize = pageSize,
placeholdersEnabled = false
)
// Expected result: An error LoadResult with a specific exception.
val expected = PagingSource.LoadResult.Error<Long, Article>(
throwable = Exception("Response body is null")
)::class.java
// Act: Simulate an error scenario and get the actual result.
val actual = dataSource.load(params = params)::class.java
// Assert: Verify the actual result matches the expected result.
assertEquals(expected, actual)
}
/**
* Test to ensure the paging source returns a successful LoadResult
* when loading a subsequent page of data.
*/
@Test
fun `when second page of news is available then paging source returns success append load result`() = runTest {
// Arrange: Simulate a successful API response for the second page.
val secondPage = firstPage + 1
setupMockResponse(secondPage)
// Create PagingSource load parameters for the Append operation.
val params = PagingSource.LoadParams.Append(
key = secondPage,
loadSize = pageSize,
placeholdersEnabled = false
)
// Expected result: Page with data, previous key decremented, and next key incremented.
val expected = PagingSource.LoadResult.Page(
data = expectedArticles,
prevKey = secondPage – 1,
nextKey = secondPage + 1
)
// Act: Load the data using the PagingSource.
val actual = dataSource.load(params = params)
// Assert: Verify the actual result matches the expected result.
assertEquals(expected, actual)
}
/**
* Helper method to simulate API responses by mocking the repository.
*
* @param page The page number to simulate in the API response.
*/
private suspend fun setupMockResponse(page: Long) {
`when`(
repository.fetchNews(query, page)
).thenReturn(
NewsApiResponse(status = "ok", totalResults = 3, articles = expectedArticles)
)
}
}

And that’s it folks! Happy coding! Thanks for reading!



Leave a comment