With the release of the stable version of Jetpack Compose just around the corner, here are some things you need to know before switching from the imperative, XML-based style for building UI, to the new Android’s toolkit for building native UI declaratively.
First, let's get the basics out of the way
What does it mean to build UI declaratively rather than imperatively? Let's quickly break down the main difference between these two styles.
The imperative style focuses on how. You provide step-by-step instructions on how you would like to get to your desired result. This is how we were building UI in Android applications all this time. The simplified illustration of the imperative style would be:
-> Go to the kitchen.
-> Open the fridge.
-> Take eggs from the fridge.
-> Put eggs in a pan.
-> Wait 5 minutes.
-> Put eggs from the pan on a plate.
The declarative style, on the other hand, focuses on what. You describe what result you want to achieve. The above example would look something like this:
-> I want fried eggs.
The declarative approach does remove quite a lot of boilerplate, provided you have a buddy who knows how to cook. Luckily for us, in the Android UI building world, Jetpack Compose is that buddy.
Other examples of building UI declaratively in the mobile development are ReactNative, Flutter, and SwiftUI.
How to use Compose for building UI
To draw UI, Compose uses regular Kotlin functions annotated with @Composable
. Therefore, these functions are often referred to as composables.
Here are some key points regarding composable functions to keep in mind:
- Composable functions can only be called from within the scope of other composable functions.
- Composable functions don't return anything. They don't return any Views or Widgets but rather describe the UI.
- Composable functions can take in arguments and you can use regular Kotlin expressions within the composable function as you would within regular functions.
- Composable functions should behave the same way when called multiple times with the same arguments, without using or modifying global properties and variables.
- As opposed to the regular naming convention of functions in Kotlin, names of composable functions start with a capital letter.
With that out of the way, let's look at how to use Jetpack Compose in practice.
Let's say we need to build the following UI element:
Now, to achieve this result, we need to describe the following things in our composable function:
- We need a
Text
and aCheckbox
, arranged in aRow
, with someSpace
in between.
- The whole
Row
should be modified to have a grayRoundedBorder
and somePadding
to let the elements inside breathe.
- The elements inside should be
CenteredVertically
, for a more pleasant look.
Let's create our Composable and, without being too preoccupied with the syntax at this stage, see how our description looks in Kotlin:
@Composable
fun FollowTheBlog() {
Row(
modifier = Modifier
.border(
width = 1.dp,
color = Color.Gray,
shape = RoundedCornerShape(5.dp)
)
.padding(14.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text("Check to follow the blog for awesome content!")
Spacer(modifier = Modifier.width(8.dp))
Checkbox(
checked = false,
onCheckedChange = { checked ->
// some follow the blog action
}
)
}
}
This code should be quite intuitive and easy to read, even for those not fluent in Kotlin, which is a major step up from how we used to build our Android UI.
Now, if we run our composable, it will indeed produce the desired result in terms of visuals but, if we try to interact with it, we will notice some unexpected behavior:
The Single Source of Truth
The main premise of Jetpack Compose is to represent the current state of UI on the screen and, as opposed to when using views, clicking the checkbox emitted by Compose doesn't visually change it because we haven't changed the state of our UI.
Coming from the imperative style of building UI, this approach might seem unintuitive and redundant, but this is one of the key concepts in declarative UI.
The main benefit of this approach is that Compose makes it much easier to manage the state of UI in one place. This allows us to draw UI based on a single source of truth - the current state of UI - without needing to synchronize the state between multiple components, as was often the case when using an XML/View-based approach.
Understanding State
Since composable functions represent the state of UI, the only way to update UI is to run the composable function again but with different arguments representing the current state. In Compose, this is called Recomposition.
So if we look at our code above, we see that we have described our Checkbox
with an argument checked = false
, which is exactly what we got.
Considering this, we could fix our checkbox with the following steps:
- When describing our
Checkbox
, we will set the parameterchecked
to a variable.
- We will toggle that variable in the
onCheckedChange
callback.
- We need that variable to persist between the recompositions.
- We need to trigger the recomposition when the user clicks the checkbox.
The first two steps are quite straightforward. It could look something like this:
Checkbox(
checked = isChecked,
onCheckedChange = { checked ->
isChecked = checked
// some follow the blog action
}
)
The last two steps, however, might present some challenges for people new to Compose.
Let's break them down.
First, we need to persist the value of the isChecked
variable between recompositions or, in other words, multiple runs of the function. The first answer that comes to mind would be to declare it as a global variable outside the scope of the composable function.
However, those who paid attention earlier would notice that this would violate one of the key points:
- ...without using or modifying global properties and variables
Moreover, the state of a checkbox represents the local state - other components don't necessarily need to know or influence how the checkbox is rendered.
Therefore, a better solution would be to manage this state inside our composable function, but it takes us back to square one with our persistence problem.
Luckily for us, Compose has an elegant built-in solution for this problem, as well as the problem with triggering the recomposition.
Here is how this solution looks:
var isChecked by remember { mutableStateOf(false) }
This one line can be quite a brain bender for people who are new to Compose and/or are not used to more advanced Kotlin syntax, so let's break it down.
First, let's take a look at the mutableStateOf
function.
If you have read my previous blog post 7 Tips for Becoming a Better Developer, you know that I like to dive into the source code to understand the API of a framework better, so buckle up.
The signature of this function is as follows:
fun <T> mutableStateOf(
value: T,
policy: SnapshotMutationPolicy<T> = structuralEqualityPolicy()
): MutableState<T>
We can see that this function takes in a value
of generic type and returns an object implementing an interface MutableState<T> : State<T>
. The policy
parameter has a default value so let's not concern ourselves with it for the time being.
The value
represents the initial value of that state, and in our case, we pass false
for our checkbox to be unchecked.
If we go down the rabbit hole into the source code to find the actual implementation of our MutableState
object, we will find:
/**
* A single value holder whose reads and writes are observed by Compose.
* ...
*/
internal open class SnapshotMutableStateImpl<T>(
value: T,
override val policy: SnapshotMutationPolicy<T>
) : StateObject, SnapshotMutableState<T>
The most important part is the first line of the KDoc.
Without getting stuck on the implementation details, we can see that this state object acts as an observable.
In practice, it means that Compose will observe our state and when the value changes it will schedule a recomposition of any composable subscribed to it.
The composable subscribes to a state when it reads its value.
In other words - when setting the initial value, the composable will subscribe to changes in our state. As soon as we change the state inside the onCheckedChange
callback, it will trigger the recomposition.
Next is the remember { }
function.
/**
* Remember the value produced by [calculation].
* [calculation] will only be evaluated during the composition.
* Recomposition will always return the value produced by composition.
*/
@Composable
inline fun <T> remember(
calculation: @DisallowComposableCalls () -> T
): T
The implementation details of this function can seem quite tricky, especially for less experienced programmers, but once again, reading the KDoc is more than enough for understanding what the function does.
The key takeaway is as follows:
As opposed to regular Kotlin functions, composable functions have a memory which persists across recompositions and even across configuration changes. remember
function allows you to access that memory during recompositions. This means that by wrapping our state inside the remember
function, we allow Compose to access its value during the next recomposition.
This means that when we change the value inside the isChecked
state and trigger recomposition, the next time our function runs, it will remember our value.
Please note that remember
doesn't work for configuration changes. For that, we would use a similar function rememberSaveable
, which uses Bundle
under the hood.
Lastly, we have the by
keyword.
This is a feature of Kotlin called Delegated Properties. It allows manual implementation of the setter and the getter of a property.
In this case, it unwraps the value property inside the MutableState
object and allows us to access it as we would a regular property.
Without it:
var isChecked = remember { mutableStateOf(false) }
We would have to access the value
of our state directly:
Checkbox(
checked = isChecked.value,
onCheckedChange = { checked ->
isChecked.value = checked
// some follow the blog action
}
)
Note: As of writing this post, you have to manually add the following imports for the by
keyword to work correctly.
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
In summary, here is what we have achieved with that line:
- We have created a
isChecked
variable, which is a state.
- The Compose will subscribe to our state during the initial composition when setting the initial value. The value writes that happen in the
onCheckedChange
callback will trigger recomposition.
- During the recomposition, Compose will remember the value of the state from the initial composition and render UI correctly.
- We have used an advanced Kotlin feature to unwrap the value of our state so that we can use it as a regular property.
How it all comes together
This is just scratching the surface of the topic of state management in Compose. For those willing to learn more, I will leave a link to the official documentation.
For now, however, let's see how our code looks with all the fixes:
@Composable
fun FollowTheBlog() {
var isChecked by remember { mutableStateOf(false) }
Row(
modifier = Modifier
.border(
width = 1.dp,
color = Color.Gray,
shape = RoundedCornerShape(5.dp)
)
.padding(14.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text("Check to follow the blog for awesome content!")
Spacer(modifier = Modifier.width(8.dp))
Checkbox(
checked = isChecked,
onCheckedChange = { checked ->
isChecked = checked
// some follow the blog action
}
)
}
}
And with that, we have a working checkbox!
Switching to Jetpack Compose
Moving forward, Jetpack Compose will be a primary way to build UI on Android, and, sooner or later, the developers will have to embrace it.
To make things easier, here are some things to keep in mind when switching to Jetpack Compose:
You have to use Kotlin to utilize Jetpack Compose fully.
Compose is written in Kotlin, so its Java compatibility is limited, and there are no plans to support it in the future. Therefore, if you haven't already, this might be a good point to start learning Kotlin. The good news is that Kotlin is awesome, and for Java developers, it shouldn't take more than a couple of weeks to start developing in Kotlin comfortably.
Jetpack Compose is fully compatible with the old .XML
/View
-based way of building UI.
You can start migrating your project to Compose on a screen-by-screen basis, or even add Compose components to existing .XML
layouts or ViewGroups
. You can do it with a ComposeView
, which you can use as a regular View
. It has a setContent { }
function that allows you to add composables to existing UI.
composeView.setContent {
// you can use composables here,
// which will be added to the view
Text(text = "I am Compose inside this View!")
}
It also works great the other way around. You can use regular Views
or layouts inside the Composable scope. For that, you can use a composable function AndroidView
, which has a factory that provides a context
and allows you to inflate Views
or layouts.
setContent {
AndroidView(
factory = { context ->
// use this context to inflate views or layouts
TextView(context).apply {
text = "I am a View inside Compose!"
}
}
)
}
You can find more information on interoperability API in the official documentation.
Jetpack Compose can be easily integrated with common Android libraries and popular stream-based solutions
Compose comes with support for ViewModels, Navigation, Paging, as well as other Android libraries. It also provides an API to listen to LiveData
, Flow
or RxJava's Observable
and represent their values as State
.
This makes integrating Compose into an existing project much easier, especially if the project has an architecture with a unidirectional data flow.
As always, there is a lot of useful information about that in the documentation.
Some other things to keep in mind
To start using Jetpack Compose, you need at least the Arctic Fox version of Android Studio, which as of the time of writing is available in Beta.
Most people will probably start using Compose in an existing project. While adding Compose to an existing project shouldn't be too hard, depending on the state of your dependencies (pun not intended), it can be a bumpy ride. To make things easier, please refer to this guide and be sure to upgrade the Gradle plugin to the latest version.
At the time of this writing, the Compose Compiler requires Kotlin version
1.4.32
. If you are using1.5
features in your project, you might have to wait a bit or use the suppressKotlinVersionCompatibilityCheck flag, which might cause unexpected behavior.Compose allows you to preview your UI without building your app, which greatly speeds up development. You can also interact with the preview as you would on a normal device. However, depending on your version of Android Studio, this feature might be experimental and therefore hidden. To turn in on, go to
Preferences -> Experimental -> Enable interactive and animation preview tools
.
- For many of us who are using
ConstraintLayout
on a daily version, Compose has its own version.MotionLayout
is not supported just yet, but the Compose team is working on supporting it.
Conclusion
Jetpack Compose is a huge toolkit, and one can probably write a whole book about it. It makes writing beautiful UI much easier and offers quite a few APIs and features that allow us to build UI in ways that weren't possible with layouts and views.
Compose has already hit a release candidate version and will be available as a stable version in no time. As I have noted before, this will be a new standard for Android development. Therefore, now is as good a time as any to start learning it.
I am truly excited about this new chapter in Android development and the journey that lies ahead. Hopefully, for some people, this post will make starting this journey a little bit easier.
See you next time.
To starting new journeys,
Max.