Implementing a Custom Infinite Scroll Pager in Jetpack Compose

Mohammad Derakhshan
4 min readMay 18, 2024

--

Hello everyone, and welcome back to another Jetpack Compose tutorial! In this article, we’re diving into how to implement a custom infinite vertical scroll pager.

https://unsplash.com/photos/a-blurry-photo-of-a-light-in-the-dark-ouwdw--XNzo

Now, you might be wondering: why bother with a custom implementation when Jetpack Compose already offers a VerticalPager? The answer is simple: the existing VerticalPager is fantastic and works perfectly for many use cases!

So, why the need for something custom? In a recent project, I encountered a scenario where we needed an infinite scroll effect without recycling items. The VerticalPager in Jetpack Compose uses lazy views under the hood, which behave similarly to a RecyclerView in XML. This means that when an item goes out of the defined bounds, it gets recycled, and new content is loaded in its place.

However, for our project, we needed all items to persist in their state without being recycled to create a seamless infinite scrolling experience. The main reason for this custom implementation was that we were using a library for a specific view that automatically destroys its view based on the lifecycle. This behavior resulted in a heavy item being instantiated each time the lazy column wanted to display it, which was far from ideal for our needs.

We needed a solution that would allow the items to remain persistent, allowing for a smoother and more efficient infinite scrolling experience. Let’s dive into how we tackled this challenge in Jetpack Compose!

First Step: How should the screens be arranged?

To implement our custom infinite scroll pager efficiently, we need to outline the architecture. The goal is to minimize memory usage by keeping the number of instances as low as possible. Here’s the plan:

  1. Active Instance on Screen: This is the view currently visible to the user.
  2. Buffer Instances Off-Screen: We will have two additional instances, one just above the visible area and one just below it.

Helper Class for Screen Arrangement

Before we write the composable function, we need a helper class to manage screen arrangements. This class ensures that only a minimal number of instances are created and maintained efficiently. Here’s a breakdown of the key parts of this helper class:

data class Screen<T>(
val id: Int,
val offset: MutableState<Float> = mutableFloatStateOf(0f),
val data: MutableState<T>
)

The Screen data class holds information about each screen, including an ID, its offset position, and the data it displays. The offset is used to determine the screen's position relative to the viewport.

Then we have Screens class. This class is designed to manage a collection of Screen instances. It takes the screen height, a list of data to display, and the total number of screens (default is 3).

During initialization, we create the specified number of screens, setting their offsets and initial data. The offsets are calculated based on the screen height, spacing them vertically.

init {
repeat(totalScreens) {
screen.add(
Screen(
id = it,
data = mutableStateOf(data[it % data.size]),
offset = mutableFloatStateOf(screenHeight * it)
)
)
}
}

The updateOffset method adjusts the offset of each screen based on the scrolling direction. It prevents scrolling beyond the data limits and updates the scrollDirection to indicate the scroll direction.

fun updateOffset(offset: Float) {
if (offset > 0 && !canScrollBackward) return
if (offset < 0 && !canScrollForward) return

scrollDirection = if (offset > 0) -1 else 1
screen.forEach {
it.offset.value += offset
}
}

The rearrangeOffsets method ensures that the offsets are correctly rounded and checks if the current screen has changed. If it has, it updates the counter and adjusts the content of screens that are now off-screen.

fun rearrangeOffsets() {
screen.forEach {
it.offset.value =
it.offset.value.roundToBase(base = screenHeight, roundTrip = totalScreens)
}
val currentScreenIdAfterUpdate = screen.first { it.offset.value == 0f }.id
if (currentScreenId != currentScreenIdAfterUpdate) {
counter += scrollDirection
currentScreenId = currentScreenIdAfterUpdate
correctScreenContent()
}
}

private fun Float.roundToBase(base: Float, roundTrip: Int): Float {
val factor = (this / base).roundToInt()
val result = factor * base
return when {
abs(result) > base * (roundTrip - 2) && result < 0f -> base
abs(result) > base * (roundTrip - 2) && result > 0f -> -base
else -> result
}
}

The correctScreenContent method updates the data of screens that are now off-screen, ensuring they display the correct content when they come back into view.

private fun correctScreenContent() {
screen.firstOrNull { it.offset.value < 0 }?.let {
it.data.value = data.getOrElse(counter - 1) { data[counter] }
}
screen.firstOrNull { it.offset.value > 0 }?.let {
it.data.value = data.getOrElse(counter + 1) { data[counter] }
}
}

Here is the complete code of Screens class:

And finally, we have our composable function like this:

@Composable
fun CustomPager(modifier: Modifier = Modifier, items: List<String>) {

var dragLimit by remember { mutableFloatStateOf(0f) }
val density = LocalDensity.current
val screenHeight = with(density) {
LocalConfiguration.current.screenHeightDp.dp.toPx()
}
val screens = remember {
Screens(
screenHeight = screenHeight,
data = items
)
}


Box(
modifier = modifier
.fillMaxSize()
.draggable(
state = rememberDraggableState { delta ->
if (abs(dragLimit) <= screenHeight) {
dragLimit += (delta*1.1f)
screens.updateOffset(delta)
}
},
orientation = Orientation.Vertical,
onDragStopped = {
dragLimit = 0f
screens.rearrangeOffsets()
}
)
) {
repeat(3) {
Box(
modifier = Modifier
.graphicsLayer {
translationY = screens.screen[it].offset.value
}
.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text(text = screens.screen[it].data.value)
}
}
}
}

--

--

Mohammad Derakhshan

Hi! I'm Mohammad. A master's student at the University of Milan. I am an android expert who loves NLP! You can search for me on LinkedIn to make a connection!