Custom Paging for LazyColumn

Having a paginated stream of data can give you a bit of a headache. AndroidX provides its own paging library, which offers a lot of abstraction. However, I ultimately failed three times and started looking into what other options I had.

My Issues with AndroidX.Paging

  1. Unable to pass through additional metadata. My current API also returns the total count of items. A small implementation detail: this number is returned only on the first page. In general, this is an example of additional data I want to pass through from the data layer to the UI.
    Open ticket: https://issuetracker.google.com/issues/175338415
  2. Refreshing data outside the UI. The AndroidX.Paging library provides a refresh() method, but only on the LazyPagingItems object. That object is accessible only in Compose, simply put. Performing a refresh in the ViewModel or deeper in the domain layer is very difficult.
  3. Impossible to use local state and reactivity. My app’s local layer stores some item metadata that can be updated locally without re-fetching from the server. I want to reactively update the paginated stream to reflect those metadata changes (until the backend refetch happens).

All these issues ultimately stem from AndroidX.Paging’s very closed, strict types (no interfaces, private internals), which use Flow only under the hood.

Back to the Roots

We’re all familiar with Flow<T> and the basic operations it enables. Let’s start modeling the API.

First, we need a data holder and the loading state:

sealed interface LoadState {
	data object Waiting : LoadState
	data object Loading : LoadState
	data object Success : LoadState
	data class Error(val exception: Exception) : LoadState
}

@Immutable
interface PagedData<E> {
	val items: List<E>
	val loadState: LoadState

	fun refresh()
	fun withItems(items: List<E>): PagedData<E>
}

fun interface Fetcher<E : Any> {
	suspend fun fetch(page: Int, pageSize: Int): List<E>
}

Because we’re using an interface instead of a data class, we lose the copy method. Therefore, we need an explicit withItems() function that allows us to create a copy with updated data (e.g., from a local state).

Data fetching will be handled by a simple suspending lambda. To ensure proper parameter names and types, let’s use a fun interface. In this example, I don’t need any nullable items, but feel free to adapt the code to your needs.

Now, let’s proceed with the implementation of the PagedData interface. First and foremost, this interface must inform the paging mechanism when items are accessed. The responsibility for determining whether more data is needed lies elsewhere – this class simply signals what has been accessed.

internal class PagedDataImpl<E>(
	private val data: List<E>,
	override val loadState: LoadState,
	private val onRefresh: () -> Unit,
	private val onItemReached: (Int) -> Unit,
) : PagedData<E> {
	override val items: List<E> = object : List<E> by data {
		override val size: Int
			get() {
				onItemReached(0)
				return data.size
			}

		override fun get(index: Int): E {
			onItemReached(index)
			return data[index]
		}
	}

	override fun refresh() {
		onRefresh.invoke()
	}

	override fun withItems(items: List<E>): PagedData<E> =
		PagedDataImpl(
			data = items,
			loadState = loadState,
			onRefresh = onRefresh,
			onItemReached = onItemReached,
		)
}

My API is a simple paged API using offset and size. This function calculates the required number of pages:

private fun calcPageCount(lastItem: Int, pageSize: Int, prefetchSize: Int): Int {
	val totalItemsToLoad = lastItem + prefetchSize
	val pages = ceil(totalItemsToLoad.toFloat() / pageSize).toInt().coerceAtLeast(1)
	return pages
}

The paging function itself will be a simple, pure function. It needs the pageSize and a lambda block to fetch the data.

fun <E : Any> pagedFlow(
	pageSize: Int,
	prefetchSize: Int = pageSize,
	fetcher: Fetcher<E>,
): Flow<PagedData<E>> {
	// TODO
}

Usage is straightforward:

class FilmsRepository {
	fun filmReviews(filmId: Int): Flow<PagedData<FilmReviews>> =
		pagedFlow(
			pageSize = 10,
		) { page, pageSize ->
			val response = filmRemoteSource.filmReviews(
				offset = page * pageSize,
				size = pageSize,
				filmId = filmId,
			)
			val items = response.data?.items?.map { it.toDomain() }.orEmpty()
			items
		}
}

Sharing, state handling, and caching are entirely up to you. And since the output is a standard Flow, you’re already familiar with how to work with it.

The implementation of pagedFlow() is not trivial, but it’s quite straightforward:

fun <E : Any> pagedFlow(
	pageSize: Int,
	prefetchSize: Int = pageSize,
	fetcher: Fetcher<E>,
): Flow<PagedData<E>> {
	val lastAccessedItem = MutableStateFlow(-1)
	var reachedEnd = false
	val refreshFlow = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
	val cache = mutableMapOf<Int, List<E>>()

	return lastAccessedItem
		.filter { !reachedEnd }
		.map { lastItem -> calcPageCount(lastItem, pageSize, prefetchSize) }
		.distinctUntilChanged()
		.refreshOn(refreshFlow)

We store the last accessed item in the UI using a state flow – this represents the last maximum accessed item. We also track whether we’ve reached the end of the data (i.e., when the API no longer returns data). The maximum last accessed item is mapped to the page count using the function we previously defined. Additionally, we support refreshing, so there’s an event flow for that signal. How to implement refreshOn() was covered in my previous blog post: Kotlin Flow tips for ViewModel. Lastly, we need a cache for the already loaded items.

Now, the “important” part – data fetching:

		.transform<Int, Pair<List<E>, LoadState>> { pages ->
			val dataPre = (0..<pages).flatMap { page ->
				cache[page] ?: emptyList()
			}
			emit(dataPre to LoadState.Loading)

			val dataFull = (0..<pages).flatMap { page ->
				cache.getOrPut(page) {
					if (reachedEnd) return@getOrPut emptyList()
					fetcher.fetch(page, pageSize).also {
						if (it.size < pageSize) reachedEnd = true
					}
				}
			}
			emit(dataFull to LoadState.Success)
		}
  • First, we simply emit “what” we already have in the cache, along with a new LoadingState. Then we remap the data and run the fetcher if any data is missing.
  • After fetching the page data, we double-check if we’ve received the full page size. If not, we know we’ve reached the end.
  • The entire result is “flattened” into a single list of items.

The remaining parts are just about wrapping the data into the PagedDataImpl object:

		.onStart { emit(emptyList<E>() to LoadState.Waiting) }
		.map { (data, state) ->
			PagedDataImpl(
				data = data,
				loadState = state,
				onRefresh = {
					cache.clear()
					refreshFlow.tryEmit(Unit)
				},
				onItemReached = { index ->
					lastAccessedItem.update { max(it, index) }
				},
			)
		}

Conclusion

As you can see, the whole custom pagination is a neatly chained reactive stream of operations, without any external state handling, etc. We rely on the basic, well-known, and interoperable Flow API. There are definitely areas that could be improved or optimized, but let’s not overengineer it from the start. It didn’t occur to me that I wouldn’t need large, fancy libraries, but here we are—with a custom, concise implementation that lets me do exactly what I need.

What paging library do you use?