Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Compose animation to move a Box across a Row

I have this design that I am trying to follow:

enter image description here

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:

enter image description here

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),
    )
}
like image 216
ant2009 Avatar asked Oct 26 '25 13:10

ant2009


1 Answers

I 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:

Screen Recording

like image 108
BenjyTec Avatar answered Oct 29 '25 03:10

BenjyTec



Donate For Us

If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!