Subscribe for UI recipes
Custom TabRow Indicator in Jetpack Compose
Build a custom TabRow Indicator that draws a curved path around the selected page.
In the last tutorial, we looked at how to create unique pager indicators. These work for the user to know what page index they are on in relation to all the pages. But other than that, they do not give the user a glimpse of what is contained in the other pages, let alone navigate to them.
Enter TabRow
, and its variant, ScrollableTabRow
.
You have probably seen these before. They are a horizontally arranged list of all the page titles in a Pager
. The current index usually has an indicator and the user can click other tabs to instantly navigate to those pages.
In this article, we will learn how to make a custom indicator for TabRow
and ScrollableTabRow
.
Note:TabRow
andScrollableTabRow
are identical besides the fact that the latter can scroll horizontally (duh!).
Default Setup
First, let's get the basics out of the way. This how to make a default TabRow:
val scope = rememberCoroutineScope()
val pagerState = rememberPagerState()
ScrollableTabRow(
selectedTabIndex = pagerState.currentPage,
) {
list.forEachIndexed { index, recipe ->
Tab(
selected = index == pagerState.currentPage,
onClick = {
scope.launch {
pagerState.animateScrollToPage(index)
}
},
) {
Text(
text = recipe.title,
modifier = Modifier
.padding(horizontal = 32.dp, vertical = 16.dp)
)
}
}}
PagerContent(pagerState = pagerState, list = list)
First, we create a scope that we will use for animating our Pager
plus a pager state in order to connect with our Pager
.
The only required argument that ScrollableTabRow
needs is the current index, which we pass in the pagerState.currentPage
. We will add more arguments here later on.
Inside the content of ScrollableTabRow
, we put all our tabs from our list. So for each item, we are creating a Tab
. Each Tab
should know whether or not it is the current tab (selected
), and how it should behave when clicked (onClick
). We also add how the Tab
should display itself. In this case, it shows the title of the page.
We only add a text showing the title here, but keep in mind that this is a composable function. Instead of just a Text
composable, you can add anything else. For example, you could add a badge to indicate unread items in a certain page. Or you can add any of the fancy jetpack compose animations. Anything goes!
Now that we have a basic version, let's customize it.
Tab Row Customization
There are multiple arguments that we can pass into our Tab Row to make it more unique.
ScrollableTabRow(
selectedTabIndex = pagerState.currentPage,
edgePadding = 20.dp,
backgroundColor = Color.Transparent,
contentColor = Color(0xFF362C28),
divider = {},
indicator = { /* custom indicator will goes here */ },
) { ... }
First, we will add an edgePadding
. This adds padding to the start of the Tab Row so that the first item is not at the edge of the screen.
Next, we remove the backgroundColor
by setting it to Transparent. You could set this to any other color according to your desired design, but in my case, I do not need it.
The contentColor
is the color of the page titles. We define this here, instead of inside the Text
so that TabRow can handle active and inactive tab colors.
Finally we remove the divider
by setting it to an empty composable. This would add a divider at the bottom of the Tab Row, but I do not need it for my design.
One last argument we can add is an indicator
. This composable is an overlay over our tabs and we can use this to indicate which tab is active. Here is where we shall build our custom indicator.
Custom Indicator
The idea behind this custom indicator is to have a hand-drawn style circle around the current page title.
How to draw the indicator
First, we need some dimensions so that we can calculate the circles.
val sizeList = remember { mutableStateMapOf<Int, Pair<Float, Float>>() }
...
list.forEachIndexed { index, recipe ->
Tab(
selected = index == pagerState.currentPage,
onClick = { ... },
modifier = Modifier
.onSizeChanged {
sizeList[index] = Pair(it.width.toFloat(), it.height.toFloat())
}
) { ...
We use the onSizeChanged
modifier to collect the width and height of all tab titles and keep them in a map with their index as the key.
We then use this to create a long continuous path that goes around each and every tab title.
indicator = {
Box(modifier = Modifier
.padding(start = 20.dp)
.fillMaxSize()
.drawBehind {
var currentOrigin = 0f
val path = Path()
sizeList.keys
.sorted()
.mapNotNull { sizeList[it] }
.forEachIndexed { index, (width, height) ->
val bottom = height - 10f
val top = 10f
if (index == 0) path.moveTo(0f, top)
path.quadraticBezierTo(
currentOrigin + width,
top,
currentOrigin + width,
height / 2,
)
path.quadraticBezierTo(
currentOrigin + width,
bottom,
currentOrigin + (width / 2),
bottom,
)
path.quadraticBezierTo(
currentOrigin + 0f,
bottom,
currentOrigin + 0f,
height / 2,
)
path.quadraticBezierTo(
currentOrigin,
top,
currentOrigin + width,
top,
)
currentOrigin += width
}
drawPath(
path = path,
color = Color(0xFF7A6151),
style = Stroke(
width = 20f,
cap = StrokeCap.Round,
join = StrokeJoin.Round,
)
)
}
)
}
Ok, this is a big chunk of code, let's break it down. Inside the indicator
, we create a Box
in which we will draw our indicator. Before we get to drawing, we set it's padding to 20.dp
. This matches the edgePadding
we added in the earlier section.
In the drawBehind
modifier, we can draw using canvas functions. We will be only using the drawPath()
function.
To create a path, we first create the Path
object. Then we create the path by looping through our list of sorted sizes. On our first iteration, we set the beginning position of our path.
After that, we create quadratic bezier curves to circle around the tab. A quadratic bezier is simply a curve that has a starting point and a destination, but is influenced and curved by a control point. (The starting point is wherever the path ended drawing at).
path.quadraticBezierTo(
controlPointX,
controlPointY,
destinationX,
destinationY,
)
After drawing all 4 bezier curves, we increment the currentOrigin
. This variable keeps track of the leftmost position of the current tab being drawn on.
After defining the entire path, we now draw it on screen. As mentioned, we call drawPath()
and pass in the path
itself and information on how to draw it. This is the color of the path, and the shapes of corners and caps.
Now we have a long ribbon that wraps around all our tabs. But how do we make it so that only the selected tab is wrapped and how do we animate between pages?
How to animate the indicator
To be able to animate, we need to get the progress of the pager, so as to how far we are progressing along the pager. We can get this value by like this:
val progressFromFirstPage by remember {
derivedStateOf {
pagerState.offsetForPage(0)
}
}
This gives us a float that we can use to determine what part of the ribbon we should draw.
Speaking of the ribbon, we need to get the measurement of the part of the ribbon that wraps around a particular tab.
.drawBehind {
val ribbonSectionsLengths = mutableMapOf<Int, Float>()
var currentRibbonLength = 0f
...
ribbonSectionsLengths
will keep track of the length of the ribbon per section of a certain index, while currentRibbonLength
will keep track of the running total of the ribbon length.
...
.forEachIndexed { index, (width, height) ->
// ALL THE path.quadraticBezierTo() FUNCTION CALLS FROM THE PREVIOUS SECTION.
val measure = PathMeasure()
measure.setPath(path, false)
val length = measure.length
ribbonSectionsLengths[index] = length - currentRibbonLength
currentRibbonLength = length
}
...
After defining the curve for each tab, we will calculate the length of that section and add it to the map. We do this by using PathMeaseure
. We create the object and then set the path to measure.
After setting the path, we can call measure.length
to get the current length of the ribbon so far.
Now we just simply subtract the previous length so that we are left with only that one section and add this length to ribbonSectionsLengths
.
We now have the length of every section of our ribbon. Using this, we can draw only certain sections of the path.PathEffect.dashPathEffect()
Is usually used to draw a path as a dashed line. But we can give it certain values and animate them to make it look like a ribbon moving around. Make the dashes the exact size of the ribbon section, and the gaps the entire length of the ribbon. And then it's just a matter of animating where the first dash starts.
pathEffect = PathEffect.dashPathEffect(
intervals = floatArrayOf(
DASH_LENGTH, GAP_LENGTH
),
phase = START_OFFSET,
)
Here is a visualization to help clarify:
How do we apply this to our tabs? We need to calculate the current ribbon length as the user swipes through the page. We will then pass this in for DASH_LENGTH
.
val progress = progressFromFirstPage - floor(progressFromFirstPage)
val start = floor(progressFromFirstPage)
.toInt()
.coerceIn(0, ribbonSectionsLengths.size - 1)
val end = ceil(progressFromFirstPage)
.toInt()
.coerceIn(0, ribbonSectionsLengths.size - 1)
val ribbonLength =
ribbonSectionsLengths[start]!! + ((ribbonSectionsLengths[end]!! - ribbonSectionsLengths[start]!!) * progress)
We calculate progress
to be the progress between pages (0f
- 1f
). Then we get the page index we are starting from and the page index we will end at. With both these indices, we can get both section lengths, and then animate between them using progress
.
Next, we get how much we need to offset the path from the start, which we will pass in for START_OFFSET
in our PathEffect
val lengthUntilStart = ribbonSectionsLengths
.keys
.sorted()
.map { ribbonSectionsLengths[it] ?: 0f }
.take(start)
.fold(0f) { acc, it -> acc - it }
val lengthUntilEnd = ribbonSectionsLengths
.keys
.sorted()
.map { ribbonSectionsLengths[it] ?: 0f }
.take(end)
.fold(0f) { acc, it -> acc - it }
val phaseOffset =
lengthUntilStart + ((lengthUntilEnd - lengthUntilStart) * progress)
Same deal as the ribbon length but this time we are getting the length until the start of the section of the ribbon. And then we animate it to the end of the section of the ribbon.
And finally, we have this animation:
Final code is available here. Feel free to try it out or even implement it in other UI elements. I think it would work very well on a bottom navigation animation ;)
Thanks for reading and good luck!