In this article, I describe what challenges you may face when you use the Accompanist Pager library when creating a custom pager indicator for a pager built in Jetpack Compose.
Recently, I had the chance to create a custom pager indicator for a pager built in Jetpack Compose using the Accompanist Pager library. Since the library is relatively new and there is not a lot to be found online apart from the official documentation, I would like to share the challenges I faced along the way.
You can find the entire source code for this article on GitHub.
I used the latest stable version of the Accompanist Pager library which is 0.23.1. Please note that the APIs still change quite a bit (as expected with 0.x versions). At the time of writing, there is a release candidate for version 0.24.13. At the end of this article, there is a quick outlook on the coming changes.
So, let’s get into it.
Pager in Compose
Building pagers in Compose is very straightforward and the official documentation gives you everything you need to know. So let’s just start with a basic pager showing one image per page:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
val pagerState = rememberPagerState() val pages = listOf( Page("https://cdn.pixabay.com/photo/2014/02/19/20/39/winter-270160_1280.jpg"), Page("https://cdn.pixabay.com/photo/2019/11/23/03/08/valley-4646114_1280.jpg"), Page("https://cdn.pixabay.com/photo/2018/11/29/20/01/nature-3846403_1280.jpg"), Page("https://cdn.pixabay.com/photo/2016/11/19/14/38/camel-1839616_1280.jpg"), Page("https://cdn.pixabay.com/photo/2014/07/23/00/56/moon-399834_1280.jpg"), Page("https://cdn.pixabay.com/photo/2019/12/14/18/28/sunrise-4695484_1280.jpg"), Page("https://cdn.pixabay.com/photo/2018/03/29/07/35/water-3271579_1280.jpg"), Page("https://cdn.pixabay.com/photo/2021/01/23/13/01/hills-5942468_1280.jpg"), Page("https://cdn.pixabay.com/photo/2019/10/09/20/18/etretat-4538160_1280.jpg"), ) HorizontalPager( count = pages.size, state = pagerState, modifier = Modifier .fillMaxSize() .background(Color.LightGray) ) { page -> Box(modifier = Modifier.fillMaxSize()) { Surface( modifier = Modifier.fillMaxWidth() ) { AsyncImage( model = pages[page].url, contentDescription = null ) } } } |
(The pictures are taken from pixabay.com.)
I suppose this does not need much explanation – feel free to play around with it in the sample project.
PagerIndicator
Now, in order to see which item of the pager is currently selected, we would like to have a pager indicator. Adding one is fairly simple using the documentation. However, the indicators that come with the library are very limited. You can customize the colors, spacing and indicator shape, but that is about it.
A few examples (which can also be found in the sample project, of course):
What I wanted to build was something like this:
Seems simple enough, doesn’t it? Well, unfortunately, it is not …
While the default indicator just moves an item around on a static background, here we need to actually change two items simultaneously. When changing pages, the previous item gets smaller and the next one gets larger.
To achieve this, we have to – you guessed it – build a custom pager indicator. And we are doing it with Jetpack Compose.
Build your own pager indicator with Jetpack Compose
First try: Just calculate the current and target width
The PagerState
gives us the current page number, the target page number and an offset. Using that, it should be fairly easy to calculate the width of the current and target page (all other widths are constant anyway).
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
private const val MULTIPLIER_SELECTED_PAGE = 4 private val baseWidth = 4.dp private val spacing = 10.dp private val height = 8.dp @OptIn(ExperimentalPagerApi::class) @Composable fun CustomPagerIndicatorFirstTry(pagerState: PagerState, modifier: Modifier = Modifier, indicatorColor: Color = Color.Black) { Row { val currentPageWidth = baseWidth * (1 + (1 - abs(pagerState.currentPageOffset)) * MULTIPLIER_SELECTED_PAGE) val targetPageWidth = baseWidth * (1 + abs(pagerState.currentPageOffset) * MULTIPLIER_SELECTED_PAGE) repeat(pagerState.pageCount) { index -> val width = when (index) { pagerState.currentPage -> currentPageWidth pagerState.targetPage -> targetPageWidth else -> baseWidth } Box( modifier = Modifier .width(width) .background(indicatorColor) .height(height) ) if (index != pagerState.pageCount - 1) { Spacer(modifier = Modifier.width(spacing)) } } } } |
So, we are calculating the width of the current and target pages based on the currentPageOffset
. The calculation is quite straight-forward:
- If there is no offset at all, the current page is 5 times as wide as all the other pages.
- During scrolling, as the offset gets bigger, the width of the current page gets smaller while the width of the target page gets bigger. In the end, the current page has the same width as all inactive pages while the target page has 5 times the width of the other pages.
In the rest of the code, we just draw boxes with background and spacing for all the pages we need.
Problem: Glitch when aborting a scroll
Now, this already works quite well – the sizes are (almost) adjusted correctly. However, there is a glitch in the rendering as can be seen above: When aborting a scroll, i.e., starting to change the page but stopping the drag before the page actually changes, then the width of the current and target pages (and, by extension, the entire indicator) is too small for a moment.
It took me a while to figure this out: The issue is that when scrolling through the pages, the target and current page numbers change. Let’s say we start scrolling from page 2 to 3: In the beginning, currentPage
is 2 while targetPage
is 3, just as expected. But, if we lift our finger while being closer to page 2 than to page 3 (meaning that the pager is supposed to animate back to page 2 again), currentPage
and targetPage
both point to 2. Of course, this makes sense because, at that moment, the target of the scroll is actually 2, but it complicates adjusting the width of page 3.
Second try: Calculate targetPage based on offset
So it seems that relying on targetPage
from PagerState
is not possible for this. So let’s calculate it on our own:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
@OptIn(ExperimentalPagerApi::class) @Composable fun CustomPagerIndicatorSecondTry(pagerState: PagerState, modifier: Modifier = Modifier, indicatorColor: Color = Color.Black) { Row { val currentPage = pagerState.currentPage val targetPage = if (pagerState.currentPageOffset < 0) currentPage - 1 else currentPage + 1 val currentPageWidth = baseWidth * (1 + (1 - abs(pagerState.currentPageOffset)) * MULTIPLIER_SELECTED_PAGE) val targetPageWidth = baseWidth * (1 + abs(pagerState.currentPageOffset) * MULTIPLIER_SELECTED_PAGE) repeat(pagerState.pageCount) { index -> val width = when (index) { currentPage -> currentPageWidth targetPage -> targetPageWidth else -> baseWidth } Box( modifier = Modifier .width(width) .background(indicatorColor) .height(height) ) if (index != pagerState.pageCount - 1) { Spacer(modifier = Modifier.width(spacing)) } } } } |
Now, targetPage
always points to the “original“ target of the scroll, even if the user has stopped scrolling at a point which means that the pager will move back to the current page.
This helps and in most cases, it all works fine now (feel free to try it using the sample project).
Problem: Huge indicators when scrolling quickly
However, when scrolling quite fast and with the correct timing between the drag motions, an odd effect can be seen:
Actually, there was another assumption that I made above (without actually mentioning it): The offset is at all times supposed to be somewhere between -1 and 1. Bad news: it is not. When scrolling quickly, the absolute value of the offset can actually be greater than 1. In that case, currentPage
somehow does not keep up with the scrolling. The logs can look something like this:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
currentPage: 2 targetPage 1 offset -0.9296297 currentPage: 2 targetPage 1 offset -0.9944445 currentPage: 2 targetPage 1 offset -1.0592592 currentPage: 2 targetPage 1 offset -1.1166667 currentPage: 2 targetPage 1 offset -1.1611111 currentPage: 2 targetPage 1 offset -1.1907408 currentPage: 2 targetPage 1 offset -1.1953704 currentPage: 2 targetPage 1 offset -1.237963 currentPage: 2 targetPage 1 offset -1.2944444 currentPage: 2 targetPage 1 offset -1.3592592 currentPage: 2 targetPage 1 offset -1.425 currentPage: 2 targetPage 1 offset -1.4907408 currentPage: 2 targetPage 1 offset -1.5574074 currentPage: 2 targetPage 1 offset -1.6148148 currentPage: 2 targetPage 1 offset -1.6666666 currentPage: 2 targetPage 1 offset -1.7120371 currentPage: 2 targetPage 1 offset -1.7537037 currentPage: 2 targetPage 1 offset -1.7898148 currentPage: 2 targetPage 1 offset -1.8231481 currentPage: 2 targetPage 1 offset -1.85 currentPage: 2 targetPage 1 offset -1.9203703 currentPage: 2 targetPage 1 offset -1.9805555 currentPage: 2 targetPage 1 offset -2.0 currentPage: 0 targetPage 1 offset 0.0 |
We see that currentPage
is stuck at 2 but the offset continues to grow until at some point, the scroll settles and currentPage
is updated to 0.
Solution: Calculate currentPage based on offset
Apparently, we need to calculate currentPage
ourselves using the integer part of the offset:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
@OptIn(ExperimentalPagerApi::class) @Composable fun CustomPagerIndicator(pagerState: PagerState, modifier: Modifier = Modifier, indicatorColor: Color = Color.Black) { Row { val offsetIntPart = pagerState.currentPageOffset.toInt() val offsetFractionalPart = pagerState.currentPageOffset - offsetIntPart val currentPage = pagerState.currentPage + offsetIntPart val targetPage = if (pagerState.currentPageOffset < 0) currentPage - 1 else currentPage + 1 val currentPageWidth = baseWidth * (1 + (1 - abs(offsetFractionalPart)) * MULTIPLIER_SELECTED_PAGE) val targetPageWidth = baseWidth * (1 + abs(offsetFractionalPart) * MULTIPLIER_SELECTED_PAGE) repeat(pagerState.pageCount) { index -> val width = when (index) { currentPage -> currentPageWidth targetPage -> targetPageWidth else -> baseWidth } Box( modifier = Modifier .width(width) .background(indicatorColor) .height(height) ) if (index != pagerState.pageCount - 1) { Spacer(modifier = Modifier.width(spacing)) } } } } |
Using our calculated value for currentPage
, the indicator finally behaves as expected:
As a small optimization, we can use animateDpAsState
for the width of the items. This makes the page changes a bit smoother.
Conclusion
I am quite happy with the solution for this custom PagerIndicator
. Also, the journey to get there was fun – it felt a bit like getting your hands dirty which does not happen very often with all the great libraries that offer everything you need.
That being said, the solution feels rather complicated for such a seemingly simple problem. I suppose that most users are happy with the customization that the default indicator offers. But for those who need more, there is certainly room for improvement in the Accompanist Pager library.
As mentioned in the beginning, the code for this blog post uses version 0.23.1 of the Pager library. To get a complete picture, we should also take a look at the upcoming changes.
Outlook
Currently, there is a release candidate of version 0.24.13 for the Pager library. So let’s find out how the library’s behavior changed in the meantime.
First of all, the new version does not have any breaking changes – I created a branch in GitHub which runs just fine.
There are, however, a few notable differences:
- The glitch in the first version still persists. Actually, that is what I expected since the change of
currentPage
during the scroll makes sense. - The second glitch is not reproducible. The reason for this is that
currentPage
is updated immediately when scrolling past half of the page (for details, check out the pull request). Apparently, this strange behavior was indeed a bug that is already fixed in the upcoming version. - Also,
targetPage
is deprecated (pull request). So, in the future, we would need to calculate the target page ourselves anyway.
In conclusion, the update fixes the issue with absolute offset values >= 1 which makes implementation of a custom pager indicator as described in this article easier. But still, there is some manual effort to be done for calculating the targetPage
.
Hopefully, this article helps some people out there. I am looking forward to any comments or questions you might have!