Last active
October 29, 2025 14:37
-
-
Save sdetilly/4de7e573ad46725c6b2f42867ec0fedb to your computer and use it in GitHub Desktop.
SegmentedControl animation
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import android.os.Bundle | |
| import androidx.activity.ComponentActivity | |
| import androidx.activity.compose.setContent | |
| import androidx.activity.enableEdgeToEdge | |
| import androidx.activity.SystemBarStyle | |
| import androidx.compose.animation.Crossfade | |
| import androidx.compose.animation.animateColorAsState | |
| import androidx.compose.animation.core.animateDpAsState | |
| import androidx.compose.animation.core.tween | |
| import androidx.compose.foundation.background | |
| import androidx.compose.foundation.clickable | |
| import androidx.compose.foundation.layout.Box | |
| import androidx.compose.foundation.layout.IntrinsicSize | |
| import androidx.compose.foundation.layout.Row | |
| import androidx.compose.foundation.layout.fillMaxSize | |
| import androidx.compose.foundation.layout.offset | |
| import androidx.compose.foundation.layout.padding | |
| import androidx.compose.foundation.layout.size | |
| import androidx.compose.foundation.layout.width | |
| import androidx.compose.foundation.shape.RoundedCornerShape | |
| import androidx.compose.material3.Text | |
| import androidx.compose.ui.graphics.toArgb | |
| import androidx.compose.runtime.* | |
| import androidx.compose.runtime.getValue | |
| import androidx.compose.ui.Alignment | |
| import androidx.compose.ui.Modifier | |
| import androidx.compose.ui.draw.clip | |
| import androidx.compose.ui.graphics.Color | |
| import androidx.compose.ui.layout.onGloballyPositioned | |
| import androidx.compose.ui.platform.LocalDensity | |
| import androidx.compose.ui.unit.IntOffset | |
| import androidx.compose.ui.unit.dp | |
| import androidx.lifecycle.compose.collectAsStateWithLifecycle | |
| import com.tillylabs.androiddemo.ui.theme.AndroidDemoTheme | |
| import kotlinx.coroutines.CoroutineScope | |
| import kotlinx.coroutines.Dispatchers | |
| import kotlinx.coroutines.SupervisorJob | |
| import kotlinx.coroutines.flow.MutableStateFlow | |
| import kotlinx.coroutines.flow.SharingStarted | |
| import kotlinx.coroutines.flow.StateFlow | |
| import kotlinx.coroutines.flow.map | |
| import kotlinx.coroutines.flow.stateIn | |
| class ParentViewModel { | |
| private val viewModelScope = CoroutineScope(Dispatchers.Main + SupervisorJob()) | |
| private val child1 = ChildViewModel1() | |
| private val child2 = ChildViewModel2() | |
| private val _selection = MutableStateFlow(Type.CHILD1) | |
| val selection: StateFlow<Type> = _selection | |
| val segmentedControl: List<SegmentedControlContent> = listOf( | |
| SegmentedControlContent( | |
| text = "child1", | |
| isSelected = selection.map { it == Type.CHILD1 }.stateIn(viewModelScope, SharingStarted.Lazily, true) | |
| ) { | |
| selectType(Type.CHILD1) | |
| }, | |
| SegmentedControlContent( | |
| text = "child2", | |
| isSelected = selection.map { it == Type.CHILD2 }.stateIn(viewModelScope, SharingStarted.Lazily, false) | |
| ) { | |
| selectType(Type.CHILD2) | |
| }, | |
| ) | |
| val shownViewModel: StateFlow<ChildViewModel> = selection.map { type -> | |
| when (type) { | |
| Type.CHILD1 -> child1 | |
| Type.CHILD2 -> child2 | |
| } | |
| }.stateIn(viewModelScope, SharingStarted.Lazily, child1) | |
| private fun selectType(type: Type) { | |
| _selection.value = type | |
| } | |
| interface ChildViewModel | |
| enum class Type { | |
| CHILD1, | |
| CHILD2, | |
| } | |
| data class SegmentedControlContent( | |
| val text: String, | |
| val isSelected: StateFlow<Boolean>, | |
| val action: () -> Unit, | |
| ) | |
| } | |
| class ChildViewModel1: ParentViewModel.ChildViewModel { | |
| val text = "Hi from ChildViewModel 1" | |
| } | |
| class ChildViewModel2: ParentViewModel.ChildViewModel { | |
| val text = "Hi from ChildViewModel 2" | |
| } | |
| class MainActivity : ComponentActivity() { | |
| override fun onCreate(savedInstanceState: Bundle?) { | |
| super.onCreate(savedInstanceState) | |
| enableEdgeToEdge( | |
| statusBarStyle = SystemBarStyle.light( | |
| scrim = Color(0xFF1C1C1E).toArgb(), | |
| darkScrim = Color(0xFF1C1C1E).toArgb() | |
| ), | |
| navigationBarStyle = SystemBarStyle.dark(Color(0xFF1C1C1E).toArgb()) | |
| ) | |
| setContent { | |
| AndroidDemoTheme { | |
| DemoApp(ParentViewModel()) | |
| } | |
| } | |
| } | |
| } | |
| @Composable | |
| fun DemoApp(viewModel: ParentViewModel) { | |
| val typeSelected by viewModel.selection.collectAsStateWithLifecycle() | |
| val shownViewModel by viewModel.shownViewModel.collectAsStateWithLifecycle() | |
| Box( | |
| modifier = Modifier | |
| .fillMaxSize() | |
| ) { | |
| Crossfade( | |
| targetState = shownViewModel, | |
| animationSpec = tween(durationMillis = 300), | |
| label = "typeCrossfade" | |
| ) { currentViewModel -> | |
| when (currentViewModel) { | |
| is ChildViewModel1 -> { | |
| Child1View(currentViewModel) | |
| } | |
| is ChildViewModel2 -> { | |
| Child2View(currentViewModel) | |
| } | |
| } | |
| } | |
| SegmentedControl( | |
| controls = viewModel.segmentedControl, | |
| selectedTab = typeSelected, | |
| modifier = Modifier | |
| .align(Alignment.TopCenter) | |
| .padding(top = 60.dp) | |
| ) | |
| } | |
| } | |
| @Composable | |
| private fun SegmentedControl( | |
| controls: List<ParentViewModel.SegmentedControlContent>, | |
| selectedTab: ParentViewModel.Type, | |
| modifier: Modifier = Modifier | |
| ) { | |
| val segmentedIndex = remember(selectedTab) { ParentViewModel.Type.entries.indexOf(selectedTab) } | |
| val density = LocalDensity.current | |
| var segmentWidth by remember { mutableStateOf(0.dp) } | |
| var segmentHeight by remember { mutableStateOf(0.dp) } | |
| val indicatorOffset by animateDpAsState( | |
| targetValue = segmentWidth * segmentedIndex, | |
| animationSpec = tween(durationMillis = 300), | |
| label = "indicatorOffset" | |
| ) | |
| Box( | |
| modifier = modifier | |
| .background( | |
| color = Color.Gray.copy(alpha = 0.85f), | |
| shape = RoundedCornerShape(20.dp) | |
| ) | |
| .padding(4.dp) | |
| ) { | |
| Box( | |
| modifier = Modifier | |
| // Using the lambda form of offset ensures we move the indicator efficiently without triggering recompositions on every frame. | |
| .offset { | |
| IntOffset(x = indicatorOffset.roundToPx(), y = 0) | |
| } | |
| .size(width = segmentWidth, height = segmentHeight) | |
| .background(Color.Black, RoundedCornerShape(16.dp)) | |
| ) | |
| Row( | |
| modifier = Modifier.width(IntrinsicSize.Max) | |
| ) { | |
| controls.forEachIndexed { index, content -> | |
| Box( | |
| modifier = Modifier | |
| .weight(1f) | |
| .clickable( | |
| interactionSource = null, | |
| indication = null, | |
| onClick = content.action | |
| ) | |
| .then( | |
| if (index == 0) { | |
| Modifier.onGloballyPositioned { | |
| with(density) { | |
| segmentWidth = it.size.width.toDp() | |
| segmentHeight = it.size.height.toDp() | |
| } | |
| } | |
| } else { | |
| Modifier | |
| } | |
| ), | |
| ) { | |
| val isSelected by content.isSelected.collectAsStateWithLifecycle() | |
| SegmentedControlItem( | |
| text = content.text, | |
| isSelected = isSelected, | |
| ) | |
| } | |
| } | |
| } | |
| } | |
| } | |
| @Composable | |
| private fun SegmentedControlItem( | |
| text: String, | |
| isSelected: Boolean, | |
| ) { | |
| val contentColor by animateColorAsState( | |
| targetValue = if (isSelected) { | |
| Color.White | |
| } else { | |
| Color.Black | |
| } | |
| ) | |
| Row( | |
| modifier = Modifier | |
| .clip(RoundedCornerShape(20.dp)) | |
| .padding(horizontal = 16.dp), | |
| verticalAlignment = Alignment.CenterVertically | |
| ) { | |
| Text( | |
| text = text, | |
| color = contentColor, | |
| ) | |
| } | |
| } | |
| @Composable | |
| private fun Child1View(viewModel: ChildViewModel1) { | |
| Box( | |
| modifier = Modifier | |
| .fillMaxSize() | |
| .background(Color.DarkGray), | |
| contentAlignment = Alignment.Center | |
| ) { | |
| Text(text = viewModel.text, color = Color.White) | |
| } | |
| } | |
| @Composable | |
| private fun Child2View(viewModel: ChildViewModel2) { | |
| Box( | |
| modifier = Modifier | |
| .fillMaxSize() | |
| .background(Color.Magenta), | |
| contentAlignment = Alignment.Center | |
| ) { | |
| Text(text = viewModel.text, color = Color.White) | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment