Since its public announcement during Google I/O 2019, Jetpack Compose has come a long way. It is not only the UI framework of choice for new Android apps, but may also be used for macOS-, iOS-, Windows-, Linux-, and Web apps, thanks to Kotlin Multiplatform and Compose Multiplatform. While Jetpack Compose certainly drew inspiration from its elder brother, Flutter, and other declarative UI frameworks, it perfectly integrates with Kotlin and functional programming. Which, I firmly believe, is why Jetpack Compose is such a beautiful framework. It is easy to grasp and easy to use.
Well, most of the time. Still, while writing my Compose book, I was puzzled now and then. Sometimes the parameters of some function were hard to understand, or a building block seemed to make no sense. Reasons for my confusion were:
- Bugs (fortunately, not often)
- Lack of, or unclear, documentation
- Unexpected behavior
The first two aren't puzzlers. A bug is something that needs to be fixed. Period. Unclear documentation should be made clearer. Missing documentation should be added. But what about unexpected behavior? If something works as described in the documentation, or as showcased in an example, and we still don't get why, we clearly lack context, or are missing some facts. That is the perfect puzzler. We scratch our head and think What?! Wait a minute. In this series of articles, I will share my favorite Jetpack Compose Puzzlers with you. But before we dive in, allow me to credit Joshua Bloch and Neal Gafter, who published a book called Java Puzzlers almost 20 years ago. That book inspired me to give a talk called Android Puzzlers back in 2015, and it inspired me to start this series.
How can Unit
help building trees?
The perceived majority of things we deal with when writing a Compose user interface are functions. Sure, we also use classes and objects, but the UI tree (the manifestation of the user interface, if you will) is defined – declared – by nesting functions. Here's how:
@Composable
fun HelloCompose() {
Box(
modifier = Modifier
.size(64.dp)
.background(color = Color.Green),
contentAlignment = Alignment.Center
) {
Text(
modifier = Modifier
.background(color = Color.Black)
.padding(all = 4.dp),
color = Color.White,
text = "Hello"
)
}
}
A function named HelloCompose()
invokes another one, Box()
, which in turn calls Text()
. Let's appreciate this: instead of instantiating UI elements and adding them to an object tree (yes, that's what happens when we inflate our XML layout files), we just nest function calls. But what happens when the invoked function returns? To understand what I am up to, let's look at the definitions of Box()
and Text()
.
@Composable
inline fun Box(
modifier: Modifier = Modifier,
contentAlignment: Alignment = Alignment.TopStart,
propagateMinConstraints: Boolean = false,
content: @Composable BoxScope.() -> Unit
)
fun Text(
text: String,
modifier: Modifier = Modifier,
color: Color = Color.Unspecified,
fontSize: TextUnit = TextUnit.Unspecified,
fontStyle: FontStyle? = null,
fontWeight: FontWeight? = null,
fontFamily: FontFamily? = null,
letterSpacing: TextUnit = TextUnit.Unspecified,
textDecoration: TextDecoration? = null,
textAlign: TextAlign? = null,
lineHeight: TextUnit = TextUnit.Unspecified,
overflow: TextOverflow = TextOverflow.Clip,
softWrap: Boolean = true,
maxLines: Int = Int.MAX_VALUE,
minLines: Int = 1,
onTextLayout: ((TextLayoutResult) -> Unit)? = null,
style: TextStyle = LocalTextStyle.current
)
Both don't specify a return (result) type. When no explicit return type is given, the default one, Unit
, is assumed. To test this, you can wrap the Box()
inside println()
. HelloCompose()
will still work as expected, and kotlin.Unit
will be printed. Now, with such a general result we surely can't build a component tree. So, here's the puzzler: while invoking Box()
and Text()
makes corresponding UI elements appear on screen, returning from the functions apparently does not make their visual counterparts disappear. What makes them stay?
To answer that questions, let's find out what Box()
and Text()
do. To see their source code, just click on the name of the composable while pressing the Cmd (macOS) or Control (Windows, Linux) key. Box()
immediately invokes androidx.compose.ui.layout.Layout()
. Text()
arrives there, too, but visits androidx.compose.foundation.text.BasicText()
first. It turns out, that Layout()
is the final destination for a lot of composables. So, let's have a quick look:
Well, that looks somewhat scary, right? Don't worry, I just said final destination for a reason. At some point you may want to invoke Layout()
from your code, but you likely won't need to use ReusableComposeNode()
. To quote from the docs, this composable emits
a recyclable node into the composition of type T. Nodes emitted inside of content will become children of the emitted node.
The key to solving our first puzzler is the beautifully short word emit. Jetpack Compose maintains an internal data structure which keeps representations of composable functions that have been invoked while declaring the user interface. Even though a composable has returned, its representation is still available in that structure, until Compose can prove that it is no longer needed.
So, nesting composable functions indeed creates a UI tree. Unlike with imperative UI toolkits (among many others, the Android View
system) we just don't need to manipulate it. And that is a great improvement. If you want to learn more about what is going on under the hood, I highly recommend Jetpack Compose internals by Jorge Castillo.
Let's turn to another puzzler.
So clickable {}
doesn't always click?
Many imperative UI frameworks are based on class hierarchies. For example, android.widget.Switch
, contains methods and properties to define both visual appearance and behavior of a special button type (a switch). Some of these are unique to Switch
(for example setSwitchTextAppearance()
). Others are inherited from ancestors (for example, setOnCheckedChangeListener()
is defined in CompoundButton
, the parent class). If we want to use a Switch
in our UI, we configure it by setting properties and registering listeners (to do something in response to a user interaction). To change the behavior and visual appearance of all instances, we need to create a derived class. What is not easily possible with such a class based approach is to alter only parts of parent behavior and visuals, as this would require either multiple inheritance or mixin-like concepts.
That is why declarative UI frameworks often follow a concept called composition over inheritance. Visual appearance and behavior is achieved by combining small reusable bits and pieces. In Jetpack Compose, this can be:
- composable functions
- function arguments
- modifiers
The first one is obvious. We build our user interface by nesting lots of single-purpose composables. For example, Text()
does nothing but show some text. It doesn't provide means to get the text either from resources or from a String
. If we want to get the text from resources, we use stringResource()
to obtain it.
What about the second one, function arguments? Instead of keeping state (that's what the properties of imperative UI elements do), composable functions should try to remain stateless: ideally, everything a composable needs to render is passed as arguments. Behavior, too, should not be hard-coded, but passed to the composable as a lambda. As with every rule, there are valid exceptions. To learn more, I recommend having a look at State and Jetpack Compose.
Modifiers truly are one of the super powers of Jetpack Compose. As their name implies, they are used to alter both visual appearance and behavior of a composable function. Here's some code:
@Composable
@Preview
fun MadModifier() {
var toggle1 by remember { mutableStateOf(false) }
var toggle2 by remember { mutableStateOf(false) }
val color = { isTrue: Boolean ->
if (isTrue) Color.White else Color.Black }
Row(
modifier = Modifier.size(100.dp)
) {
Box(
modifier = Modifier
.weight(0.5F)
.fillMaxHeight()
.background(color = color(toggle1))
.clickable { toggle1 = !toggle1 }
.padding(all = 8.dp)
.background(color = color(!toggle1))
)
Box(
modifier = Modifier
.weight(0.5F)
.fillMaxHeight()
.background(color = color(toggle2))
.padding(all = 8.dp)
.background(color = color(!toggle2))
.clickable { toggle2 = !toggle2 }
)
}
}
MadModifier()
puts two black and white boxes in a row. When you click on either one, the colors are swapped. Although the two Box()
versions looks almost identical, they behave subtly different. Take a look.
Here's the puzzler: why does clicking in the outer areas of the left box trigger a color swap, but not in the right one?
If you compare the two implementations, you'll spot that, besides using different variables, the second version uses the clickable {}
modifier later; to be more precisely, it's the last element in the modifier chain.
We can read the modifier chain like a recipe:
- Set the width to half of the available space
- Use the whole available height
- Fill the background with some color
- Create a padding facing to the center of the area
- Draw the background again, using some other color
- Make the remaining space clickable
Now, please try this with the first version.
In imperative UI frameworks, we can change properties of components in any order we want. That's because they are all part of one object. If the order matters, that usually indicates an unwanted side effect. In Jetpack Compose, however, the order of the modifiers does matter, and that's a good thing. If it didn't, the result of our code would become unpredictable.
Let's look under the hood.
fillMaxSize()
may well be one of the most used modifiers. It's an extension function of Modifier
; it doesn't do much besides invoking then()
, passing either FillWholeMaxSize
or FillElement.size(fraction)
. Both, in the end, are Modifier.Element
instances. As a nice exercise, you could look at the code to learn about the stops along the way. Element
is an interface that extends Modifier
.
As you can see, its four methods implement the parent declarations. But what about then()
?
then()
concatenates two modifiers, creating a chain.
Wrap-up
In this first part of Jetpack Compose puzzlers, I introduced you to what might be called the mothers of all Jetpack Compose puzzlers (as an homage to The Mother of All Demos). Once you have grasped the essence of Jetpack Compose, both are perfectly clear. For Compose newbies, they, however, may well cause some head scratching.
I sincerely hope you enjoyed reading this. What are your favorite Compose puzzlers? Which ones would you like me to cover? Please share your thoughts in the comments.
Top comments (0)