6

My problem is that i need a tab indicator to match exactly to the text that is above it (from designs):

proper

However, all i managed to do is get something looking like this:

improper

My code:

 ScrollableTabRow(
            selectedTabIndex = selectedSeason,
            backgroundColor = Color.White,
            edgePadding = 0.dp,
            modifier = Modifier
                .padding(vertical = 24.dp)
                .height(40.dp),
            indicator = { tabPositions ->
                TabDefaults.Indicator(
                    color = Color.Red,
                    height = 4.dp,
                    modifier = Modifier
                        .tabIndicatorOffset(tabPositions[selectedSeason])
                )
            }
        ) {
            item.seasonList().forEachIndexed { index, contentItem ->
                Tab(
                    modifier = Modifier.padding(bottom = 10.dp),
                    selected = index == selectedSeason,
                    onClick = { selectedSeason = index }
                )
                {
                    Text(
                        "Season " + contentItem.seasonNumber(),
                        color = Color.Black,
                        style = styles.seasonBarTextStyle(index == selectedSeason)
                    )
                }
            }

        }
    }

Also a little bonus question, my code for this screen is inside lazy column, now i need to have this tab row to behave somewhat like a sticky header(when it gets to the top, screen stops scrolling, but i can still scroll the items inside it)

Thanks for your help

Pioak
  • 117
  • 1
  • 9

3 Answers3

2

Have a look at the provided modifier, it internally computes a width value. If you change the Modifier yourself to the code below you can provide a width value.

fun Modifier.ownTabIndicatorOffset(
    currentTabPosition: TabPosition,
    currentTabWidth: Dp = currentTabPosition.width
): Modifier = composed(
    inspectorInfo = debugInspectorInfo {
        name = "tabIndicatorOffset"
        value = currentTabPosition
    }
) {
    val indicatorOffset by animateAsState(
        targetValue = currentTabPosition.left,
        animationSpec = tween(durationMillis = 250, easing = FastOutSlowInEasing)
    )
    fillMaxWidth()
        .wrapContentSize(Alignment.BottomStart)
        .offset(x = indicatorOffset + ((currentTabPosition.width - currentTabWidth) / 2))
        .preferredWidth(currentTabWidth)
}

Now to the point of how to get the width of your Text: Warning: I think it's not the way to do it but I can't figure out a better one atm.

At first, I create a Composable to provide me the width of its contents.

@Composable
fun MeasureWidthOf(setWidth: (Int) -> Unit, content: @Composable () -> Unit) {
    Layout(
        content = content
    ) { list: List<Measurable>, constraints: Constraints ->
        check(list.size == 1)
        val placeable = list.last().measure(constraints)
        layout(
            width = placeable.width.also(setWidth),
            height = placeable.height
        ) {
            placeable.placeRelative(x = 0, y = 0)
        }
    }
}

Now I can use it in your example (simplified):

// Needed for Android
fun Float.asPxtoDP(density: Float): Dp {
    return (this / (density)).dp
}

fun main(args: Array<String>) {
    Window(size = IntSize(600, 800)) {
        val (selectedSeason, setSelectedSeason) = remember { mutableStateOf(0) }
        val seasonsList = mutableListOf(2020, 2021, 2022)
        val textWidth = remember { mutableStateListOf(0, 0, 0) }
        // Android
        val density = AmbientDensity.current.density
        ScrollableTabRow(
            selectedTabIndex = selectedSeason,
            backgroundColor = Color.White,
            edgePadding = 0.dp,
            modifier = Modifier
                .padding(vertical = 24.dp)
                .height(40.dp),
            indicator = { tabPositions ->
                TabDefaults.Indicator(
                    color = Color.Red,
                    height = 4.dp,
                    modifier = Modifier
                        .ownTabIndicatorOffset(
                            currentTabPosition = tabPositions[selectedSeason],
                            // Android:
                            currentTabWidth = textWidth[selectedSeason].asPxtoDP(density)
                           // Desktop:
                           currentTabWidth = textWidth[selectedSeason].dp
                        )
                )
            }
        ) {
            seasonsList.forEachIndexed { index, contentItem ->
                Tab(
                    modifier = Modifier.padding(bottom = 10.dp),
                    selected = index == selectedSeason,
                    onClick = { setSelectedSeason(index) }
                )
                {
                    val text = @Composable {
                        Text(
                            text = "Season $contentItem",
                            color = Color.Black,
                            textAlign = TextAlign.Center
                        )
                    }
                    if (index == selectedSeason) {
                        MeasureWidthOf(setWidth = { textWidth[index] = it }) {
                            text()
                        }
                    } else {
                        text()
                    }
                }
            }
        }
    }
}

Edit (05.01.2021): Simplified Modifier code

Edit (09.01.2021): Fixed density problem on android and tested on Desktop and Android

2jan222
  • 1,732
  • 2
  • 16
  • 29
  • On Android sadly something is wrong as when i try to enter screen with those details i get error: https://pastebin.com/MYgaiEnc, ill have to investigate if me using LazyColumn doesnt cause this error of if its something else. Can i bother You with trying to answer: Also a little bonus question, my code for this screen is inside lazy column, now i need to have this tab row to behave somewhat like a sticky header(when it gets to the top, screen stops scrolling, but i can still scroll the items inside it)? – Pioak Jan 06 '21 at 20:25
  • Can you provide a link to the complete code of your use case? – 2jan222 Jan 07 '21 at 08:55
  • Thanks! - https://gist.github.com/piotrsedlak/a7390438c330337a9e57d258b9a149be – Pioak Jan 08 '21 at 10:00
  • 1
    The above solution is correct, except for one screen density issue, which I have addressed in an edit. Please accept it as it answers the question correctly and is not related to your problem. To your problem, I extracted the sticky composable to a topbar of the scaffold composable. The first column expanded on maxSize thus nothing could be seen below it and the tab bar was drawn on top of your buttons. Here is a gist. https://gist.github.com/jan222ik/94a180fba2fbf91de68b965cde0d6511 Hope it helps, in the future please provide better code snippets that one can just copy-paste and run. – 2jan222 Jan 09 '21 at 15:41
2

I had the same requirement, and came up with a simpler solution. By putting the same horizontal padding on the Tab and on the indicator, the indicator aligns with the tab's content:

ScrollableTabRow(selectedTabIndex = tabIndex,
    indicator = { tabPositions ->
        Box(
            Modifier
                .tabIndicatorOffset(tabPositions[tabIndex])
                .height(TabRowDefaults.IndicatorHeight)
                .padding(end = 20.dp)
                .background(color = Color.White)
        )
    }) {
    Tab(modifier = Modifier.padding(end = 20.dp, bottom = 8.dp),
        selected = tabIndex == 0, onClick = { tabIndex = 0}) {
        Text(text = "Tab 1!")
    }
    Tab(modifier = Modifier.padding(end = 20.dp, bottom = 8.dp),
        selected = tabIndex == 1, onClick = { tabIndex = 1}) {
        Text(text = "Tab 2!")
    }
}
mtotschnig
  • 1,238
  • 10
  • 30
0

To get this functionality, add two things to the default Compose TabRow.

First use reflection to get a consistent padding on both sides of tabs. Change the ScrollableTabRowMinimumTabWidth to 0 and change the HorizontalTextPadding to whatever you want the horizontal padding to be. You may need to change material to material3 or vise versa depending on what compose packages you are using.

private fun updateMinimumTabWidth(context: Context) {
    try {
        var field = Class.forName("androidx.compose.material.TabRowKt")
            .getDeclaredField("ScrollableTabRowMinimumTabWidth").apply {
                isAccessible = true
            }
        field.set(context, 0f)

        field = Class.forName("androidx.compose.material3.TabKt")
            .getDeclaredField("HorizontalTextPadding").apply {
                isAccessible = true
            }
        field.set(context, 20f)
    }
    catch (_: Exception) {}
}

Source: Thread

Then place this call in a LaunchedEffect where you are making the TabRow.

LaunchedEffect(Unit) {
    updateMinimumTabWidth(context)
}

Finally make a custom indicator with the same padding as the horizontal text padding.

Box(modifier = Modifier
    .pagerTabIndicatorOffset(pagerState, it)
    .fillMaxWidth()
    .padding(horizontal = 20.dp)
    .height(2.dp)
    .background(Color.White)
)

Note that as compose gets updated this solution may require updating as well.

Eric
  • 2,573
  • 1
  • 23
  • 19