'Scaling Button Animation in Jetpack Compose

I want to build this awesome button animation pressed from the AirBnB App with Jetpack Compose 1

Unfortunately, the Animation/Transition API was changed recently and there's almost no documentation for it. Can someone help me get the right approach to implement this button press animation?

Edit Based on @Amirhosein answer I have developed a button that looks almost exactly like the Airbnb example

Code:

@Composable
fun AnimatedButton() {
    val boxHeight = animatedFloat(initVal = 50f)
    val relBoxWidth = animatedFloat(initVal = 1.0f)
    val fontSize = animatedFloat(initVal = 16f)

    fun animateDimensions() {
        boxHeight.animateTo(45f)
        relBoxWidth.animateTo(0.95f)
       // fontSize.animateTo(14f)
    }

    fun reverseAnimation() {
        boxHeight.animateTo(50f)
        relBoxWidth.animateTo(1.0f)
        //fontSize.animateTo(16f)
    }

        Box(
        modifier = Modifier
            .height(boxHeight.value.dp)
            .fillMaxWidth(fraction = relBoxWidth.value)

            .clip(RoundedCornerShape(8.dp))
            .background(Color.Black)
            .clickable { }
            .pressIndicatorGestureFilter(
                onStart = {
                    animateDimensions()
                },
                onStop = {
                    reverseAnimation()
                },
                onCancel = {
                    reverseAnimation()
                }
            ),
        contentAlignment = Alignment.Center
    ) {
        Text(text = "Explore Airbnb", fontSize = fontSize.value.sp, color = Color.White)
    }
}

Video:

2

Unfortunately, I cannot figure out how to animate the text correctly as It looks very bad currently



Solution 1:[1]

Are you looking for something like this?

@Composable
fun AnimatedButton() {
    val selected = remember { mutableStateOf(false) }
    val scale = animateFloatAsState(if (selected.value) 2f else 1f)

    Column(
        Modifier.fillMaxSize(), verticalArrangement = Arrangement.Center,
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Button(
            onClick = {  },
            modifier = Modifier
                .scale(scale.value)
                .height(40.dp)
                .width(200.dp)
                .pointerInteropFilter {
                    when (it.action) {
                        MotionEvent.ACTION_DOWN -> {
                            selected.value = true }

                        MotionEvent.ACTION_UP  -> {
                           selected.value = false }
                    }
                    true
                }
        ) {
            Text(text = "Explore Airbnb", fontSize = 15.sp, color = Color.White)
        }
    }
}

Solution 2:[2]

Use pressIndicatorGestureFilter to achieve this behavior.

Here is my workaround:

@Preview
@Composable
fun MyFancyButton() {
val boxHeight = animatedFloat(initVal = 60f)
val boxWidth = animatedFloat(initVal = 200f)
    Box(modifier = Modifier
        .height(boxHeight.value.dp)
        .width(boxWidth.value.dp)
        .clip(RoundedCornerShape(4.dp))
        .background(Color.Black)
        .clickable { }
        .pressIndicatorGestureFilter(
            onStart = {
                boxHeight.animateTo(55f)
                boxWidth.animateTo(180f)
            },
            onStop = {
                boxHeight.animateTo(60f)
                boxWidth.animateTo(200f)
            },
            onCancel = {
                boxHeight.animateTo(60f)
                boxWidth.animateTo(200f)
            }
        ), contentAlignment = Alignment.Center) {
           Text(text = "Utforska Airbnb", color = Color.White)
     }
}

The default jetpack compose Button consumes tap gestures in its onClick event and pressIndicatorGestureFilter doesn't receive taps. That's why I created this custom button

Solution 3:[3]

With 1.0.0 (tested with 1.0.0-beta08) you can use the Modifier.pointerInput to detect the tapGesture.
Define an enum:

enum class ComponentState { Pressed, Released }

Then:

var toState by remember { mutableStateOf(ComponentState.Released) }
val modifier = Modifier.pointerInput(Unit) {
    detectTapGestures(
        onPress = {
            toState = ComponentState.Pressed
            tryAwaitRelease()
            toState = ComponentState.Released
        }

    )
}
 // Defines a transition of `ComponentState`, and updates the transition when the provided [targetState] changes
val transition: Transition<ComponentState> = updateTransition(targetState = toState, label = "")

// Defines a float animation to scale x,y
val scalex: Float by transition.animateFloat(
    transitionSpec = { spring(stiffness = 50f) }, label = ""
) { state ->
    if (state == ComponentState.Pressed) 1.25f else 1f
}
val scaley: Float by transition.animateFloat(
    transitionSpec = { spring(stiffness = 50f) }, label = ""
) { state ->
    if (state == ComponentState.Pressed) 1.05f else 1f
}

Apply the modifier and use the Modifier.graphicsLayer to change also the text dimension.

Box(
    modifier
        .padding(16.dp)
        .width((100 * scalex).dp)
        .height((50 * scaley).dp)
        .background(Color.Black, shape = RoundedCornerShape(8.dp)),
    contentAlignment = Alignment.Center) {

        Text("BUTTON", color = Color.White,
            modifier = Modifier.graphicsLayer{
                scaleX = scalex;
                scaleY = scaley
            })

}

enter image description here

Solution 4:[4]

Here's the implementation I used in my project. Seems most concise to me.

val interactionSource = remember { MutableInteractionSource() }
val isPressed by interactionSource.collectIsPressedAsState()
val sizeScale by animateFloatAsState(if (isPressed) 0.5f else 1f)

Button(
    onClick = { },
    modifier = Modifier
        .wrapContentSize()
        .graphicsLayer(
            scaleX = sizeScale,
            scaleY = sizeScale
        ),
    interactionSource = interactionSource
) { Text(text = "Open the reward") }

Solution 5:[5]

I slightly modified @CodePoet's code so that onClick is fired when users click the button and state is reset when users move their finger out of the button area after pressing the button and not releasing it. I'm using Modifier.pointerInput function to detect user inputs:

@Composable
fun AnimatedButton(onClick: () -> Unit, content: @Composable RowScope.() -> Unit) {
    var selected by remember { mutableStateOf(false) }
    val scale by animateFloatAsState(if (selected) 0.7f else 1f)

    Button(
        onClick = onClick,
        modifier = Modifier
            .scale(scale)
            .pointerInput(Unit) {
                awaitPointerEventScope {
                    while (true) {
                        awaitFirstDown(false)
                        selected = true
                        waitForUpOrCancellation()
                        selected = false
                    }
                }
            }
    ) {
        content()
    }
}

OR

Another approach without using an infinite loop:

@Composable
fun AnimatedButton(onClick: () -> Unit, content: @Composable RowScope.() -> Unit) {
    var selected by remember { mutableStateOf(false) }
    val scale by animateFloatAsState(if (selected) 0.75f else 1f)

    Button(
        onClick = onClick,
        modifier = Modifier
            .scale(scale)
            .pointerInput(selected) {
                awaitPointerEventScope {
                    selected = if (selected) {
                        waitForUpOrCancellation()
                        false
                    } else {
                        awaitFirstDown(false)
                        true
                    }
                }
            }
    ) {
        content()
    }
}

Solution 6:[6]

If you want to animated button with different types of animation like scaling, rotating and many different kind of animation then you can use this library in jetpack compose. Check Here

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1 Code Poet
Solution 2 Amirhosein
Solution 3
Solution 4 Mieszko Koźma
Solution 5
Solution 6 Purvesh Dodiya