Implementing a Custom Infinite Scroll Pager in Jetpack Compose
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.
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:
- Active Instance on Screen: This is the view currently visible to the user.
- 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)
}
}
}
}