Hey all, I’m trying to do a bounce animation to an...
# compose
m
Hey all, I’m trying to do a bounce animation to an image. This image is placed inside a box. The box is the Composable that handles the click and should start the animation on the image inside it. I created the
bounce
function, an extension of Modifier. The
bounce
function receives a
shouldBounce
boolean that indicates if I should start the animation. I wonder if there is a better way to do that and move the
shouldBounce
boolean into the Modifier. With my current implementation, I need to define a
shouldBounce
boolean in every place that wants to use my bounce function. Maybe there is a way to catch the click event on both Box and Modifier? I attached some code examples in the first reply
🧵 1
🔥 1
the box:
Copy code
@Composable
fun SaveButton(saved: Boolean = true, modifier: Modifier, onClickListener: () -> Unit ) {
    val interactionSource = remember { MutableInteractionSource() }
    var shouldBounce by remember { mutableStateOf(false) }

    Box(
        modifier = modifier
            .size(40.dp)
            .background(
                color = Color.Black,
                shape = CircleShape
            )
            .clickable(
                interactionSource = interactionSource,
                indication = null,
                onClick = {
                    onClickListener()
                    shouldBounce = true
                }
            )
    ) {
        SaveIcon(
            saved = saved,
            modifier = Modifier
                .padding(12.dp)
                .fillMaxSize(),
            shouldBounce = shouldBounce,
            onBounceAnimationEnd = {
                shouldBounce = false
            }
        )
    }
}
The image
Copy code
@Composable
fun SaveIcon(saved: Boolean = true, modifier: Modifier, shouldBounce: Boolean = false, onBounceAnimationEnd: () -> Unit) {
    val savedRes = R.drawable.save_with_border
    val saveRes = R.drawable.save_outline_with_border

    Image(
        painterResource(if (saved) savedRes else saveRes),
        contentDescription = "Save",
        modifier = modifier.bounce(
            bounce = shouldBounce,
            finishedListener = { onBounceAnimationEnd() }
        )
    )
}
bounce func:
Copy code
fun Modifier.bounce(
    shouldBounce: Boolean = false,
    finishedListener: ((Float) -> Unit)? = null
) = composed {
    val bounceFloat by animateFloatAsState(
        targetValue = if (shouldBounce) 1.25f else 1f,
        animationSpec = repeatable(
            iterations = 1, // iteration count
            animation = tween(durationMillis = 300),
            repeatMode = RepeatMode.Reverse
        ),
        finishedListener = finishedListener
    )

    graphicsLayer(
        scaleY = if (shouldBounce) bounceFloat else 1f,
        scaleX = if (shouldBounce) bounceFloat else 1f
    )
}
The way I use it inside the screen
Copy code
@Composable
fun SaveButtonScreen() {
    var saved by rememberSaveable{ mutableStateOf(false) }
    Column(
        modifier = Modifier
            .fillMaxSize()
            .padding(16.dp)) {
        SaveButton(
            modifier = Modifier,
            saved = saved,
            onClickListener = { saved = !saved})
    }

}
m
I didn’t quite understand. But, something like this helps you:
Copy code
fun Modifier.bounce(onClick: () -> Unit): Modifier {
    return this.then(
        Modifier.composed {
            val bounce = remember { mutableStateOf(false) }
            
            clickable(
                enabled = bounce.value,
                onClick = {
                    bounce.value = false,
                    onClick()
                }
            )
        }
    )
}
?
m
Hi @myanmarking thanks for you answer. I tried this, but because the Box handles the click event, the clickable inside the modifier is not being called.
c
Do you want the icon to bounce once? And do you want the animation to end when saved == true or when the bounce actually ends based on duration, or both?
And is the bouncing meant to be a progress indicator or a visual feedback response to the click event?
m
@Chris Sinco [G] it should be visual feedback for the click event and bounce only once. The animation should start when the user clicks on the box and end based on the duration.
c
Gotcha. So not sure if you still want a reusable bounce Modifier, but I think this sample code does simplify things, using the Animatable API. Here, every click event will restart the animation, since you have more granular control with Animatable.
Copy code
@Preview
@Composable
fun BounceButton(
    favorite: Boolean = false
) {
    val scope = rememberCoroutineScope()
    val animatedScale = remember { Animatable(1f) }
    Button(
        onClick = {
            scope.launch {
                animatedScale.animateTo(targetValue = 1.25f, animationSpec = tween(300))
                animatedScale.animateTo(targetValue = 1f, animationSpec = tween(300))
            }
        }
    ) {
        Icon(
            imageVector = if (favorite) Icons.Default.Favorite else Icons.Default.FavoriteBorder,
            contentDescription = null,
            modifier = Modifier.scale(animatedScale.value)
        )
    }
}
Here, you don’t have to track event listeners for animation or maintain state if the icon should bounce, which I think is simpler for your visual feedback use case.
m
Thanks @Chris Sinco [G] for your answer! The issue is that I might use this bouncing icon inside other containers. We have a few types of save buttons around the app. This is why I wanted to make the icon reusable
c
Right. I think you should be able to extract the code above into something more reusable. Maybe not as a Modifier, but as a Composable function with a content slot
So actually since the content of your Box is what you want to scale, moving the custom modifier up into the Box + adding the Animatable code I suggested works.
Copy code
fun Modifier.bounceContent(
    onClick: () -> Unit = {}
) = composed {
    val scope = rememberCoroutineScope()
    val interactionSource = remember { MutableInteractionSource() }
    val animatedScale = remember { Animatable(1f) }

    clickable(
        interactionSource = interactionSource,
        indication = null,
        onClick = {
            scope.launch {
                animatedScale.animateTo(targetValue = 1.25f, animationSpec = tween(300))
                animatedScale.animateTo(targetValue = 1f, animationSpec = tween(300))
            }
            onClick()
        }
    )
    .graphicsLayer(
        scaleY = animatedScale.value,
        scaleX = animatedScale.value
    )
}
Then you would use the custom modifier like this:
Copy code
@Preview
@Composable
fun BounceSample(
    favorite: Boolean = false
) {
    Column {
        Box(
            modifier = Modifier
                .size(40.dp)
                .background(Color.Black, CircleShape)
                .bounceContent { println("bounce heart") },
            contentAlignment = Alignment.Center
        ) {
            Icon(
                imageVector = if (favorite) Icons.Default.Favorite else Icons.Default.FavoriteBorder,
                contentDescription = null,
                tint = Color.White,
                modifier = Modifier
                    .padding(12.dp)
                    .fillMaxSize(),
            )
        }
        Column(
            modifier = Modifier
                .background(Color.Yellow, RoundedCornerShape(4.dp))
                .padding(horizontal = 8.dp, vertical = 4.dp)
                .bounceContent { println("bounce column")}
        ) {
            Text("This text will grow")
            Text("This text will grow")
        }
    }
}
Resulting in the below. The one limitation is this custom modifier won’t work on components like Surface and Button that take onClick as a parameter, since in their implementations they would override whatever clickable modifier you’re trying to apply
m
Thanks a lot @Chris Sinco [G]! it looks better now, and the code is much cleaner! I wonder how the bounce animation works on the child although we apply it on the container?
c
I believe it is because Modifiers actually affect the content of a Composable, that is they modify what the Composable emits. For example, the padding modifier applies padding around the content/children before drawing the children. In our case with this bounce modifier, it applies them all in order, with the last one specifying to transform the content on click.
I might not be explaining this super well, so maybe @Zach Klippenstein (he/him) [MOD] can add more insight
z
So you know how composables and layout nodes form trees, so do graphics layers (i.e. RenderNodes). When you add a graphics layer to an element (via the
graphicsLayer
modifier), that layer is used for all drawing and coordinate transforms for everything below it: any modifiers that come after it on the current element, and any elements that are a child of the modified one. You can put a graphics layer at the root of your app and any transformations applied to it will be applied to everything in your app.
👍 1
m
Thanks you so much for the explanation!