What is Jetpack Compose?

XML made you invalidate the UI by hand. Compose recomposes only the nodes whose data changed — and that one difference reorders how you build a screen.

5 min read

Also published on Medium — read it there if you prefer, or to comment and highlight.

androidcomposekotlinfundamentals

Day 2 — What is Jetpack Compose?

Day 2 of 100. What the toolkit actually changes.

When we talk about declarative languages we usually talk about React Native or SwiftUI. On Android we used imperative XML. XML was not performant when we re-rendered UI: data changed, the UI was complex, the user clicked, or we synced network data and now a sub-component needs to be visible — so we had to invalidate the UI, which is a costly action.

Now we have Jetpack Compose. It gives us recomposition, which only updates the tree nodes whose data is different. With XML we thought about adding the UI first; if something changed in our data source, we applied the logic afterwards, and released.

Imperative: invalidate by hand. Declarative: the data is the input, recomposition is the output

You can imagine how coupled everything was. After adopting Jetpack Compose we focus on the data source, which is the main part of the application.

Jetpack Compose is a declarative language. It lets us write UI components that depend on data — the UI state is entirely up to the data, and as the data changes the UI updates.

Compose gives us a lot of features that make development faster and more testable:

  • A tree-structured node implementation that lets you think in 3D
  • Data dependency, which gives fixed UI states
  • A Compose test API that makes UI tests easy
  • Lifecycle awareness
  • Compose previews

Thinking in 1D, 2D and 3D

Before implementing any design, do a short analysis so you write an efficient composable.

Look through the design properly and analyse each component like LEGO. If I asked you to split the design and break it into small pieces, what would they be?

First: how many UI components are there, in 1D? For a post card:

  • Placeholder image → Image
  • Name → Text
  • Time → Text
  • Post image → Image

Second: what is their orientation, in 2D? We have the components; now look at how they sit on the flat plane. Going from the top, can we fit everything in a Column, and how many nodes does that make?

Column {
  Node 1 — placeholder image + name + time
  Node 2 — post image
}

Node 1 breaks down further:

Row {
  Image
  Column {
    Text
    Text
  }
}

Third: what is behind it, in 3D? We have the design on 2D paper; now think about the background of the components. It can differ between Node 1 and Node 2, but here the parent Column has the same white background. Put the 2D components on a solid white background and it matches the design:

Column(
    modifier = Modifier.background(color = Color.White)
) {
    Row {
        Image
        Column {
            Text
            Text
        }
    }
    Image
}

A lot of people think that wrapping the above in a Box and setting the background achieves the design. That is wrong. Column, Row and Box are empty sheets: whatever size you give them and whatever colour you set becomes the background, and anything you put on top does not inherit that colour. It is the same as putting a pen on a table — the pen is a separate object.

We are also missing the border. So decide who owns what: the background and the border both belong to the Column.

Column(
    modifier = Modifier
        .background(color = Color.White)
        .border(width = 1.dp, color = Grey, shape = RoundedCornerShape(0.dp))
) {
    Row {
        Image
        Column {
            Text
            Text
        }
    }
    Image
}

Just by thinking in one, two and three dimensions you can build any complex UI with a minimal tree hierarchy.

Data dependency gives you fixed UI states

Compose is a declarative language, which lets it depend on data. Whatever data source you have, the UI revolves around it: as the data changes, the UI state changes with it.

val showPostImage by rememberSaveable { mutableStateOf(true) }

Column(
    modifier = Modifier
        .background(color = Color.White)
        .border(width = 1.dp, color = Grey, shape = RoundedCornerShape(0.dp))
) {
    Row {
        Image
        Column {
            Text
            Text
        }
    }
    if (showPostImage) {
        Image
    }
}

If we later change showPostImage to false — on a click event, or from wherever the post data comes from downstream — the image becomes visible or not accordingly, and when the data changes only the nodes that depend on it are recomposed.

That is the power of it: you sync data from different sources and your UI renders accordingly.

The test API

This is the feature I like most. The test API makes it easy to pick a node, act on it and assert against it.

You can access nodes three ways:

  • Using a tag
  • Using text
  • Getting all nodes, filtering, and asserting

I usually use text when I have a details page or unique content on screen, but when the same text appears more than once, use a tag.

@Composable
fun Post() {
    Column(
        modifier = Modifier
            .testTag("Background")
            .background(color = Color.White)
            .border(width = 1.dp, color = Grey, shape = RoundedCornerShape(0.dp))
    ) {
        Row {
            Image
            Column {
                Text(modifier = Modifier.testTag("Name"))
                Text(modifier = Modifier.testTag("Time"))
            }
        }
        if (showPostImage) {
            Image
        }
    }
}

Then the test:

class Test {

    @get:Rule
    val composeTestRule = createComposeRule()

    @Test
    fun myTest() {
        composeTestRule.setContent {
            MyAppTheme {
                Post()
            }
        }

        composeTestRule.onNodeWithTag("Name").assertIsDisplayed()
        composeTestRule.onNodeWithTag("Time").assertIsDisplayed()
    }
}

That is all it takes. The official testing documentation goes further, and we will come back to writing better test suites later in the series.

Lifecycle awareness

With fragments and activities we always had to make sure flows, coroutines and LiveData were subscribed in the right lifecycle so they survived configuration changes, the fragment back stack and so on.

In Compose we collect flow data bound to the ViewModel's lifecycle:

@Composable
fun Post(postViewModel: PostViewModel = hiltViewModel()) {

    val postData by postViewModel
        .postData
        .collectAsStateWithLifecycle()

    // ...
}

Previews

I know the pain of building XML, running the application, seeing everything work, and fighting build times. Running the app on different screen sizes and fixing the UI accordingly is a lot of work — worth it, but only once you know the pain.

Compose has @Preview. Annotating a function with it generates a preview of that composable at build time, beside your code.

@Preview
@Composable
fun PostPreview() {
    Post()
}

@Preview takes a lot of parameters, which lets you generate previews at different screen sizes too. Now you don't have to wonder how the UI will look on a real device.

That is the basis of Jetpack Compose.


Day 2 of a 100-day series on Jetpack Compose, working through the official documentation in order.