Beware of this pitfall in Jetpack Compose!

Know it before you fall in it and waste time trying to get out of it 😉

The Android Developer
6 min readFeb 9, 2023

Introduction

Hello everyone, hope you’re doing well. In this short article, I will explain a pitfall that you might very well come across when using Jetpack compose. I will also explain how to circumvent it. It revolves around touch propagation, layouts and the surface composable.

Background

The standard built-in material layout composables

Jetpack compose offers three main built-in material layout composables — Row, Column and Box.

  • Row — Arranges its children in a row.
  • Column — Arranges its children in a column.
  • Box — Arranges its children on top of each other.

There are other layouts as well, such as the LazyRow and LazyColumn. The pitfall that we are gonna look is applicable to all built-in layouts and even custom composables. Since the standard built-in layout composables are usually used as building blocks for other composables, I’ve taken them as an example.

The Surface composable

The surface composable is used to display a “material surface” on the screen. It takes care of clipping, elevation, borders, setting the right background and content color, and blocking touch propagation behind the surface. Spoiler alert! The last one is relevant to the pitfall, so keep that in mind 😉. Here’s the documentation of the surface composable.

Documentation of the Surface composable

The pitfall

Let’s say that you’re building a clone of the Spotify app as a side-project and wanted to add a functionality wherein clicking the mini player will expand it to fill the entire screen and make it appear over the content of whatever the app is displaying. Implementing it might seem very straightforward isn’t it? We could just use a Box composable, place the app content before the mini player and voila! The code for that might look something like this.

@Composable
fun App() {
Box(modifier = Modifier.fillMaxSize()) {
AppContent(...)
.
.
ExpandableMiniPlayer(
modifier = Modifier.align(Alignment.BottomCenter)
.
.
)
}
}
Expandable mini-player aligned in a Box composable

Now, in the above gif, it seems that everything is normal and, it definitely works. But, you’ll notice a very strange behavior when you start tapping on the background of the full-screen music player when it’s displayed over a list of items that can be tapped. The items behind the player get the tap events, even if the player is drawn over the list of items! If they are a list of tracks, then, the track that gets selected starts to play, even though a player is drawn in front of it!

In the above gif, notice how I’m able to scroll the track list displayed behind, and change the currently playing track by tapping on the background of the full screen player, even though the full-screen player is overlaid on top of the list. Weird isn’t it?! Let me explain what’s going on.

Explanation

The reason this happens is because none of the layout composables use the Surface composable under the hood. They directly make use of the Layout composable. This means, they are essentially “transparent”, even if there is a background color applied. This means that all touch events go “through” them, and, that’s why the items behind the music player were responding to tap events, even though they were overlaid by the music player screen. To further drive home the point, let’s try out an example. In the code snippet below, I’ve placed a Box composable over a button. I’ve also added a background gradient to the Box composable that is placed on top of the button.

@Composable
fun Example() {
val brush = remember {
Brush.verticalGradient(
listOf(
Color.Red.copy(alpha = 0.5f),
Color.Transparent
)
)
}
Box(modifier = Modifier.fillMaxSize()) {
Button(
modifier = Modifier.align(Alignment.Center),
colors = ButtonDefaults.buttonColors(backgroundColor = Color.Black),
onClick = { Log.d("MainActivity", "Clicked!") },
content = { Text("Button") }
)
// box that will appear on top of the button
Box(
modifier = Modifier
.fillMaxSize()
.background(brush)
)
}
}
UI produced by the above code snippet

If you notice in the above gif, the button is clickable even though there is an entire composable placed on top of it with a gradient color applied to it. This clearly shows us that the composables that directly use the Layout composable under the hood, without a surface, are essentially “transparent” and allow all touch events to go through them. Let’s go back to our Spotify clone and fix the issue.

The fix

The issue lies in the ExpandableMiniPlayer composable. In specific it lies in the implementation of the FullScreenPlayer composable that the ExpandableMiniPlayer uses under the hood. The FullScreenPlayer composable directly uses the Column composable. Since the Column composable uses the Layout composable under the hood, it just lays its children in a column without a “solid” background even though a background color is applied.

@Composable
fun ExpandableMiniPlayer(...) {
AnimatedContent(...) { isFullScreenPlayerVisible ->
if (isFullScreenPlayerVisible) {
FullScreenPlayer(...)
} else {
Miniplayer(...)
}
}
}

// issue lies in this composable
private fun FullScreenPlayer() {
Column(
modifier = Modifier
.dynamicBackgroundColor(...) // custom modifier
.fillMaxSize()
.systemBarsPadding()
.padding(start = 16.dp, end = 16.dp)
) {...}
}

This means that when the composable is laid over another composable, it just propagates all touch events to the composable behind it since it doesn’t have a “solid” background as a barrier that doesn’t allow the touch event to propagate any further. Now that we understand why it happens, let’s fix it. The fix is pretty simple. Just surround the content of the FullScreenPlayer with a Surface composable. That’s it!

private fun FullScreenPlayer() {
// surround the content with the Surface composable
Surface{
Column(
modifier = Modifier
.dynamicBackgroundColor(...) // custom modifier
.fillMaxSize()
.systemBarsPadding()
.padding(start = 16.dp, end = 16.dp)
) { ... }
}
}

Since the surface acts as a “solid” background for the layout, the touch event doesn’t get propagated over to the composable below the FullScreenPlayer. It also metaphorically makes sense. The layout composable is only responsible for laying out its children. The surface composable provides a “Surface” / background for the layout. This also aligns with the documentation of the “Surface” composable, where it’s mentioned that it’s responsible for blocking touch events (refer to the screenshot of the documentation of the Surface composable above).

Conclusion

And, that wraps it up 🎉! Hopefully, you found this blog post helpful! As always, I would like to thank you for taking the time to read this article😊. I wish you the best of luck! Happy coding 👨‍💻! Go create some awesome Android apps 👨‍💻! Cheers!

If you really liked my article and want to support me, you can do so, by clicking this link. Thank you so much for being generous ❤️, it really motivates me to keep going, and it helps me to keep my articles free for anyone to read. If you don’t feel like supporting, that’s fine too! The fact that you took some time off your schedule to read my article means a lot to me. Thank you 🙂

--

--

The Android Developer

| A very passionate Android Developer 💚 | An extreme Kotlin fanatic 💜 | A huge fan of Jetpack Compose 💙| Focused on making quality blog posts 📝 |