Skip to content

Instantly share code, notes, and snippets.

@sdetilly
Last active October 29, 2025 14:37
Show Gist options
  • Select an option

  • Save sdetilly/4de7e573ad46725c6b2f42867ec0fedb to your computer and use it in GitHub Desktop.

Select an option

Save sdetilly/4de7e573ad46725c6b2f42867ec0fedb to your computer and use it in GitHub Desktop.
SegmentedControl animation
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