Avoid pain in Jetpack Compose
This is an opinionated post about a solution to an issue I have come across while working with Jetpack Compose. As always, understand that nothing is going to be applicable 100% of the time and you should always use your best judgement and consult with your team before accepting new standards.
What is pain in Jetpack Compose?
Let’s look at an example of an unstructured Compose hierarchy. This is a very basic card with a child layout that has another child layout.
@Composable
fun UnstructuredCards() {
TopLevel(
groupName = "groupName",
position = 1,
label = "label",
id = 2,
title = "title",
key = 3,
isActive = true,
onClick = { println("click") }
)
}
@Composable
fun TopLevel(
groupName: String,
position: Int,
label: String,
id: Int,
title: String,
key: Int,
isActive: Boolean,
onClick: () -> Unit
) {
Card(modifier = Modifier.padding(top = 16.dp)) {
Surface(color = Color.Blue,) {
Column(modifier = Modifier.padding(16.dp),) {
Text(
color = Color.White,
text = "#$position: $groupName"
)
MiddleLevel(
label = label,
id = id,
title = title,
key = key,
isActive = isActive,
textColor = textColor,
onClick = onClick
)
}
}
}
}
@Composable
fun MiddleLevel(
label: String,
id: Int,
title: String,
key: Int,
isActive: Boolean,
onClick: () -> Unit
) {
Card {
Surface(color = Color.Red,) {
Column(modifier = Modifier.padding(16.dp),) {
Text(
color = Color.White,
text = "#$id: $label"
)
BottomLevel(
title = title,
key = key,
isActive = isActive,
onClick = onClick
)
}
}
}
}
@Composable
fun BottomLevel(
title: String,
key: Int,
isActive: Boolean,
onClick: () -> Unit
) {
Card {
Surface(color = Color.Green) {
if (isActive && textColor != null) {
Text(
modifier = Modifier
.padding(16.dp)
.clickable(onClick = onClick),
text = "#$key: $title",
)
}
}
}
}
There isn’t anything inherently wrong with this structure, at least at a glance. It runs, it looks right, the code is clean enough if not verbose, formatting looks good… So what’s the issue?
The issue is that unfortunately, software is usually not final when written. Usually someone comes along and coerces developers one way or another to make changes here and there. What if we need to add a nullable text color to BottomLevel?
Easy enough, update BottomLevel arguments to include textColor: Color? = null
. Then update MiddleLevel arguments to include textColor: Color? = null
and pass it down to BottomLevel. Then update TopLevel arguments to include textColor: Color? = null
and in UnstructuredCards pass a value for the color to TopLevel.
You run the code and the color didn’t change. What? How? You spend a non-negligible amount of time looking over the code only to realize you forgot to pass textColor
from TopLevel to MiddleLevel. A simple mistake and easy to rectify, no big deal right? Wrong.
It is a big deal when you eventually have cases like this all over a code base and you find it painful to refactor any of it. It is not uncommon to see two or more nested UI components and the deeper the hierarchy the more critical this problem becomes.
How to avoid pain in Jetpack Compose
I propose a different way of looking at Compose. And it’s not so much revolutionary as it is actually quite simple. Take a look at the following example of a structured compose hierarchy.
@Composable
fun StructuredCards() {
val bottomLevel = BottomLevel(
title = "title",
key = "3",
isActive = true,
onClick = { println("click") }
)
val middleLevel = MiddleLevel(
label = "label",
id = 2,
bottomLevel = bottomLevel
)
val topLevel = TopLevel(
groupName = "groupName",
position = 1,
middleLevel = middleLevel
)
topLevelData.Compose()
}
data class TopLevel(val groupName: String, val position: Int, val middleLevel: MiddleLevel) {
@Composable
fun Compose() {
Card(modifier = Modifier.padding(top = 16.dp)) {
Surface(color = Color.Blue) {
Column(modifier = Modifier.padding(16.dp),) {
Text(
color = Color.White,
text = "#${position}: $groupName"
)
middleLevel.Compose()
}
}
}
}
}
data class MiddleLevel(val label: String, val id: Int, val bottomLevel: BottomLevel) {
@Composable
fun Compose() {
Card {
Surface(color = Color.Red,) {
Column(modifier = Modifier.padding(16.dp),) {
Text(
color = Color.White,
text = "#${id}: $label"
)
bottomLevel.Compose()
}
}
}
}
}
data class BottomLevel(val title: String, val key: String, val isActive: Boolean, val onClick: () -> Unit) {
@Composable
fun Compose() {
Card {
Surface(color = Color.Green,) {
if (isActive && textColor != null) {
Text(
modifier = Modifier
.padding(16.dp)
.clickable(onClick = onClick),
text = "#${key}: $title"
)
}
}
}
}
}
Here’s what changed
- Create a data class for each composable.
- Move
Compose
function into each data class - Pass child data classes as arguments in their parent’s data class
- Create each data class ahead of time and structure accordingly.
I’m not going to count the lines but it’s less code going this route because it is less code duplication. There is a little more boilerplate but it is easily offset by the benefits. Each composable has its own tunnel essentially from the very top so there is less room for error.
The benefit really manifests when looking at maintainability. Adding or removing things is more straightforward, take the previous text color example. Here you add it as a parameter on BottomLevel then add it to StructuredCards and pass it to the BottomLevel constructor. That’s it.
There will absolutely be cases where this approach would need to be modified to meet your needs but applying this pattern by default will give you a solid foundation to build on. This is a look at how to avoid a very specific issue that can arise and catch you off-guard down the road if you aren’t careful.
Thank you for reading, I look forward to digging deeper into Compose and will continue to share my thoughts.