Scaling Button Animation in Jetpack Compose

12.4k Views Asked by At

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

5

There are 5 best solutions below

4
On BEST ANSWER

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)
        }
    }
}
0
On

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") }
1
On

Here is the ScalingButton, the onClick callback 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 ScalingButton(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) {
                while (true) {
                    awaitPointerEventScope {
                        awaitFirstDown(false)
                        selected = true
                        waitForUpOrCancellation()
                        selected = false
                    }
                }
            }
    ) {
        content()
    }
}

OR

Another approach without using an infinite loop:

@Composable
fun ScalingButton(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()
    }
}
3
On

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

0
On

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