TabRow
Component in Material 3 Compose
Material Design fixed tabs.
For primary indicator tabs, use [PrimaryTabRow]. For secondary indicator tabs, use [SecondaryTabRow].
Fixed tabs display all tabs in a set simultaneously. They are best for switching between related content quickly, such as between transportation methods in a map. To navigate between fixed tabs, tap an individual tab, or swipe left or right in the content area.
A TabRow contains a row of [Tab]s, and displays an indicator underneath the currently selected tab. A TabRow places its tabs evenly spaced along the entire row, with each tab taking up an equal amount of space. See [ScrollableTabRow] for a tab row that does not enforce equal size, and allows scrolling to tabs that do not fit on screen.
A simple example with text tabs looks like:
@sample androidx.compose.material3.samples.TextTabs
You can also provide your own custom tab, such as:
@sample androidx.compose.material3.samples.FancyTabs
Where the custom tab itself could look like:
@sample androidx.compose.material3.samples.FancyTab
As well as customizing the tab, you can also provide a custom [indicator], to customize the indicator displayed for a tab. [indicator] will be placed to fill the entire TabRow, so it should internally take care of sizing and positioning the indicator to match changes to [selectedTabIndex].
For example, given an indicator that draws a rounded rectangle near the edges of the [Tab]:
@sample androidx.compose.material3.samples.FancyIndicator
We can reuse [TabRowDefaults.tabIndicatorOffset] and just provide this indicator, as we aren't changing how the size and position of the indicator changes between tabs:
@sample androidx.compose.material3.samples.FancyIndicatorTabs
You may also want to use a custom transition, to allow you to dynamically change the appearance of the indicator as it animates between tabs, such as changing its color or size. [indicator] is stacked on top of the entire TabRow, so you just need to provide a custom transition that animates the offset of the indicator from the start of the TabRow. For example, take the following example that uses a transition to animate the offset, width, and color of the same FancyIndicator from before, also adding a physics based 'spring' effect to the indicator in the direction of motion:
@sample androidx.compose.material3.samples.FancyAnimatedIndicatorWithModifier
Last updated:
Installation
dependencies {
implementation("androidx.compose.material3:material3:1.4.0-alpha02")
}
Overloads
@Composable
fun TabRow(
selectedTabIndex: Int,
modifier: Modifier = Modifier,
containerColor: Color = TabRowDefaults.primaryContainerColor,
contentColor: Color = TabRowDefaults.primaryContentColor,
indicator: @Composable (tabPositions: List<TabPosition>) -> Unit =
@Composable { tabPositions ->
if (selectedTabIndex < tabPositions.size) {
TabRowDefaults.SecondaryIndicator(
Modifier.tabIndicatorOffset(tabPositions[selectedTabIndex])
)
}
},
divider: @Composable () -> Unit = @Composable { HorizontalDivider() },
tabs: @Composable () -> Unit
)
Parameters
name | description |
---|---|
selectedTabIndex | the index of the currently selected tab |
modifier | the [Modifier] to be applied to this tab row |
containerColor | the color used for the background of this tab row. Use [Color.Transparent] to have no color. |
contentColor | the preferred color for content inside this tab row. Defaults to either the matching content color for [containerColor], or to the current [LocalContentColor] if [containerColor] is not a color from the theme. |
indicator | the indicator that represents which tab is currently selected. By default this will be a [TabRowDefaults.SecondaryIndicator], using a [TabRowDefaults.tabIndicatorOffset] modifier to animate its position. Note that this indicator will be forced to fill up the entire tab row, so you should use [TabRowDefaults.tabIndicatorOffset] or similar to animate the actual drawn indicator inside this space, and provide an offset from the start. |
divider | the divider displayed at the bottom of the tab row. This provides a layer of separation between the tab row and the content displayed underneath. |
tabs | the tabs inside this tab row. Typically this will be multiple [Tab]s. Each element inside this lambda will be measured and placed evenly across the row, each taking up equal space. |
Code Examples
TextTabs
@Preview
@Composable
@OptIn(ExperimentalMaterial3Api::class)
fun TextTabs() {
var state by remember { mutableStateOf(0) }
val titles = listOf("Tab 1", "Tab 2", "Tab 3 with lots of text")
Column {
PrimaryTabRow(selectedTabIndex = state) {
titles.forEachIndexed { index, title ->
Tab(
selected = state == index,
onClick = { state = index },
text = { Text(text = title, maxLines = 2, overflow = TextOverflow.Ellipsis) }
)
}
}
Text(
modifier = Modifier.align(Alignment.CenterHorizontally),
text = "Text tab ${state + 1} selected",
style = MaterialTheme.typography.bodyLarge
)
}
}
FancyTabs
@Preview
@Composable
@OptIn(ExperimentalMaterial3Api::class)
fun FancyTabs() {
var state by remember { mutableStateOf(0) }
val titles = listOf("Tab 1", "Tab 2", "Tab 3")
Column {
SecondaryTabRow(selectedTabIndex = state) {
titles.forEachIndexed { index, title ->
FancyTab(title = title, onClick = { state = index }, selected = (index == state))
}
}
Text(
modifier = Modifier.align(Alignment.CenterHorizontally),
text = "Fancy tab ${state + 1} selected",
style = MaterialTheme.typography.bodyLarge
)
}
}
FancyTab
@Composable
fun FancyTab(title: String, onClick: () -> Unit, selected: Boolean) {
Tab(selected, onClick) {
Column(
Modifier.padding(10.dp).height(50.dp).fillMaxWidth(),
verticalArrangement = Arrangement.SpaceBetween
) {
Box(
Modifier.size(10.dp)
.align(Alignment.CenterHorizontally)
.background(
color =
if (selected) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.background
)
)
Text(
text = title,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.align(Alignment.CenterHorizontally)
)
}
}
}
FancyIndicator
@Composable
fun FancyIndicator(color: Color, modifier: Modifier = Modifier) {
// Draws a rounded rectangular with border around the Tab, with a 5.dp padding from the edges
// Color is passed in as a parameter [color]
Box(
modifier
.padding(5.dp)
.fillMaxSize()
.border(BorderStroke(2.dp, color), RoundedCornerShape(5.dp))
)
}
FancyIndicatorTabs
@Preview
@Composable
@OptIn(ExperimentalMaterial3Api::class)
fun FancyIndicatorTabs() {
var state by remember { mutableStateOf(0) }
val titles = listOf("Tab 1", "Tab 2", "Tab 3")
Column {
SecondaryTabRow(
selectedTabIndex = state,
indicator = {
FancyIndicator(
MaterialTheme.colorScheme.primary,
Modifier.tabIndicatorOffset(state)
)
}
) {
titles.forEachIndexed { index, title ->
Tab(selected = state == index, onClick = { state = index }, text = { Text(title) })
}
}
Text(
modifier = Modifier.align(Alignment.CenterHorizontally),
text = "Fancy indicator tab ${state + 1} selected",
style = MaterialTheme.typography.bodyLarge
)
}
}
FancyAnimatedIndicatorWithModifier
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TabIndicatorScope.FancyAnimatedIndicatorWithModifier(index: Int) {
val colors =
listOf(
MaterialTheme.colorScheme.primary,
MaterialTheme.colorScheme.secondary,
MaterialTheme.colorScheme.tertiary,
)
var startAnimatable by remember { mutableStateOf<Animatable<Dp, AnimationVector1D>?>(null) }
var endAnimatable by remember { mutableStateOf<Animatable<Dp, AnimationVector1D>?>(null) }
val coroutineScope = rememberCoroutineScope()
val indicatorColor: Color by animateColorAsState(colors[index % colors.size], label = "")
Box(
Modifier.tabIndicatorLayout {
measurable: Measurable,
constraints: Constraints,
tabPositions: List<TabPosition> ->
val newStart = tabPositions[index].left
val newEnd = tabPositions[index].right
val startAnim =
startAnimatable
?: Animatable(newStart, Dp.VectorConverter).also { startAnimatable = it }
val endAnim =
endAnimatable
?: Animatable(newEnd, Dp.VectorConverter).also { endAnimatable = it }
if (endAnim.targetValue != newEnd) {
coroutineScope.launch {
endAnim.animateTo(
newEnd,
animationSpec =
if (endAnim.targetValue < newEnd) {
spring(dampingRatio = 1f, stiffness = 1000f)
} else {
spring(dampingRatio = 1f, stiffness = 50f)
}
)
}
}
if (startAnim.targetValue != newStart) {
coroutineScope.launch {
startAnim.animateTo(
newStart,
animationSpec =
// Handle directionality here, if we are moving to the right, we
// want the right side of the indicator to move faster, if we are
// moving to the left, we want the left side to move faster.
if (startAnim.targetValue < newStart) {
spring(dampingRatio = 1f, stiffness = 50f)
} else {
spring(dampingRatio = 1f, stiffness = 1000f)
}
)
}
}
val indicatorEnd = endAnim.value.roundToPx()
val indicatorStart = startAnim.value.roundToPx()
// Apply an offset from the start to correctly position the indicator around the tab
val placeable =
measurable.measure(
constraints.copy(
maxWidth = indicatorEnd - indicatorStart,
minWidth = indicatorEnd - indicatorStart,
)
)
layout(constraints.maxWidth, constraints.maxHeight) {
placeable.place(indicatorStart, 0)
}
}
.padding(5.dp)
.fillMaxSize()
.drawWithContent {
drawRoundRect(
color = indicatorColor,
cornerRadius = CornerRadius(5.dp.toPx()),
style = Stroke(width = 2.dp.toPx())
)
}
)
}
FancyIndicatorContainerTabs
@Preview
@Composable
@OptIn(ExperimentalMaterial3Api::class)
fun FancyIndicatorContainerTabs() {
var state by remember { mutableStateOf(0) }
val titles = listOf("Tab 1", "Tab 2", "Tab 3")
Column {
SecondaryTabRow(
selectedTabIndex = state,
indicator = { FancyAnimatedIndicatorWithModifier(state) }
) {
titles.forEachIndexed { index, title ->
Tab(selected = state == index, onClick = { state = index }, text = { Text(title) })
}
}
Text(
modifier = Modifier.align(Alignment.CenterHorizontally),
text = "Fancy transition tab ${state + 1} selected",
style = MaterialTheme.typography.bodyLarge
)
}
}