9

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

Gabriele Mariotti
  • 320,139
  • 94
  • 887
  • 841
Yannick
  • 4,833
  • 8
  • 38
  • 63

6 Answers6

16

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)
        }
    }
}
Code Poet
  • 6,222
  • 2
  • 29
  • 50
  • 2
    This works for me. Note I also had to include setting `selected.value = false` for `MotionEvent.ACTION_CANCEL`, so the animation would reset if I were to drag my finger off the button without lifting it up. Without it, the button would still think it's "selected" even though it is not. – trod Apr 18 '22 at 02:10
  • I take that back. While this solution works great for providing the animation, for some reason, adding a `.pointerInteropFilter` that returns `true` will prevent `onClick` from firing. If I could take back my upvote, I would. – trod Apr 18 '22 at 04:04
  • Following up again, I was able to adjust `.pointerInteropFilter` to the following, which seems to provide both animation and click handling: ``` .pointerInteropFilter { if (isEnabled) { when (it.action) { MotionEvent.ACTION_DOWN -> { isPressed = true } MotionEvent.ACTION_UP -> { isPressed = false onClick() } MotionEvent.ACTION_CANCEL -> { isPressed = false } } } else { isPressed = false } true }, ``` – trod Apr 18 '22 at 04:34
  • `onClick = { do something },` is not works after add `scale` effect. – Binh Ho Oct 24 '22 at 09:10
10

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") }
Mieszko Koźma
  • 460
  • 4
  • 10
4

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

Amirhosein
  • 1,048
  • 7
  • 19
  • Great approach! Do you know how we can animate the whole surface (including the font size) instead of only the width & height of the box? (See my updated question) – Yannick Feb 18 '21 at 19:11
  • Animating text font size seems pretty straightforward. What do you mean by *It looks very bad currently*? – Amirhosein Feb 20 '21 at 05:06
  • what is the pressIndicatorGestureFilter import? – Sergio Nov 24 '21 at 18:50
3

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

Gabriele Mariotti
  • 320,139
  • 94
  • 887
  • 841
0

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()
    }
}
Sergio
  • 27,326
  • 8
  • 128
  • 149
-2

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

Purvesh Dodiya
  • 594
  • 3
  • 17