Blog Infos
Author
Published
Topics
, ,
Published

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

Job Offers

Job Offers

There are currently no vacancies.

OUR VIDEO RECOMMENDATION

,

Blast Off_ Managing Hundreds of UI Updates for Emoji Cannons

Managing a state might be a challenge. Managing the state with hundreds of updates and constant recomposition of floating emojis is a challenge indeed.
Watch Video

Blast Off_ Managing Hundreds of UI Updates for Emoji Cannons

Piotr Prus
Android developer

Blast Off_ Managing Hundreds of UI Updates for Emoji Cannons

Piotr Prus
Android developer

Blast Off_ Managing Hundreds of UI Updates for Emoji Cannons

Piotr Prus
Android developer

Jobs

YOU MAY BE INTERESTED IN

YOU MAY BE INTERESTED IN

blog

How to animate BottomSheet content using Jetpack Compose

Early this year I started a new pet project for listening to random radio…
READ MORE
blog
In this part of the series, we will plan our first screen in Jetpack…
READ MORE
blog
We’ll be selecting a time whenever a user presses a number key. Following points…
READ MORE
blog
The LookaheadScope (replaced by the previous LookaheadLayout) is a new experimental API in Jetpack…
READ MORE

Leave a Reply

Your email address will not be published. Required fields are marked *

Fill out this field
Fill out this field
Please enter a valid email address.

Menu