Search code examples
androidandroid-jetpack-composematerial-design

Displaying Selected Tab Text with ScrollableTabRow Indicator - Unexpected Behavior


As you may already know, the ScrollableTabRow component has an input parameter named "Indicator"

When I use this parameter to draw a box around the selected item, the output differs from my expectations. This issue arises because ScrollableTabRow calculates and draws the indicator in the final step, causing the text value of the selected tab not to be displayed.

To resolve this problem, I could easily copy the entire ScrollableTabRow and modify its behavior. However, it seems there should be a better way to handle this situation. Can someone please provide guidance on achieving the desired behavior?

What I expect: enter image description here

What I see:

enter image description here

Code:



enum class TrackScreenTab(val text: UiText, val index: Int) {
    MUSIC(
        index = 0,
        text = UiText.StringResource(R.string.music))
    ,
    COMMENTS(
        index = 1,
        text = UiText.StringResource(R.string.comments))
    ,
    SIMILAR_MUSICS(
        index = 2,
        text = UiText.StringResource(R.string.simular_musics)
    )
}



@Composable
fun TrackTabRow(
    modifier: Modifier = Modifier,
    tabs: List<TrackScreenTab>,
    selectedTab: TrackScreenTab = TrackScreenTab.MUSIC,
    onTabSelected: (selectedTab: TrackScreenTab) -> Unit
) {
    ScrollableTabRow(
        selectedTabIndex = selectedTab.index,
        modifier = modifier,
        edgePadding = 16.dp,
        tabs = {
            tabs.forEachIndexed { index, tab ->
                Tab(
                    selected = selectedTab.index == index,
                    onClick = remember { { onTabSelected(tab) } },
                    text = {
                        Text(
                            text = tab.text.asString(),
                            style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.Bold),
                        )
                    },
                    modifier = Modifier
                        .padding(end = 8.dp)
                        .height(height = 36.dp)
                )
            }
        },
        divider = {},
        indicator = { tabPositions: List<TabPosition> ->
            Box(
                Modifier
                    .tabIndicatorOffset(tabPositions[selectedTab.index])
                    .fillMaxWidth()
                    .height(36.dp)
                    .background(
                        brush = Brush.verticalGradient(
                            colors = listOf(
                                Color(0XFF00B2FF),
                                Color(0XFF00F0FF)
                            )
                        ),
                        shape = RoundedCornerShape(16.dp)
                    )
            )
        },
        containerColor = Color.Transparent,
        contentColor = Color(0XFF9E9FB4)
    )
}

Solution

  • This issue arises because ScrollableTabRow calculates and draws the indicator in the final step, causing the text value of the selected tab not to be displayed.

    This is correct but since they are all siblings, placed in same layout(), using Modifier.zIndex(1f) can place tabs above indicator.

    enter image description here

    tabs = {
        tabs.forEachIndexed { index, tab ->
            Tab(
                selected = selectedTab.index == index,
                onClick = remember { { onTabSelected(tab) } },
                text = {
                    Text(
                        text = tab.text,
                        color = if (selectedTab.index == index) Color.Blue else Color.Gray,
                        style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.Bold),
                    )
                },
                modifier = Modifier
                    .zIndex(1f)
                    .padding(end = 8.dp)
                    .height(height = 36.dp)
            )
        }
    },
    

    Demo to try out

    @Preview
    @Composable
    private fun Test() {
        val tabs = remember {
            listOf(
                TrackScreenTab.MUSIC,
                TrackScreenTab.COMMENTS,
                TrackScreenTab.SIMILAR_MUSICS
            )
        }
    
        var selectedTab by remember {
            mutableStateOf(
                tabs[0]
            )
        }
    
        TrackTabRow(
            tabs = tabs,
            selectedTab = selectedTab
        ) {
            selectedTab = it
        }
    }
    
    enum class TrackScreenTab(val text: String, val index: Int) {
        MUSIC(
            index = 0,
            text = "Music"
        ),
        COMMENTS(
            index = 1,
            text = "Comments"
        ),
        SIMILAR_MUSICS(
            index = 2,
            text = "Similar Music"
        )
    }
    
    
    @Composable
    fun TrackTabRow(
        modifier: Modifier = Modifier,
        tabs: List<TrackScreenTab>,
        selectedTab: TrackScreenTab = TrackScreenTab.MUSIC,
        onTabSelected: (selectedTab: TrackScreenTab) -> Unit
    ) {
        ScrollableTabRow(
            selectedTabIndex = selectedTab.index,
            modifier = modifier,
            edgePadding = 16.dp,
    tabs = {
        tabs.forEachIndexed { index, tab ->
            Tab(
                selected = selectedTab.index == index,
                onClick = remember { { onTabSelected(tab) } },
                text = {
                    Text(
                        text = tab.text,
                        color = if (selectedTab.index == index) Color.Blue else Color.Gray,
                        style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.Bold),
                    )
                },
                modifier = Modifier
                    .zIndex(1f)
                    .padding(end = 8.dp)
                    .height(height = 36.dp)
            )
        }
    },
            divider = {},
            indicator = { tabPositions: List<TabPosition> ->
                Box(
                    Modifier
                        .tabIndicatorOffset(tabPositions[selectedTab.index])
                        .fillMaxWidth()
                        .height(36.dp)
                        .background(
                            brush = Brush.verticalGradient(
                                colors = listOf(
                                    Color(0XFF00B2FF),
                                    Color(0XFF00F0FF)
                                )
                            ),
                            shape = RoundedCornerShape(16.dp)
                        )
                )
            },
            containerColor = Color.Transparent,
            contentColor = Color(0XFF9E9FB4)
        )
    }
    

    This how they are placed for anyone interested.

            // Position the children.
            layout(layoutWidth, layoutHeight) {
                // Place the tabs
                val tabPositions = mutableListOf<TabPosition>()
                var left = padding
                tabPlaceables.forEach {
                    it.placeRelative(left, 0)
                    tabPositions.add(TabPosition(left = left.toDp(), width = it.width.toDp()))
                    left += it.width
                }
    
                // The divider is measured with its own height, and width equal to the total width
                // of the tab row, and then placed on top of the tabs.
                subcompose(TabSlots.Divider, divider).forEach {
                    val placeable = it.measure(
                        constraints.copy(
                            minHeight = 0,
                            minWidth = layoutWidth,
                            maxWidth = layoutWidth
                        )
                    )
                    placeable.placeRelative(0, layoutHeight - placeable.height)
                }
    
                // The indicator container is measured to fill the entire space occupied by the tab
                // row, and then placed on top of the divider.
                subcompose(TabSlots.Indicator) {
                    indicator(tabPositions)
                }.forEach {
                    it.measure(Constraints.fixed(layoutWidth, layoutHeight)).placeRelative(0, 0)
                }
    
                scrollableTabData.onLaidOut(
                    density = this@SubcomposeLayout,
                    edgeOffset = padding,
                    tabPositions = tabPositions,
                    selectedTab = selectedTabIndex
                )
            }