What You Will Build
A custom segmented control (tab selector) with rounded corners, highlighted selection state, and content that changes based on the selected segment. This is the Compose equivalent of iOS UISegmentedControl, commonly used for toggling between views like Daily/Weekly/Monthly.
Why This Pattern Matters
While Material 3 introduced SegmentedButton, building your own gives full design control. This pattern teaches Row-based layouts with weight distribution, conditional backgrounds, and index-based selection management.
The Complete Implementation
@Composable
fun CustomSegmentedControlScreen() {
var selectedIndex by remember { mutableIntStateOf(0) }
val options = listOf("Daily", "Weekly", "Monthly")
Column(
modifier = Modifier.fillMaxSize().padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text("Custom Segmented Control",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold)
Spacer(Modifier.height(32.dp))
// Segmented control
Row(
modifier = Modifier.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(MaterialTheme.colorScheme.surfaceVariant)
) {
options.forEachIndexed { index, label ->
val isSelected = selectedIndex == index
Box(
modifier = Modifier
.weight(1f)
.clip(RoundedCornerShape(12.dp))
.background(
if (isSelected) MaterialTheme.colorScheme.primary
else Color.Transparent
)
.clickable { selectedIndex = index }
.padding(vertical = 12.dp),
contentAlignment = Alignment.Center
) {
Text(
label,
color = if (isSelected) Color.White
else MaterialTheme.colorScheme.onSurfaceVariant,
fontWeight = if (isSelected) FontWeight.Bold
else FontWeight.Normal
)
}
}
}
Spacer(Modifier.height(24.dp))
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp)) {
Text("Selected: ${options[selectedIndex]}",
style = MaterialTheme.typography.titleMedium)
}
}
}
}
Adding Animation
To animate the selection indicator sliding between segments, use animateDpAsState for the indicator offset:
val indicatorOffset by animateDpAsState(
targetValue = (selectedIndex * segmentWidth).dp,
animationSpec = spring(dampingRatio = 0.8f),
label = "indicator"
)
// Use this offset with Modifier.offset(x = indicatorOffset) on the
// selection highlight Box, positioned absolutely within the Row
Tips and Pitfalls
- weight(1f) on each segment ensures equal distribution regardless of text length.
- Clip the outer Row and each Box with the same corner radius so the selected state fits cleanly within the track.
- mutableIntStateOf is the correct state type for indices. Avoid mutableStateOf<Int> to prevent boxing.
- For accessibility: Add
Modifier.semantics { selected = isSelected }to each segment for screen reader support.
Min SDK: 21 | Compose BOM: 2024.01.00+