最新消息:雨落星辰是一个专注网站SEO优化、网站SEO诊断、搜索引擎研究、网络营销推广、网站策划运营及站长类的自媒体原创博客

android - Compose animation to move a Box across a Row - Stack Overflow

programmeradmin3浏览0评论

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
Add a comment  | 

2 Answers 2

Reset to default 2

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:

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)
    }
}
发布评论

评论列表(0)

  1. 暂无评论