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:
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.
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
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
)
}
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