Jetpack Compose: HorizontalPager with PagerIndicator & Infinity scroll
In this article, we will explore the process of creating an infinite HorizontalPager
with a PagerIndicator
by using Compose.
As you may already know, Jetpack Compose does not have a built-in ViewPager
component. However, we can incorporate ViewPager
functionality into our project by adding the respective accompanist library dependency to our build.gradle
file.
implementation "com.google.accompanist:accompanist-pager:0.28.0"
In order to incorporate indicators, we will also utilize the respective library from accompanist.
implementation "com.google.accompanist:accompanist-pager-indicators:0.28.0"
Note: For this project, we are using Compose version 1.3.1 and Kotlin version 1.8.10.
Let’s begin by creating a HorizontalPager.
Thanks to the accompanist library, creating a HorizontalPager
is a simple task.
HorizontalPager(
count = pageCount,
state = pagerState,
modifier = modifier
) {
// page content
}
- Count: The number of pages
We set the count
to a significantly large number, such as Int.MAX_VALUE
, so we can achieve infinite scrolling behavior.
// Used Int.MAX_VALUE for infinity scroll
val pageCount = Int.MAX_VALUE
- State: The state object that is used to control or observe the pager’s state.
For state creation, we only need the initialPage
value. In order to enable infinite scrolling in both directions, we should start from the middle of the given page count. Therefore, the initialPage
can be set as in the following example:
val middlePage = pageCount / 2
val pagerState = rememberPagerState(initialPage = middlePage)
While everything may seem correct at first glance, we will soon encounter an issue concerning the initial displayed page.
The Trick 🪄
In order to achieve infinite behavior for the ViewPager
, we implemented a solution by setting the count
to a very large number. However, our actual list of items (trophies
) is considerably smaller. To ensure that the ViewPager
displays the correct page from our real list without creating duplicate pages, we need to handle the page number appropriately.
To address this challenge, we divide the provided page number by the size of the trophies
list. The remainder of this division allows us to obtain the correct page index within our real list. By performing this calculation, we ensure that the ViewPager
only displays the actual items from the list, preventing any duplications.
By utilizing this approach, we can seamlessly navigate through the trophies list within the ViewPager
while maintaining its infinite behavior.
Take a look at the following example:
val realSize = trophies.size
HorizontalPager(
count = pageCount,
state = pagerState,
modifier = modifier
) { page ->
val realPage = page % realSize
// max value is trophies.size
TrophyWidget(realPage, trophy = trophies[realPage])
}
Did you get it? No? 🤔 (Let's do the maths then!)
Looks like our math is working just fine! 🎉
If you take a closer look, it becomes apparent that the initial page is not the first on the trophies
list. And actually, the initial state depended on the trophies
list size. To resolve this discrepancy and ensure the correct initial state, it is necessary to calculate and pass a parameter to the ViewPager state
.
val realSize = trophies.size
val middlePage = pageCount / 2
// Init the PagerState with a very large number and make it always start from the first item of the real list
val pagerState = rememberPagerState(initialPage = middlePage - (middlePage % realSize))
By decreasing the middlePage
with the result of the mod
of middlePage
and trophies size
we are making sure that the ViewPager
is going to start from the begging of the trophies
list.
Pager Indicator
Adding indicators is also very simple, we just need to add HorizontalPagerIndicator
and pass pagerState
as a parameter.
⚠️ However, there is an issue here! If you try to use the pagerState
without specifying the real size of the list (trophies
), then the app will 🧨 . That’s because the default pageCount
of HorizontalPagerIndicator
is set to the value of PagerState.pageCount
, which, in our case, is a very large number.
Fortunately, we can specify the pageCount
by adding it as a parameter in HorizontalPagerIndicator
.
Take a look at the example:
HorizontalPagerIndicator(
pagerState = pagerState,
pageCount = realSize,
pageIndexMapping = { page -> page % realSize },
activeColor = Color.White,
modifier = modifier
.align(Alignment.BottomCenter)
.padding(bottom = 12.dp)
)
Also we have to describe how to obtain the position of the active indicator by passing the page into pageIndexMapping
function. This can be achieved by dividing pagerState.currentPage
by the size of the trophies
list.
As you can see in the example above, this can be implemented with the following code snippet:
pageIndexMapping = { page -> page % realSize }
To get the position of the active indicator, you can use the pageIndexMapping
function and perform the mod
using pagerState.currentPage
and the size of the trophies
list.
Auto-Scroll
If you need also your pager to auto-scroll, you can use the following code snippet:
// Start auto-scroll effect
LaunchedEffect(isDraggedState) {
// convert compose state into flow
snapshotFlow { isDraggedState.value }
.collectLatest { isDragged ->
// if not isDragged start slide animation
if (!isDragged) {
// infinity loop
while (true) {
// duration before each scroll animation
delay(5_000L)
runCatching {
pagerState.animateScrollToPage(pagerState.currentPage.inc() % pagerState.pageCount)
}
}
}
}
}