Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

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

like image 452
Danfb__ Avatar asked Oct 24 '25 12:10

Danfb__


2 Answers

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

like image 87
Thracian Avatar answered Oct 26 '25 03:10

Thracian


If you want the left OutlinedButton to shrink in place, you can use animateFloatAsState to animate the width of the left OutlinedButton and then use weight on the right Button to fill all available width.

Please have a look at the following code:

@Composable
fun ShrinkingButtons() {
    val localDensity = LocalDensity.current
    var show by remember { mutableStateOf(true) }
    val leftButtonMaxWidth by remember { mutableFloatStateOf(128.dp.dpToPx(localDensity)) }
    val leftButtonWidth by animateFloatAsState(if (show) leftButtonMaxWidth else 0f)

    Row(
        modifier = Modifier.fillMaxWidth(),
        horizontalArrangement = Arrangement.spacedBy(8.dp)
    ) {
        OutlinedButton(
            modifier = Modifier.width(with(localDensity) { leftButtonWidth.toDp() }),
            onClick = {},
        ){
            Text(
                text = "Button 2",
                maxLines = 1,
                overflow = TextOverflow.Clip
            )
        }
        OutlinedButton(
            modifier = Modifier.weight(1f),
            onClick = {
                show = !show
            }
        ){
            Text("Button 1", modifier = Modifier.animateContentSize())
        }
    }
}

@Composable
fun Dp.dpToPx() = with(LocalDensity.current) { [email protected]() }

Output:

Screen Recording

Note that I made the right Button an OutlinedButton too for better visibility.

like image 42
BenjyTec Avatar answered Oct 26 '25 03:10

BenjyTec