Implementing custom pagination indicators in Jetpack Compose
In Jetpack Compose, we can create custom pagination indicators to enhance user experience. A paging indicator is an indicator used in an application to show the position of the current active page, usually in a sliding view or a ViewPager. Using Jetpack Compose, we can easily customize these indicators to suit the look and feel of our application. In this article, we will learn how to implement custom pagination indicators to improve user experience.
background
Pagination indicators play an important role in guiding users through multiple screens or pages in an application. While Jetpack Compose provides many built-in components, customizing pagination indicators to match your application's unique style and branding can enhance the user experience.
In this blog post, we'll explore how to create and implement custom pagination indicators in Jetpack Compose, allowing you to add a unique flair to your application's navigation.
What will we achieve in this blog?
Let's get started...
I've implemented most of the indicators using the Canvas API. Also, to show alternatives, I've also used built-in composables like Box.
Additionally, we will highlight the flexibility of pagination indicators and show how to implement them using unified logic.
Let's take a look at the calculation of common values used in all indicators.
// To get scroll offset
val PagerState.pageOffset: Float
get() = this.currentPage + this.currentPageOffsetFraction
// To get scrolled offset from snap position
fun PagerState.calculateCurrentOffsetForPage(page: Int): Float {
return (currentPage - page) + currentPageOffsetFraction
}
This is a typical extension function for drawing indicators on the canvas. Now, let's start implementing cool indicators.
private fun DrawScope.drawIndicator(
x: Float,
y: Float,
width: Float,
height: Float,
radius: CornerRadius
) {
val rect = RoundRect(
x,
y - height / 2,
x + width,
y + height / 2,
radius
)
val path = Path().apply {
addRoundRect(rect) }
drawPath(path = path, color = Color.White)
}
Extended Line/Point Indicator
To achieve the expand/collapse effect, we just need to animate the width of the indicator based on the page offset.
Canvas(modifier = Modifier.width(width = totalWidth)) {
val spacing = circleSpacing.toPx()
val dotWidth = width.toPx()
val dotHeight = height.toPx()
val activeDotWidth = activeLineWidth.toPx()
var x = 0f
val y = center.y
repeat(count) {
i ->
val posOffset = pagerState.pageOffset
val dotOffset = posOffset % 1
val current = posOffset.toInt()
val factor = (dotOffset * (activeDotWidth - dotWidth))
val calculatedWidth = when {
i == current -> activeDotWidth - factor
i - 1 == current || (i == 0 && posOffset > count - 1) -> dotWidth + factor
else -> dotWidth
}
drawIndicator(x, y, calculatedWidth, dotHeight, radius)
x += calculatedWidth + spacing
}
}
- The variable x is initialized to 0, representing the starting x coordinate for drawing the indicator.
- The variable y is assigned the y-coordinate of the drawn indicator, calculated as the center y-coordinate of the canvas.
posOffset
IndicatespagerState.pageOffset
the paging offset obtained from the . Calculated using the modulo operator %1 - dotOffset,dotOffset
isposOffset
the fractional part.current
The integer part to be assignedposOffset
.
-factor
Determines the adjustment of the current position of the indicator width within the page.- Finally, update
x
to calculate the starting position of the next indicator.
Below is the result.
sliding indicator
For this indicator we will use Box
the combination. We just need to move the lines/dots horizontally when the page changes.
distance
is the width of the indicator plus the spacing
Let's see the results.
worm point indicator
For this indicator we will also use the Box combination. We just need to change the width of the current indicator to achieve the worm effect. Let's create a modifier for it.
@OptIn(ExperimentalFoundationApi::class)
private fun Modifier.wormTransition(
pagerState: PagerState
) =
drawBehind {
val distance = size.width + 10.dp.roundToPx()
val scrollPosition = pagerState.currentPage + pagerState.currentPageOffsetFraction
val wormOffset = (scrollPosition % 1) * 2
val xPos = scrollPosition.toInt() * distance
val head = xPos + distance * 0f.coerceAtLeast(wormOffset - 1)
val tail = xPos + size.width + 1f.coerceAtMost(wormOffset) * distance
val worm = RoundRect(
head, 0f, tail, size.height,
CornerRadius(50f)
)
val path = Path().apply {
addRoundRect(worm) }
drawPath(path = path, color = Color.White)
}
Here we calculate the left and right position of the indicator and drawBehind
draw the path in the modifier.
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun WormIndicator(
count: Int,
pagerState: PagerState,
modifier: Modifier = Modifier,
spacing: Dp = 10.dp,
) {
Box(
modifier = modifier,
contentAlignment = Alignment.CenterStart
) {
Row(
horizontalArrangement = Arrangement.spacedBy(spacing),
modifier = modifier
.height(48.dp),
verticalAlignment = Alignment.CenterVertically,
) {
repeat(count) {
Box(
modifier = Modifier
.size(20.dp)
.background(
color = Color.White,
shape = CircleShape
)
)
}
}
Box(
Modifier
.wormTransition(pagerState)
.size(20.dp)
)
}
}
The result is as follows:
jump point indicator
The implementation of this indicator is very similar to the previous indicators.
Here we'll use graphicsLayer
modifiers, changing the X position and scaling of the indicator to achieve something like this...,
Let's take a look jumpingDotTransition
.
@OptIn(ExperimentalFoundationApi::class)
private fun Modifier.jumpingDotTransition(pagerState: PagerState, jumpScale: Float) =
graphicsLayer {
val pageOffset = pagerState.currentPageOffsetFraction
val scrollPosition = pagerState.currentPage + pageOffset
translationX = scrollPosition * (size.width + 8.dp.roundToPx()) // 8.dp - spacing between dots
val scale: Float
val targetScale = jumpScale - 1f
scale = if (pageOffset.absoluteValue < .5) {
1.0f + (pageOffset.absoluteValue * 2) * targetScale;
} else {
jumpScale + ((1 - (pageOffset.absoluteValue * 2)) * targetScale);
}
scaleX = scale
scaleY = scale
}
Here, we calculate the current page offset ( pageOffset
) and add it to pagerState
the current page index ( currentPage
). This provides the exact scroll position of the pager.
We then multiply that value by the size of the dot indicator ( size.width
) plus an extra 8 dp (the spacing between dots), and assign the result to the graphics layer's translationX
properties. This transformation causes the dot indicator pager
to move horizontally when scrolling.
Next, we use an if-else condition to pageOffset
calculate the scaling. If pageOffset
the absolute value of is less than 0.5 (indicating that the point is centered on the screen), we interpolate the scaling linearly from 1.0f to the target scaling value. On the other hand, if pageOffset
the absolute value of is greater than or equal to 0.5, we inversely interpolate so that the point smoothly transitions back to its original size.
Use this modifier as before wormTransition
.
Box(
Modifier
.jumpingDotTransition(pagerState, 0.8f)
.size(20.dp)
.background(
color = activeColor,
shape = CircleShape,
)
)
bounce point indicator
This indicator is very similar to the previous indicators. We'll use the same logic for scaling and translating the x value. Also, we'll change the Y position for the bouncing effect. Let's see how to do it. We compute a factor by multiplying pageOffset
the absolute value of by . Math.PI
This factor controls the vertical movement of the point during the bouncing motion.
Based on the relationship between current and settledPage
, we determine the direction of the bounce. If current is greater than or equal to settledPage
, jumpOffset
multiply to compute a negative y-value. Otherwise, we multiply the sine function by half the distance to calculate a positive y-value. This pager
creates a bounce effect in the opposite direction when scrolling back to the previous page.
You can achieve this effect by using this modifier in the same way as the previous indicator.
exchange point indicator
Here we use Canvas composition to draw the point indicator. The width of the Canvas is calculated from the total number of indicators and their combined width and spacing. Within the Canvas scope,
posOffset
Indicates the current page position and fractional offset.
dotOffset
Captures posOffset
the fractional portion of the , representing the offset within the current page.
current
Is posOffset
the integer part, indicating the current page index.
moveX
Determines the horizontal position of the point indicator. It does calculations differently depending on the relationship between i
and . current
If i equals current
, use posOffset
as position. If i - 1
equal current
, use i - dotOffset
as position. Otherwise, use i as the position.
Let's see the output.
Show point indicator
We will see two different effects.
Effect #1
This effect renders a dot indicator that gradually appears and scales according to the pager state.
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun RevealDotIndicator1(
count: Int,
pagerState: PagerState,
activeColor: Color = Color.White,
) {
val circleSpacing = 8.dp
val circleSize = 20.dp
val innerCircle = 14.dp
Box(
modifier = Modifier
.fillMaxWidth()
.height(48.dp), contentAlignment = Alignment.Center
) {
val width = (circleSize * count) + (circleSpacing * (count - 1))
Canvas(modifier = Modifier.width(width = width)) {
val distance = (circleSize + circleSpacing).toPx()
val centerY = size.height / 2
val startX = circleSpacing.toPx()
repeat(count) {
val pageOffset = pagerState.calculateCurrentOffsetForPage(it)
val scale = 0.2f.coerceAtLeast(1 - pageOffset.absoluteValue)
val outlineStroke = Stroke(2.dp.toPx())
val x = startX + (it * distance)
val circleCenter = Offset(x, centerY)
val innerRadius = innerCircle.toPx() / 2
val radius = (circleSize.toPx() * scale) / 2
drawCircle(
color = activeColor,
style = outlineStroke,
center = circleCenter,
radius = radius
)
drawCircle(
color = activeColor,
center = circleCenter,
radius = innerRadius
)
}
}
}
}
scale
The variable determines pageOffset
the scaling of the point indicator based on the absolute value of and radius
represents the scaling radius based on circleSize
and . scale
This creates a scaling effect where the point indicator pager
grows larger and reveals itself as the state changes, as shown in the image above.
Effect #2
In the above effect, we draw a circle with a Stroke style, and simply modify the effect on the basis of #1 to get another effect.
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun RevealDotIndicator2(
count: Int,
pagerState: PagerState,
) {
val circleSpacing = 8.dp
val circleSize = 20.dp
val innerCircle = 14.dp
Box(
modifier = Modifier
.fillMaxWidth()
.height(48.dp), contentAlignment = Alignment.Center
) {
Canvas(modifier = Modifier) {
val distance = (circleSize + circleSpacing).toPx()
val centerX = size.width / 2
val centerY = size.height / 2
val totalWidth = distance * count
val startX = centerX - (totalWidth / 2) + (circleSize / 2).toPx()
repeat(count) {
val pageOffset = pagerState.calculateCurrentOffsetForPage(it)
val alpha = 0.8f.coerceAtLeast(1 - pageOffset.absoluteValue)
val scale = 1f.coerceAtMost(pageOffset.absoluteValue)
val x = startX + (it * distance)
val circleCenter = Offset(x, centerY)
val radius = circleSize.toPx() / 2
val innerRadius = (innerCircle.toPx() * scale) / 2
drawCircle(
color = Color.White, center = circleCenter,
radius = radius, alpha = alpha,
)
drawCircle(color = Color(0xFFE77F82), center = circleCenter, radius = innerRadius)
}
}
}
}
The result is as follows:
in conclusion
Hope this article gave you valuable insight and inspiration to try out custom pager indicators in your Jetpack Compose project.
Now, it's time to take your app navigation to the next level and impress your users.