Subscribe for UI recipes
Five Pager Indicator Animations
Learn how to animate a pager indicator along with five examples
In the previous parts of this series, we looked at how to set up the Pager and how to animate its transitions.
Here we shall look into how to create custom animated Pager indicators.
Using Accompanist library
There is already an accompanist library that enables us to create beautiful animated indicators out of the box. For this option, I would recommend their already useful guide on how to set it up.
They have a tutorial on how to set up basic indicators to a scrolling tabs with titles.
If that's all you need for now, you could stop reading.
If you want to see some unreasonable convoluted ways of animating indicators, come with me.
Calculating Values
Just like with the transitions, the PagerState
will be our friend here. We can create animations based on the state it produces. In part two, I explained the values produced by PagerState
and how we can transform them with extension functions.
For this article, I shall build off of that and make another extension function that will help us create indicators.
fun PagerState.offsetForPage(page: Int) = (currentPage - page) + currentPageOffsetFraction
// NEW FUNCTION FOR INDICATORS
fun PagerState.indicatorOffsetForPage(page: Int) = 1f - offsetForPage(page).coerceIn(-1f, 1f).absoluteValue
This works by giving the current page a value of 1f
and very other page as 0f
. And then it progresses between 0f
and 1f
based on how far the page has been swiped.
The animation below shows the values change as the pages are swiped.
Using this float value, we can make animations with anything that can be animated progressively (eg. scale, width, color, etc.)
Now let us look at how to apply this function with some examples
1. Circle Indicator
For this indicator, we create a circle border and just animate the size of the shape. We do this by getting offset
using the extension function, indicatorOffsetForPage
.
With this float value, we can animate the size using a lerp function.
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun CircleIndicator(
modifier: Modifier = Modifier,
state: PagerState
) {
Row(
modifier = modifier
.fillMaxWidth()
.height(48.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
) {
for (i in 0 until NUM_OF_PAGES) {
val offset = state.indicatorOffsetForPage(i)
Box(
modifier = Modifier.size(32.dp),
contentAlignment = Alignment.Center
) {
Box(
Modifier
.size(
lerp(6.dp, 32.dp, offset)
)
.border(
width = 3.dp,
color = Color.Black,
shape = CircleShape,
)
)
}
}
}
}
2. Line Indicator
Other than the size of a composable, we can also animate the weight. We can use this to make the selected index to appear larger than the rest.weight
indicates how much space a composable should take, respective to its sibling composables and its parent size. For example, if all composables had a weight of 1f
, then they would take up the same amount of space.
But if one composables has a larger weight, it will take up more space than its siblings. So we can use the indicatorOffsetForPage
function to calculate the weight like so:
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun LineIndicator(
modifier: Modifier = Modifier,
state: PagerState
) {
Row(
modifier = modifier
.width(32.dp * NUM_OF_PAGES)
.height(48.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
) {
for (i in 0 until NUM_OF_PAGES) {
val offset = state.indicatorOffsetForPage(i)
Box(
modifier = Modifier
.padding(horizontal = 2.dp)
.weight(.5f + (offset * 3f))
.height(8.dp)
.background(
color = Color.Black,
shape = CircleShape
),
contentAlignment = Alignment.Center
) {
} }
}
}
3. Glow Indicator
We have already learned how to animate the size of a circle for a pager indicator. In this example, we just have to do this twice.
The "background" circle will animate to larger size than the "foreground", but still start from the same size value.
On top of that, we will animate a blur on the "background" circle using the offset
value. Add a vibrant gradient as the background, et voila!
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun GlowIndicator(
modifier: Modifier = Modifier,
state: PagerState
) {
Row(
modifier = modifier
.fillMaxWidth()
.height(48.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
) {
for (i in 0 until NUM_OF_PAGES) {
val offset = state.indicatorOffsetForPage(i)
Box(
modifier = Modifier
.size(32.dp),
contentAlignment = Alignment.Center
) {
Box(
Modifier
.size(
lerp(14.dp, 32.dp, offset)
)
.blur(
radius = lerp(0.dp, 16.dp, offset),
edgeTreatment = BlurredEdgeTreatment.Unbounded,
)
.background(
brush = Brush.horizontalGradient(
colors = listOf(
Color.Cyan,
Color.Magenta,
)
),
shape = CircleShape
)
)
Box(
Modifier
.size(
lerp(14.dp, 22.dp, offset)
)
.background(
color = Color.Black,
shape = CircleShape,
)
)
}
}
}
}
4. Square Indicator
This one also has a size animation, but the extra twist;) is that we rotate the composable using the offset
.
This is easily done by multiplying the offset
by the max degrees, and passing that into the rotate
modifier.
Hopefully, from this you can see that we can use the offset
to manipulate anything, be it a modifier or a parameter of a composable.
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun SquareIndicator(
modifier: Modifier = Modifier,
state: PagerState
) {
Row(
modifier = modifier
.fillMaxWidth()
.height(48.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
) {
for (i in 0 until NUM_OF_PAGES) {
Box(
modifier = Modifier.size(32.dp),
contentAlignment = Alignment.Center
) {
val offset = state.indicatorOffsetForPage(i)
Box(
Modifier
.rotate(135f * offset)
.size(
lerp(12.dp, 22.dp, offset)
)
.border(
width = 3.dp,
color = Color.Black,
shape = RectangleShape,
)
)
}
}
}
}
5. Roman Numeral Indicator
Lastly, this is an example to show that any value we can lerp
, we can use that for our indicator.
In a previous article, I wrote about a lerp
function in compose for TextStyle
. This function animates between font sizes, colors, font weights, etc. It just needs two TextStyle
s and a float (our lovely offset
of course).
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun RomanNumeralIndicator(
modifier: Modifier = Modifier,
state: PagerState
) {
Row(
modifier = modifier
.fillMaxWidth()
.height(48.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
) {
val selectedTextStyle = remember {
TextStyle(
fontSize = 36.sp,
fontWeight = FontWeight.Black,
color = Color(0xFF000000)
)
}
val defaultTextStyle = remember {
TextStyle(
fontSize = 18.sp,
fontWeight = FontWeight.Thin,
color = Color.Black.copy(alpha = .5f)
)
}
for (i in 0 until NUM_OF_PAGES) {
Box(
modifier = Modifier
.size(height = 48.dp, width = 36.dp)
.align(Alignment.CenterVertically),
contentAlignment = Alignment.CenterEnd
) {
val offset = state.indicatorOffsetForPage(i)
Text(
text = (i + 1).toRomanNumeral(),
modifier = Modifier.fillMaxWidth(),
style = lerp(defaultTextStyle, selectedTextStyle, offset),
textAlign = TextAlign.Center,
)
}
}
}
}
That's all for now. I hope some of these sparked your creativity on what you can create.
Thanks for reading and good luck!