Thinking in Compose

Chris Arriola
Android Developers
Published in
7 min readSep 15, 2022

--

This post of our Compose Basics series goes into detail on what it means to think in Compose. Because Jetpack Compose is a declarative UI framework, as a developer you describe what your UI should display, instead of telling it how to display it.

Make sure to leave a comment with your questions here, on YouTube, or using #MADCompose on Twitter. We will answer your questions in our live Q&A session on October 13.

You can also watch this article as a MAD Skills video:

With Views, you describe step-by-step how to change your UI to get from the current screen to the screen you want.

For example, imagine you want to update the background color of a button when it’s clicked. With Views, you would first load the initial UI from XML. Then when the user clicks the button, you would find the button and call setColor on it. This is repeated for any user interactions and properties, until you get to the desired screen state.

With Compose, you no longer have to tinker with individual UI components until you get the screen you want (which can be error prone, more on this later). Instead you just declare what you want up front, and when the screen needs to change, you redeclare the whole what you want again.

As a bonus, you no longer write UIs in XML. UI can be entirely described in Kotlin taking full advantage of Kotlin constructs.

Constructing UI by describing what, not how, is a key difference between Compose and Views. It’s what makes Compose much more intuitive to work with. In this article, we’ll cover the difference between the two, and how you can shift your thinking to build apps with Compose.

Building a screen with Views

To understand how to think about building apps in Compose, let’s first look at how we might build a screen in Views.

Below is a screen taken from Jetsurvey, one of the Compose sample apps available on Github. The screen shows a single choice question that the user has to answer. Once they make a selection, they can then proceed to the next question.

To simplify things, let’s look at how we might build a single component in this screen — a single answer in the survey.

A single answer is composed of an image, some text, and a radio button all arranged horizontally. In Views, these elements would be defined in XML like so:

<!-- survey_answer.xml --><LinearLayout android:orientation="horizontal" >  <ImageView android:id="@+id/answer_image" ... />  <TextView android:id="@+id/answer_text" ... />  <RadioButton android:id="@+id/answer_radio_button" ... /></LinearLayout>

To populate the Views with UI state, we would then have to obtain reference to each View in Kotlin or Java in our Fragment or Activity using findViewById. After obtaining references, we would then mutate each View by calling setter functions like setImage and setText to display the desired UI state¹. In other words in this step, we are updating our initial state to the UI state we want by individually modifying each View or describing how we should change the state. That’s where the how comes from we discussed earlier.

If a user selects an answer (i.e., a state change happens), that View should visually appear selected. In our example, when a user selects an answer to the question, we also want the ‘Next’ button to be enabled. So, if we want other Views to update as a side-effect of another View being selected, we would have to set a listener to that View and explicitly mutate other affected Views.

However, having to manually update Views when state changes is error prone. It’s possible that you forget to update a View with its dependent state, and it’s also easy to create illegal states when multiple updates conflict in unexpected ways.

For example, what if your app goes through a configuration change, like a screen rotation, and in the process, you might correctly remember the selection from the user, but you might forget to re-enable the ‘Next’ button.

Synchronizing state changes throughout the lifespan of an app is a recurring challenge when working with Views. This problem also increases in complexity as the number of views and dependent states grows in your app. It’s a solvable problem, but it is a common source of bugs.

Building a screen with Compose

Let’s see how we can think about building the same component in Compose.

In Compose, the UI can be constructed in a similar fashion: in a container arranged horizontally with an image, text, and a radio button.

However, instead of writing it in XML, you define your elements directly in Kotlin.

// SurveyAnswer.kt@Composable
fun SurveyAnswer(answer: Answer) {
Row {
Image(answer.image)
Text(answer.text)
RadioButton(false, onClick = { /* … */ })
}
}

With Compose, you no longer have to jump between XML and Kotlin as the UI is already declared in Kotlin.

In Compose, UI elements are functions, and not objects. This means that you can’t find a reference to them and call methods to mutate them later.

Instead, UI elements are entirely controlled by the state, or arguments, that you pass. Here we are just describing what our UI should look like. No calls to findViewById, setImage, or setText. Our UI is described succinctly in the functions we are calling.

To display the UI state that we want, we are passing the answer’s properties to the Image function, to the Text function, and for now, false is passed to the RadioButton function which shows it as unselected.

A very important distinction here from Views is that tapping this answer will not show the item as selected. This is because we are always providing false to the RadioButton meaning that it will stay unselected regardless of user interaction (don’t worry, we will fix this soon).

Unlike in Views, the RadioButton doesn’t hold its own state that automatically changes due to a user event, but rather, the RadioButton state is controlled by the values that are provided into it.

This is what we mean by ‘what not how’. We are declaring what our UI should look like by providing the necessary state to our UI functions. If any element in our UI changes, we call the entire thing again, just passing in a new state vs. the View system where we manually tinkered with individual parts until we got the new state we wanted.

State and events in Compose

If State controls the UI, how do we go about updating State to update the UI? In Compose, that is accomplished through events. When a user interacts with a UI element, the UI emits an event, such as onClick, and the event handler can then decide if the UI’s state should be changed.

If UI state changes, the functions, or UI elements, that depend on that state will be re-executed. This process of regenerating the UI when state changes is called recomposition. The process of converting state into UI, and state changes causing UI to regenerate, is at the core of how Compose works as a UI framework.

Recomposing a UI element

In the SurveyAnswer composable above, the RadioButton remains unselected even when interacting with it. Let’s update our implementation so that it does toggle its selection state when tapped.

First, let’s define a boolean variable called selected and pass that into the RadioButton function argument. Also, in the onClick event handler, let’s change the value of the selected variable to be the opposite of its current value. This way, when it is clicked, it toggles the selection state. With this change, we should now see the RadioButton being toggled when clicking on it.

// SurveyAnswer.kt@Composable
fun SurveyAnswer(answer: Answer) {
Row {
/* ... */
var selected: Boolean = // ...
RadioButton(selected, onClick = {
selected = !selected
})
}
}

Note that in a production app, the state of the RadioButton should come from the Answer object rather than SurveyAnswer holding this information. This is done purely for educational purposes to showcase how RadioButton state works.

Additionally, notice that I left out the implementation of the selected variable because for this to work, we have to use a special State object which we’ll cover in the next article.

Summary

To summarize, to think in Compose we:

  • Declare what we want our UI to contain, but we don’t tell it step-by-step how to do it
  • Use Kotlin functions to represent our UI elements
  • Pass in State to control UI
  • Use events to update State which in turn updates our UI

This was a really high-level overview of how to think in Compose, but there’s so much more to learn.

For instance: how do these Kotlin functions work? What does State look like? What are the different components provided in Compose? We’ll answer all these questions in the upcoming episodes.

If you’d like to jump ahead, you can check out the following resources:

Got any questions? Leave a comment below or use the #MADCompose hashtag on Twitter and we will address your questions in our upcoming live Q&A for the series on October 13. Stay tuned!

¹ The data binding library is also an option for writing declarative code with Views and can help avoid the state synchronization issues mentioned in the article. Compose, however, avoids needing to work with XML entirely.

--

--