Managing the state can be a challenge. Managing the state with hundreds of updates and constant recomposition of floating emojis is a challenge indeed. In this blog post, I’ll share how to build an emoji cannon that floods your screen with UI elements. We’ll look at how to update the state without freezing your UI. All with Jetpack Compose 🧑🏼🎨
Building a cannon 💥
Our goal here is to build a simple UI, starting with a button and an empty screen. Every time the user clicks the button we should fire an emoji in the air(screen) and clean it after a while with a fadeOut()
exit animation. Here is the final result:
Let’s look at the parts of the effect one by one:
- the animation starts at the bottom of the screen, in the middle
val configuration = LocalConfiguration.current val startPoint = IntOffset( x = configuration.screenWidthDp / 2, y = (configuration.screenHeightDp * 0.9f).toInt() )
- each emoji has a different end position, restricted by a Y-coordinate (0-20% of the screen height) and an X-coordinate(0–100% of the screen width)
val width = LocalConfiguration.current.screenWidthDp val height = LocalConfiguration.current.screenHeightDp val targetX = Random.nextInt(0, width) val targetY = Random.nextInt(0, (height * 0.2).toInt())
- random rotation is applied in the range of -90 deg, 90 deg (3)
val rotation = Random.nextInt(-90, 90).toFloat()
- emojis fade out slowly after they reach their target position
val opacityAnimatable = remember { Animatable(0f) } val offsetXAnimatable = remember { Animatable(startPoint.x, Int.VectorConverter) } val offsetYAnimatable = remember { Animatable(startPoint.y, Int.VectorConverter) } val rotationAnimatable = remember { Animatable(0f) }
For each variable, I have created separate Animatable values, with specific initials. In LaunchedEffect
, all the animations are called to animate to the target values specified by Random
functions.
LaunchedEffect(Unit) { val opacity = async { opacityAnimatable.animateTo(1f, animationSpec = tween(500)) } val offsetY = async { offsetYAnimatable.animateTo(item.offsetY, animationSpec = tween(1000)) } val offsetX = async { offsetXAnimatable.animateTo(item.offsetX, animationSpec = tween(1000)) } val rotation = async { rotationAnimatable.animateTo(1f, animationSpec = tween(1000)) } awaitAll(offsetX, offsetY, rotation, opacity) opacityAnimatable.animateTo(0f, animationSpec = tween(2000)) }
The awaitAll
is used here to make sure all animations are finished and our Emoji is now in the final position. After that, we can start the slow fade out by calling: opacityAnimatable.animateTo
.
All the variables are applied to a simple Box
and the emoji is a Text
composable with a Unicode value for an emoji.
Box( modifier = Modifier .offset( x = offsetXAnimatable.value.dp, y = offsetYAnimatable.value.dp) .rotate(rotationAnimatable.value.times(rotation)) .alpha(opacityAnimatable.value) ) { Text(text = "\uD83D\uDE00", fontSize = 40.sp) }
Managing the recompositions
Single-type events were always tricky in Android views. Nowadays with Jetpack Compose things get even harder when everything on-screen needs to be a reflection of a state. How to build a state of something that is temporary and its visibility is controlled by the duration of animations?
To start, let’s try to display multiple items just using a list. To have all parameters in one place I introduced the data class MyEmoji
data class MyEmoji( val rotation: Float, val offsetX: Int, val offsetY: Int, val unicode: String = emojiCodes.random() )
Every time a user clicks the button we will put a new item into the list.
val emojis = remember { mutableStateListOf<MyEmoji>() } Button( modifier = Modifier.align(Alignment.BottomCenter), onClick = { emojis.add( MyEmoji( rotation = Random.nextInt(-90, 90).toFloat(), offsetX = Random.nextInt(0, width), offsetY = Random.nextInt(0, (height * 0.2).toInt()) ) ) }) { Text(text = "FIRE!!") } // Show emoji on screen emojis.forEach { emoji -> SingleEmojiContainer(item = emoji) }
Run this code on an emulator and you will see that everything works. Our job ends here. Or does it? Let’s double-check the recomposition count using the Layout Inspector.
Hitting the button just once results in 174 recompositions. That is not right. Let’s try to fix that by applying some changes based on this article: https://medium.com/androiddevelopers/jetpack-compose-debugging-recomposition-bfcf4a6f8d37
I can change the offset
it to its lambda version, but rotation
and alpha
do not have an equivalent. Luckily graphicsLayer {}
comes to the rescue and just like that we have all the animation parameters in lambdas.
Box( modifier = Modifier .offset { IntOffset( x = offsetXAnimatable.value.dp.roundToPx(), y = offsetYAnimatable.value.dp.roundToPx() ) } .graphicsLayer { rotationZ = rotationAnimatable.value.times(item.rotation) alpha = opacityAnimatable.value } ) { Text(text = item.unicode, fontSize = 40.sp) }
Check the Layout Inspector now 👇
Applying these changes we manage to get rid of those crazy recompositions. However, there are some leftovers. In the above screenshot, you can see there are 5 emojis on the screen. These are not visible to the user as their alpha is 0f, but they are still there. Sitting quietly and skipping compositions. If you wonder why they skip recomposition, you can find the answer here: https://developer.android.com/jetpack/compose/lifecycle#skipping.
TL;DR: If you use @Stable class, primitives, or Strings, the recomposition may be skipped if applicable.
Remove from list
When you have just a few elements floating on the screen that are invisible, you would not care too much. Adding a new element to the list and forgetting about it, even if it does not feel right, will work. However, having thousands of these elements might cause some issues, such us:
- Skipped 728 frames! The application may be doing too much work on its main thread.
- Davey! duration=12559ms; Flags=0, FrameTimelineVsyncId=11941972,
- Background concurrent copying GC freed 504936(13MB) AllocSpace objects, 13(10MB) LOS objects, 21% free, 86MB/110MB, paused 1.212s,11us total 12.149s
All the above means we are doing too much on the main thread and our app is skipping frames, freezing, and pausing.
The memory footage from the profiler doesn’t look good either. The emojis are not removed and memory is not freed.
It is clear that we have some issues and we need to handle this list better. Let’s remove the item from the list when fadeOut()
ends.
LaunchedEffect(Unit) { . . . awaitAll(offsetX, offsetY, rotation, opacity) opacityAnimatable.animateTo(0f, animationSpec = tween(2000)) onAnimationFinished() } SingleEmojiContainer(item = emoji, onAnimationFinished = { emojis.remove(emoji) })
Here is the recording with this change:
That is unfortunate. What is happening? The list of emojis is edited during the animation, so all the elements are recomposed. It switches the emojis every time we call remove
on the snapshotList
.
To fix that we need to wrap the SingleEmojiContainer
with the utility function key{}
. This prevents more than one execution during composition. The key{}
function needs a local unique key that will be compared to the previous invocation and stop unwanted recomposition.
Whenever the button is clicked, a new emoji object is created and we need to ensure it has a unique key. For that purpose, I will use random UUID: id = UUID.randomUUID().toString()
. Here is the updated MyEmoji
data class:
data class MyEmoji( val id: String, val rotation: Float, val offsetX: Int, val offsetY: Int, val unicode: String = emojiCodes.random() )
And updated code for displaying the emojis:
emojis.forEach { emoji -> key(emoji.id) { SingleEmojiContainer(item = emoji, onAnimationFinished = { emojis.remove(emoji) }) } }
Here is the layout inspector, profiler data, and resulting video.
The SingleEmojiContainer
does not recompose at all. The FireButton
skips recomposition as many times as it animates, since we have a ripple effect on it.
The memory management is now correct. After the animation ends, the memory is freed
Updating from ViewModel — production example
At Tilt, we display each emoji sent by a viewer on the screen. They appear on both sides of the screen, with added transparency, random path, and delays, so as not to interfere with the streaming experience, but to show the activity of other participants.
To solve this use case, we moved the list management into viewModel.
- Firstly, store the set of pairs in ViewModel with an emoji symbol and unique ID:
Set<Pair<String, String>>
. This set represents the emojis that are currently visible on the screen, but it does not know anything about the progress of animation, rotation, alpha, etc.
private val _viewersReactions = MutableStateFlow<Set<Pair<String, String>>>(setOf()) val viewersReactions: StateFlow<Set<Pair<String, String>>> get() = _viewersReactions // Called from view when emoji is displayed, to delete it from the VM list fun updateViewersReactions(pair: Pair<String, String>) = viewModelScope.launch { _viewersReactions.update { oldSet -> oldSet - pair } }
Then, in the view, we create an emoji state object that has all the info about startPoint, endPoint, rotation, etc. Every time the emoji ends the animation, the high-order function gets called to inform VM that this element is no longer visible. We pass the unique ID to make sure the proper element from the set will be removed.
viewersReactions.forEach { pair -> key(pair.second) { RoomViewerEmojiReaction( emoji = pair.first, path = EmojiUtils.getRandomViewerPath(), modifier = Modifier.align(Alignment.BottomStart), onAnimationFinished = { // Inform VM that this emoji was displayed viewerEmojiFired(pair) } ) } }
The final result from the Tilt app:
Epilogue
For those of you that just scrolled to the bottom, here are the key takeaways:
- use lambda versions of modifiers that change the layout properties like alpha, rotation, position, scale, etc. You will avoid unnecessary recompositions
- use the
key{}
utility function inComposable
to avoid extra recompositions - remove items from the list if they are no longer visible to free up memory
For those of you that read this blog post, thank you. It was not a quicky. If you have any comments or questions, do not hesitate to reach out using Medium or Twitter.
Big thanks to Matt Callery 👏 for the initial version of the code, path randomizer, and proofreading of this blog post.
Happy composing 🎹 👨🏼💻
This article was previously published on proandroiddev.com