Search code examples
androidandroid-jetpack-composecompose-multiplatformjetpack-compose-animation

How to animate button width changes smoothly when showing/hiding another button in Jetpack Compose?


I want to create an animation where there’s a button that occupies the full width of the row. When a second button appears (its width depends on its text), the width of the first button should decrease smoothly. Similarly, when the second button disappears, the width of the first button should increase smoothly.

I attempted to implement this effect using AnimatedVisibility. However, the animation isn’t working as expected. The width of the first button doesn’t gradually decrease when the second button appears; instead, it jumps abruptly without any animation. The same happens when hiding the second button—the width of the first button increases suddenly.

Here is the code I have tried:

var show by remember { mutableStateOf(false) }

Column(
    modifier = Modifier.fillMaxSize().padding(16.dp),
    horizontalAlignment = Alignment.CenterHorizontally,
    verticalArrangement = Arrangement.Center
) {

    Button(onClick = { show = !show}){
        Text(text = "Click")
    }

    Spacer(modifier = Modifier.padding(32.dp))

    Row(
        modifier = Modifier.fillMaxWidth(),
        horizontalArrangement = Arrangement.spacedBy(8.dp)
    ) {
        AnimatedVisibility(
            visible = show,
            exit = fadeOut() + slideOutHorizontally(),
            enter = fadeIn() + slideInHorizontally()
        ) {
            OutlinedButton(
                onClick = {}
            ){
                Text("Button 2")
            }
        }

        Button(
            modifier = Modifier.fillMaxWidth(),
            onClick = {}
        ){
            Text("Button 1", modifier = Modifier.animateContentSize())
        }
    }
}

What I expect is that the width of the first button should decrease smoothly as the second button appears. Similarly, it should increase smoothly when the second button disappears.

This is what my code does right now:

Animation with my code


Solution

  • If you wish to animate Button at the left without changing its size from zero to content size you need to use a Layout.

    Using Layout you can define size of second Button as size of parent and place first Button with offset out of parent and move first button while resizing second Button.

    enter image description here

    Measurement

    First measure Button at the left as big as its content

                val mobileButtonPlaceable =
                    measurables.first().measure(constraints.copy(minWidth = 0))
    

    Then measure second button based on a progress value between 0-1f

    val stationaryButtonPlaceable = measurables.last().measure(
        Constraints.fixedWidth((constraints.maxWidth - mobileButtonPlaceable.width * progress).toInt())
    )
    

    to change its size between full parent width to final size parent width - first button width

    Placement

    Place first button to left with negative offset as big as its width initially based on progress. As progress goes to 1f, it will move to x=0 position

    Second button would also move based on how many pixels first button enters parent.

    return layout(
        constraints.maxWidth, stationaryButtonPlaceable.height
    ) {
    
    val width = mobileButtonPlaceable.width
    val leftPadding = 16.dp.roundToPx()
    
    mobileButtonPlaceable.placeRelative(
        x = (-(width + leftPadding) * (1 - progress)).toInt(),
        y = 0
    )
    
    stationaryButtonPlaceable.placeRelative(
        x = ((width) * progress).toInt(),
        y = 0
    )
    }
    

    Full code

    @Preview
    @Composable
    fun ButtonAnimationTest() {
        var show by remember { mutableStateOf(false) }
    
        val progress by animateFloatAsState(
            if (show) 1f else 0f,
            animationSpec = tween(1000)
        )
    
        Column(
            modifier = Modifier.fillMaxSize().padding(16.dp),
            horizontalAlignment = Alignment.CenterHorizontally,
            verticalArrangement = Arrangement.Center
        ) {
    
            Button(onClick = { show = !show }) {
                Text(text = "Click")
            }
    
            Spacer(modifier = Modifier.padding(32.dp))
    
            ButtonAnimationLayout(
                modifier = Modifier.fillMaxWidth().border(2.dp, Color.Red),
                progress = progress
            ) {
                OutlinedButton(
                    modifier = Modifier.padding(end = 8.dp),
                    onClick = {}
                ) {
                    Text("Button 2")
                }
    
                Button(
                    modifier = Modifier.fillMaxWidth(),
                    onClick = {}
                ) {
                    Text("Button 1", modifier = Modifier.animateContentSize())
                }
            }
        }
    }
    
    @Composable
    fun ButtonAnimationLayout(
        modifier: Modifier,
        progress: Float,
        content: @Composable () -> Unit
    ) {
    
        val measurePolicy = remember(progress) {
    
            object : MeasurePolicy {
                override fun MeasureScope.measure(
                    measurables: List<Measurable>,
                    constraints: Constraints
                ): MeasureResult {
    
                    require(measurables.size == 2)
    
                    val mobileButtonPlaceable =
                        measurables.first().measure(constraints.copy(minWidth = 0))
    
                    val stationaryButtonPlaceable = measurables.last().measure(
                        Constraints.fixedWidth((constraints.maxWidth - mobileButtonPlaceable.width * progress).toInt())
                    )
    
                    return layout(
                        constraints.maxWidth, stationaryButtonPlaceable.height
                    ) {
    
                        val width = mobileButtonPlaceable.width
                        val leftPadding = 16.dp.roundToPx()
    
                        mobileButtonPlaceable.placeRelative(
                            x = (-(width + leftPadding) * (1 - progress)).toInt(),
                            y = 0
                        )
    
                        stationaryButtonPlaceable.placeRelative(
                            x = ((width) * progress).toInt(),
                            y = 0
                        )
                    }
                }
    
            }
        }
        Layout(
            modifier = modifier,
            measurePolicy = measurePolicy,
            content = content
        )
    }
    

    Setting fadeIn/out for first button

    Fade in/out are alpha changes under the hood. You can implement it with

    mobileButtonPlaceable.placeRelativeWithLayer(
        x = (-(width + leftPadding) * (1 - progress)).toInt(),
        y = 0,
        layerBlock = {
            alpha = progress
        }
    )
    

    Result

    enter image description here