I have this design that I am trying to follow:
When the user selects a duration I am trying to use a white box (not sure if that is the best way) to highlight the selection. The box shouldn't move instantly, it should animate from the previous position to the new position.
This is what I have so far:
A couple of issues:
- The box overlays the text so the
5 mins
are not visible. - I can't get the animation to work.
I think that I have to use animateDpAsState. When the user selects the i.e. 30 min
I have to get the position of the Text's x
position (top left of the text composable) and then set the target value to that x
position. And then set the offset of the box to that animateDpState position.
This is my code so far:
var selectedIndex by remember { mutableIntStateOf(0) }
val items = listOf("5 min", "15 min", "30 min", "1 hour")
val animatedOffset by animateDpAsState(
targetValue = 0.dp, // This should be the selected text position
animationSpec = tween(durationMillis = 500),
)
Box(
modifier = Modifier
.fillMaxWidth()
.height(48.dp)
.background(
color = Color(0xff8138FF).copy(0.08f),
shape = RoundedCornerShape(12.dp),
)
.padding(4.dp),
) {
Row(
modifier = Modifier
.fillMaxSize()
.clip(RoundedCornerShape(16.dp)),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
items.forEachIndexed { index, item ->
Text(
fontWeight = FontWeight.W600,
fontSize = 16.sp,
text = item,
color = if (selectedIndex == index) Color.Black else Color.DarkGray,
modifier = Modifier
.clickable { selectedIndex = index }
.padding(horizontal = 16.dp),
)
}
}
Box(
modifier = Modifier
.width(100.dp) // Should be the same width as the text items
.height(48.dp)
.background(color = Color.White, shape = RoundedCornerShape(12.dp))
.offset(x = animatedOffset, y = 0.dp),
)
}
I have this design that I am trying to follow:
When the user selects a duration I am trying to use a white box (not sure if that is the best way) to highlight the selection. The box shouldn't move instantly, it should animate from the previous position to the new position.
This is what I have so far:
A couple of issues:
- The box overlays the text so the
5 mins
are not visible. - I can't get the animation to work.
I think that I have to use animateDpAsState. When the user selects the i.e. 30 min
I have to get the position of the Text's x
position (top left of the text composable) and then set the target value to that x
position. And then set the offset of the box to that animateDpState position.
This is my code so far:
var selectedIndex by remember { mutableIntStateOf(0) }
val items = listOf("5 min", "15 min", "30 min", "1 hour")
val animatedOffset by animateDpAsState(
targetValue = 0.dp, // This should be the selected text position
animationSpec = tween(durationMillis = 500),
)
Box(
modifier = Modifier
.fillMaxWidth()
.height(48.dp)
.background(
color = Color(0xff8138FF).copy(0.08f),
shape = RoundedCornerShape(12.dp),
)
.padding(4.dp),
) {
Row(
modifier = Modifier
.fillMaxSize()
.clip(RoundedCornerShape(16.dp)),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
items.forEachIndexed { index, item ->
Text(
fontWeight = FontWeight.W600,
fontSize = 16.sp,
text = item,
color = if (selectedIndex == index) Color.Black else Color.DarkGray,
modifier = Modifier
.clickable { selectedIndex = index }
.padding(horizontal = 16.dp),
)
}
}
Box(
modifier = Modifier
.width(100.dp) // Should be the same width as the text items
.height(48.dp)
.background(color = Color.White, shape = RoundedCornerShape(12.dp))
.offset(x = animatedOffset, y = 0.dp),
)
}
Share
Improve this question
edited 14 hours ago
tyg
15.1k4 gold badges35 silver badges48 bronze badges
asked 15 hours ago
ant2009ant2009
22.5k166 gold badges432 silver badges641 bronze badges
2 Answers
Reset to default 2I would suggest that you use a TabRow
to achieve this. It offers a nice animation out of the box and we can simply provide our own indicator
Composable.
Please have a look at the following code:
@Composable
fun AnimatedChipSelector() {
val localDensity = LocalDensity.current
val tabsList = listOf("5 min", "15 min", "30 min", "1 hour")
var selectedTabIndex by remember { mutableIntStateOf(0) }
val tabWidths = remember { mutableStateListOf(-1, -1, -1, -1) }
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Top
) {
TabRow(
modifier = Modifier.clip(RoundedCornerShape(12.dp)),
selectedTabIndex = selectedTabIndex,
containerColor = Color(0xff8138FF).copy(0.08f),
indicator = { tabPositions ->
if (tabWidths.isNotEmpty()) { // only show Indicator after measurements are finished
Column(
modifier = Modifier
.tabIndicatorOffset(tabPositions[selectedTabIndex])
.fillMaxHeight()
.requiredWidth( with(localDensity) { tabWidths[selectedTabIndex].toDp() } )
.padding(vertical = 8.dp)
.background(
color = Color.White,
shape = RoundedCornerShape(12.dp)
)
) {}
}
},
divider = {}
) {
tabsList.forEachIndexed { tabIndex, tabName ->
FilterChip(
modifier = Modifier
.wrapContentSize()
.zIndex(2f)
.onGloballyPositioned { layoutCoordinates ->
tabWidths[tabIndex] = layoutCoordinates.size.width
},
selected = false,
shape = RoundedCornerShape(12.dp),
border = null,
onClick = { selectedTabIndex = tabIndex },
label = {
Text(
text = tabName,
textAlign = TextAlign.Center,
color = if (selectedTabIndex == tabIndex) Color.Black else Color.DarkGray,
)
}
)
}
}
}
}
I used the onGloballyPositioned
Modifier to obtain the actual width
of each single Tab, otherwise the Indicator width
would be irrespectible of the actual Tab width.
Output:
You can do it by drawing indicator inside DrawScope with BlendMode to have fluid text color change from unselected color to selected color.
This answer explains how to do it with equal size tabs. If tabs have varying sizes all you can either use SubcomposeLayout as TabRow does or use TabRow directly to get tab size, and positions.
Inside Modifier.drawWithContent text changes to section that selected portion covers while second rectangle covers selected portion.
@Preview
@Composable
fun TabIndicatorOffsetSizeColorAnmiation() {
var selectedIndex by remember { mutableIntStateOf(0) }
val items = listOf("5 min", "15 min", "30 min", "1 hour")
val tabPositionList = remember {
mutableStateListOf<TabPosition>()
}
val indicatorOffset by animateDpAsState(
targetValue = if (tabPositionList.isEmpty()) 0.dp else tabPositionList[selectedIndex].left,
animationSpec = tween(durationMillis = 1000, easing = FastOutSlowInEasing),
label = "indicator offset"
)
val tabWidth = if (tabPositionList.isEmpty().not()) {
val currentTabWidth by animateDpAsState(
targetValue = tabPositionList.getOrNull(selectedIndex)?.width ?: 0.dp,
animationSpec = tween(durationMillis = 1000, easing = FastOutSlowInEasing),
label = "tab width"
)
currentTabWidth
} else {
null
}
Column(
modifier = Modifier.fillMaxSize().padding(16.dp)
) {
Box(
modifier = Modifier
.background(Color(0xff8138FF).copy(0.08f), RoundedCornerShape(16.dp))
.padding(horizontal = 4.dp)
) {
TabRow(
modifier = Modifier
.height(48.dp)
.drawWithContent {
val width = tabWidth?.toPx()
// This is for setting black tex while drawing on white background
val padding = 8.dp.toPx()
val xPos = indicatorOffset.toPx()
if (width != null) {
// This is for fluid transition from unselected color to selected color
drawRoundRect(
topLeft = Offset(x = xPos, padding / 2),
size = Size(width, size.height - padding),
color = Color.Black,
cornerRadius = CornerRadius(16.dp.toPx()),
)
}
drawWithLayer {
drawContent()
// This is white top rounded rectangle
if (width != null) {
drawRoundRect(
topLeft = Offset(x = xPos, padding / 2),
size = Size(width, size.height - padding),
color = Color.White,
cornerRadius = CornerRadius(16.dp.toPx()),
blendMode = BlendMode.SrcOut
)
}
}
},
containerColor = Color.Transparent,
selectedTabIndex = selectedIndex,
divider = {},
indicator = { tabPositions: List<TabPosition> ->
if (tabPositionList.isEmpty()) {
tabPositionList.addAll(tabPositions)
}
}
) {
items.forEachIndexed { index, item ->
Tab(
modifier = Modifier
.clip(RoundedCornerShape(16.dp))
.height(40.dp),
selected = false,
onClick = {
selectedIndex = index
}
)
{
Text(
text = item,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.primary,
)
}
}
}
}
}
}
private fun ContentDrawScope.drawWithLayer(block: ContentDrawScope.() -> Unit) {
with(drawContext.canvas.nativeCanvas) {
val checkPoint = saveLayer(null, null)
block()
restoreToCount(checkPoint)
}
}