Introduction
Imagine scrolling through an app that loads images or content before you even realize you need them-no loading spinners. No waiting. No lag. Just a smooth, seamless experience. Well, that's exactly what the latest Paging library in Android Jetpack, called Paging 3, brings to the table, with some cool features.
Paging 3 comes with a powerful Kotlin-first, coroutine-supported API that makes pagination easier and more efficient. One of the key performance features of Paging 3 is its smart prefetching. It doesn't just wait for the user to reach the end of the list - it starts loading the next page of data off-screen, so by the time the user scrolls down, the content is already ready.
This signifies:
- No visible loading indicators between pages
- Smooth, uninterrupted scroll
- Better perceived performance
In this post, I'll walk through building an image-loading app using your sample project: Paging3‑Image‑Loading.
Project Overview
The app fetches images in pages from a remote API and displays them in a grid using Jetpack Compose.
It highlights:
- Paging 3 for smooth pagination and lazy loading.
- Compose with LazyColumn and LazyPagingItems.
- Coil for efficient image loading.
- Built-in handling of loading and error states.
Setup Dependencies
To begin with, add the following Paging 3 dependencies to your libs.versions.toml
file.
implementation "androidx.paging:paging-compose:3.3.6"
implementation "androidx.paging:paging-runtime-ktx:3.3.6"
Please do check if any latest version has been released from here: https://developer.android.com/jetpack/androidx/releases/paging
Configure PagingSource
PagingSource
tells the Paging library how to fetch data, whether from a database or a network. It's where we define how to load a page of data and how to determine the next and previous page keys.
In our case, we're fetching images from a remote API.
class ImagePagingSource(
private val apiService: ApiService
) : PagingSource<Int, ImageListModel>() {
private val numOfOffScreenPage: Int = 4
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, ImageListModel> {
val pageIndex = params.key ?: 1
val pageSize = params.loadSize
return try {
val responseData = apiService.fetchImages(pageIndex, pageSize)
LoadResult.Page(
data = responseData.body()!!,
prevKey = if (pageIndex == 1) null else pageIndex - 1,
nextKey = if (responseData.body()!!.isEmpty()) null else pageIndex + 1
)
} catch (e: Exception) {
LoadResult.Error(e)
}
}
override fun getRefreshKey(state: PagingState<Int, ImageListModel>): Int? {
return state.anchorPosition?.let { anchor ->
state.closestPageToPosition(anchor)?.prevKey?.plus(numOfOffScreenPage)
?: state.closestPageToPosition(anchor)?.nextKey?.minus(numOfOffScreenPage)
}
}
}
There are 2 override functions, one is load() and the other is getRefreshKey().
Within the load()
Function: data is retrieved either from a local database or a remote API, depending on the implementation. The load()
method receives LoadParams
as a parameter, which provides access to the current page key (key
), the number of items to load (loadSize
), and the placeholdersEnabled
flag.
After fetching the required data, LoadResult
must be returned to the Paging framework. The LoadResult
can be one of three types:
- LoadResult.Page – Used when data is successfully loaded. It contains the list of items along with optional prevKey and nextKey.
- LoadResult.Error – Used when an error occurs during data fetching.
- LoadResult.Invalid – Used when the result is invalid. This return type can be used to terminate future load requests.
Repository
In the repository layer, we call the Pager object which connects to the PagingSource. It exposes a Flow> to the ViewModel, allowing the UI to observe paginated data. Inside the Pager, we define the page size and supply the PagingSource. The repository doesn't fetch data directly-it delegates that to the PagingSource's load method. The LoadResult return by PagingSource is automatically transformed into PagingData for the UI.
class ImageRepositoryImpl @Inject constructor(
private val apiService: ApiService
) : ImageRepository, NetworkCallback() {
override fun getImages(
pageSize: Int,
enablePlaceHolders: Boolean,
prefetchDistance: Int,
initialLoadSize: Int,
maxCacheSize: Int
): Flow<PagingData<ImageListModel>> {
return Pager(
config = PagingConfig(
pageSize = pageSize,
enablePlaceholders = enablePlaceHolders,
prefetchDistance = prefetchDistance,
initialLoadSize = initialLoadSize,
maxSize = maxCacheSize
), pagingSourceFactory = {
ImagePagingSource(apiService)
}
).flow
}
}
Usecase
The Use Case acts as an intermediary between the ViewModel and the Repository. It abstracts the business logic and simply invokes the repository'sgetImages() method. The Use Case returns a Flow>, keeping the ViewModel decoupled from data source implementations.
class ImageLoadingUseCase(
private val imageRepository: ImageRepository
) {
fun fetchImages(): Flow<PagingData<ImageListModel>> {
return imageRepository.getImages(
pageSize = 20,
enablePlaceHolders = false,
prefetchDistance = 10,
initialLoadSize = 20,
maxCacheSize = 2000
)
}
}
ViewModel
In the ViewModel layer, we collect the paginated data flow from the Use Case and expose it to the UI. Typically, this is done using Flow> and collected with collectAsLazyPagingItems() In Compose.
val getImageList = imageUseCase.fetchImages().cachedIn(viewModelScope)
User Interface
In the UI layer, LazyColumn
is used to display paginated items efficiently. The items() block consumes productItems, which is a LazyPagingItems<T>
from the Paging 3 library. Each item is accessed by index (position) and rendered inside a Card. An image is loaded asynchronously using AsyncImage, and metadata (like author name) is overlaid using a Column inside a Box. Paging handles automatic loading of more items when the user scrolls to the end. The UI reacts to paging state updates (like loading or errors) when managed properly with collectAsLazyPagingItems()
.
val productItems = viewModel.getImageList.collectAsLazyPagingItems()
LazyColumn(
modifier = Modifier
.fillMaxSize()
) {
items(productItems!!.itemCount) {position->
var itemValue = productItems[position]
Card (
modifier = Modifier
.padding(10.dp)
.fillMaxWidth()
.wrapContentHeight()
.background(Color.Transparent)
) {
Box {
itemValue?.download_url?.let {
AsyncImage(
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight()
.aspectRatio(itemValue.width!!*1.0f / itemValue.height!!),
model = itemValue.download_url.toString(),
contentDescription = "Image",
contentScale = ContentScale.Fit
)
}
Column (
modifier = Modifier
.fillMaxWidth()
.background(Color.Gray)
.height(30.dp)
.align(Alignment.BottomStart)
.padding(start = 10.dp),
verticalArrangement = Arrangement.Center
) {
Text(
color = Color.White,
text = itemValue?.author.toString()
)
}
}
}
}
}
Key Takeaways
To wrap it up, Paging 3 paired with clean architecture isn't just a design pattern - it's a productivity boost for any list-heavy Android app. With each layer playing its part - PagingSource fetching data, the Repository serving it, the Use Case shaping it, and the ViewModel delivering it to a Jetpack Compose-powered UI-you get a clean, testable, and scalable flow. It not only keeps your codebase neat but also gives users a buttery-smooth scrolling experience, even with massive datasets.
🚀 Ready to dive in or steal some code? Check out the complete working example on GitHub:
Top comments (0)