Pagination in Jetpack Compose with and without Paging 3

Table of Contents
Prerequisites
We’ll use Retrofit & Hilt in this article, so it’s better you know how they work.
Also, we’ll use this API for testing. I recommend you register and get your API key.
Getting Started
def paging_version = "3.1.1"
implementation "androidx.paging:paging-runtime:$paging_version"
implementation "androidx.paging:paging-compose:1.0.0-alpha17"
//Other Dependencies
def retrofit_version = "2.9.0"
implementation "com.squareup.retrofit2:retrofit:$retrofit_version"
implementation "com.squareup.retrofit2:converter-gson:$retrofit_version"
def hilt_version = "2.44"
implementation "com.google.dagger:hilt-android:$hilt_version"
kapt "com.google.dagger:hilt-compiler:$hilt_version"
implementation "androidx.hilt:hilt-navigation-compose:1.0.0"
Don’t forget to add Internet permission in AndroidManifest.xml
,
<uses-permission android:name="android.permission.INTERNET" />
Setting up Retrofit
Before we setup Retrofit, let’s see the response of the endpoint that we’ll use. Endpoint, https://newsapi.org/v2/everything?q=apple&sortBy=popularity&apiKey=APIKEY&pageSize=20&page=1
{
"status": "ok",
"totalResults": 65739,
"articles": [
{
"source": {
"id": "wired",
"name": "Wired"
},
"author": "Parker Hall",
"title": "Apple Music Sing Adds 'Karaoke Mode' to Streaming Songs",
"description": "America's most popular music streaming service is adding the ability to turn down the vocals and sing along.",
"url": "https://www.wired.com/story/apple-music-sing/",
"urlToImage": "https://media.wired.com/photos/638f959b54aee410695ffa12/191:100/w_1280,c_limit/Apple-Music-Sing-Featured-Gear.jpg",
"publishedAt": "2022-12-06T20:51:11Z",
"content": "When it comes to advanced technical features and seamless compatibility with iOS devices, Apple Music has Spotify well and truly beaten. The Swedish streaming giant has essentially the same content l… [+3348 chars]"
},
]
}
Response models,
Please put them into different files. I’ve put them into one code block to make it easier to read.
data class NewsResponse(
val articles: List<Article>,
val status: String,
val totalResults: Int
)
data class Source(
val id: String,
val name: String
)
data class Article(
val author: String,
val content: String,
val description: String,
val publishedAt: String,
val source: Source,
val title: String,
val url: String,
val urlToImage: String
)
Now let’s create API Service & repository, it’s going to be a simple one,
interface NewsApiService {
@GET("everything?q=apple&sortBy=popularity&apiKey=${Constants.API_KEY}&pageSize=20")
suspend fun getNews(
@Query("page") page: Int
): NewsResponse
}
That’s it. Now we can start implementing pagination.
Pagination with Paging 3
Paging Source
Let’s start by creating Paging Source,
class NewsPagingSource(
private val newsApiService: NewsApiService,
): PagingSource<Int, Article>() {
override fun getRefreshKey(state: PagingState<Int, Article>): Int? {
return state.anchorPosition?.let { anchorPosition ->
state.closestPageToPosition(anchorPosition)?.prevKey?.plus(1)
?: state.closestPageToPosition(anchorPosition)?.nextKey?.minus(1)
}
}
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Article> {
return try {
val page = params.key ?: 1
val response = newsApiService.getNews(page = page)
LoadResult.Page(
data = response.articles,
prevKey = if (page == 1) null else page.minus(1),
nextKey = if (response.articles.isEmpty()) null else page.plus(1),
)
} catch (e: Exception) {
LoadResult.Error(e)
}
}
}
The primary Paging library component in the repository layer is
PagingSource
. EachPagingSource
object defines a source of data and how to retrieve data from that source. APagingSource
object can load data from any single source, including network sources and local databases.
In our example, PagingSource
extends <Int, Article>
,
Int
is the type of paging key, for our case it’s index numbers for pages.
Article
is the type of data loaded.
getRefreshKey
, provides a key used for the initial load for the next PagingSource
due to invalidation of this PagingSource
.
load
, function will be called by the Paging library to asynchronously fetch more data to be displayed as the user scrolls around.
That’s it for PagingSource
, we can create repository & view model.
Repository & View Model
It’s not really necessary to have repository since PagingSource
acts like one, you can remove repository and make the same function calls in view model.

class NewsRepository @Inject constructor(
private val newsApiService: NewsApiService
) {
fun getNews() = Pager(
config = PagingConfig(
pageSize = 20,
),
pagingSourceFactory = {
NewsPagingSource(newsApiService)
}
).flow
}
@HiltViewModel
class NewsViewModel @Inject constructor(
private val repository: NewsRepository,
): ViewModel() {
fun getBreakingNews(): Flow<PagingData<Article>> = repository.getNews().cachedIn(viewModelScope)
}
The
Pager
component provides a public API for constructing instances ofPagingData
that are exposed in reactive streams, based on aPagingSource
object and aPagingConfig
configuration object.
PagingConfig
, this class sets options regarding how to load content from a PagingSource
such as how far ahead to load, the size request for the initial load, and others. The only mandatory parameter you have to define is the page size
pagingSourceFactory
, function that defines how to create the PagingSource
.
That’s it. Now we can implement UI and see the results.
UI Layer
collectAsLazyPagingItems
, collects values from thisFlow
ofPagingData
and represents them inside aLazyPagingItems
instance. TheLazyPagingItems
instance can be used by theitems
anditemsIndexed
methods fromLazyListScope
in order to display the data obtained from aFlow
ofPagingData
.
First, we create LazyColumn
and inside of it we use items
which expects LazyPagingItems<T>
and set a unique value for key. That’s it. We don’t have to do anything, as we fetch & paginate data will be inserted into LazyColumn
.
Since we also need to indicate when our data is being fetched, we’ll need to show loading UI to users. LazyPagingItems
comes for the rescue. LazyPagingItems<T>
has loadState
object which is CombinedLoadStates
.
CombinedLoadStates.source
is a LoadStates
type, with fields for three different types of LoadState
:
LoadStates.append
: For theLoadState
of items being fetched after the user's current position.LoadStates.prepend
: For theLoadState
of items being fetched before the user's current position.LoadStates.refresh
: For theLoadState
of the initial load.
Each LoadState
itself can be one of the following:
LoadState.Loading
: Items are being loaded.LoadState.NotLoading
: Items are not being loaded.LoadState.Error
: There was a loading error.
For the initial load, we check articles.loadState.refresh
and if state is LoadState.Loading
we show loading UI.
For the pagination, we check articles.loadState.append
and if state is LoadState.Loading
again and show loading UI.
You can find the full code at the end of the article.
That’s it. Let’s see the result.
Pagination without Paging 3
Before we start, you might ask why do we reinvent the wheel? Because in some cases Paging 3 can cause boilerplate code and increase the complexity. Implementing pagination without Paging 3 can give us more freedom and less boilerplate code.
Since we’ve already implemented ApiService
, we can start by creating repository.
Repository
class NewsManuelPagingRepository @Inject constructor(
private val newsApiService: NewsApiService
) {
suspend fun getNews(page: Int): Flow<NewsResponse> = flow {
try {
emit(newsApiService.getNews(page))
} catch (error: Exception) {
emit(NewsResponse(emptyList(), error.message ?: "", 0))
}
}.flowOn(Dispatchers.IO)
}
This is very simple and poorly executed for our example, and I do not recommend you use it this way in production. You can check these articles for more information,
- Modeling Retrofit Responses With Sealed Classes and Coroutines | by Jaewoong Eum | ProAndroidDev
- Modeling Retrofit Responses With Sealed Classes and Coroutines | by Jaewoong Eum | ProAndroidDev
View Model
Before we create view model, we’ll create enum class for List State.
enum class ListState {
IDLE,
LOADING,
PAGINATING,
ERROR,
PAGINATION_EXHAUST,
}
This enum class will help us for managing state. Now we can create view model.
First, we have 3 variables,
page
is for keeping the page number. canPaginate
is to check if we can paginate further or if there is any error. listState
is the state variable for the UI.
Inside of init
we make the first request, we are fetching first page when view model object created.
getNews
function’s logic can be change depending on the endpoints and requirements. In this example, we set listState
to Loading
or Paginating
depending on the page
number and make the endpoint call.
Since endpoint returns status: "ok"
for successful request, we check if it is successful or not. If it is successful, we insert new items to the list and set the values for canPaginate
and listState
.
That’s it. Logic is very simple and open to improvements. You can test it yourself and change it accordingly.
Finally, let’s see the UI.
UI Layer
This is going to be a little longer, so we’ll go part by part.
val viewModel = hiltViewModel<NewsManuelPagingViewModel>()
val lazyColumnListState = rememberLazyListState()
val coroutineScope = rememberCoroutineScope()
val shouldStartPaginate = remember {
derivedStateOf {
viewModel.canPaginate && (lazyColumnListState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -9) >= (lazyColumnListState.layoutInfo.totalItemsCount - 6)
}
}
val articles = viewModel.newsList
lazyColumnListState
is necessary to get the visible item info for Lazy Column.
shouldStartPaginate
is to determine whether or not we should start paginating. We’ll use derivedStateOf
for better performance. You can read more from this link.
First, we check if we can paginate or not, viewModel.canPaginate
,
Then, we get the last visible item’s index, lazyColumnListState.layoutInfo.visibleItemsInfo.lastOrNull()?.index
, and check if the index number is bigger than or equal to total number of item count, lazyColumnListState.layoutInfo.totalItemsCount
, minus some number that you decide. I decided to set it 6 for our case. You can change it depending on your list and size.
LaunchedEffect(key1 = shouldStartPaginate.value) {
if (shouldStartPaginate.value && viewModel.listState == ListState.IDLE)
viewModel.getNews()
}
We’ll use LaunchedEffect
to start pagination and make request. Whenever shouldStartPaginate.value
changes, we start the pagination and that’s it.
Now, we can create Lazy Column,
Setting state = lazyColumnListState
is very important to listen pagination, don’t forget it!
I think only part that requires a little bit of an explanation is when(viewModel.listState)
and it’s very simple. With the help of enum class that we’ve created earlier, we check the state of the list and show necessary UI.
You can find the full code at the end of the article.
That’s it. Let’s see the results.
Full Code
MrNtlu/JetpackCompose-Pagination (github.com)
Whats Next
You can check my other blog on Caching and Pagination with Paging 3,