Blast Off: Managing Hundreds of UI Updates for an Emoji Cannon | by Piotr Prus | Jul, 2023

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:

Emoji cannon, sending random emoji on each button click

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.

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.

modifier = Modifier
x = offsetXAnimatable.value.dp,
y = offsetYAnimatable.value.dp)
Text(text = "\uD83D\uDE00", fontSize = 40.sp)

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>() 

modifier = Modifier.align(Alignment.BottomCenter),
onClick =
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:

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.

modifier = Modifier
x = offsetXAnimatable.value.dp.roundToPx(),
y = offsetYAnimatable.value.dp.roundToPx()

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:

TL;DR: If you use @Stable class, primitives, or Strings, the recomposition may be skipped if applicable.

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.

Memory footage from Android Profiler

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.

. . .
awaitAll(offsetX, offsetY, rotation, opacity)
opacityAnimatable.animateTo(0f, animationSpec = tween(2000))

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 ->
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

Emoji cannon, sending random emoji on each button click

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 ->
emoji = pair.first,
path = EmojiUtils.getRandomViewerPath(),
modifier = Modifier.align(Alignment.BottomStart),
onAnimationFinished =
// Inform VM that this emoji was displayed


The final result from the Tilt app:

Tilt scheduled room with viewer’s emojis