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)
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.
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
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: