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 see:
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)
)
}
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.
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
)
}