✨ Subscribe for component library

Custom TabRow Indicator in Jetpack Compose

Build a custom TabRow Indicator that draws a curved path around the selected page.

Custom TabRow Indicator in Jetpack Compose

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 and ScrollableTabRow 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.

0:00
/
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.

0:00
/

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.

0:00
/

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:

0:00
/

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:

0:00
/

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!

Mastodon