Jetpack Compose Concepts Every Developer Should Know
What is Compose?
Jetpack Compose is Android’s new declarative UI Framework. Android Developers have long been accustomed to writing UI in xml with Stateful Views that are updated by stepping through the View Hierarchy. With Jetpack Compose, UI is written in a Stateless manner through the use of Kotlin Functions.
Composable functions are annotated with the @Composable
annotation. Composable functions must be annotated with this annotation which informs the compiler that this function adds UI to the View Hierarchy. While Composable functions can call other standard functions, Composables themselves can only be called from other Composables.
If you have not done so yet, I would highly encourage you to check out the learning path provided for Compose as it provides essential details and examples you can use to jump start your Compose Experience.
Unidirectional Data Flow
Compose is built off the standard of a Unidirectional Data Flow and it is expected that this paradigm is adhered to for proper implementation of Compose. Unlike the legacy Android UI system, Composables should be relatively Stateless — meaning their display state should be driven by arguments passed into the Composable function itself.
In its most basic sense, this means that events that either originate from the UI (button clicks, text entry, etc) or elsewhere (API Calls, Callbacks, etc) are processed by a handler that then in turn updates the UI State that is passed into Composable Functions. Since Composables are Stateless, the provided UI State will be used to build the UI.
In the flow above, the UI layer would be your Composable. Events originating from this layer, such as button clicks are passed to the Event Handler, such as a ViewModel
. The ViewModel will provide the UI with State via LiveData
/StateFlow
. As the State is changed, updates are pushed to your Composables which are then recomposed using the newly updated State.
The code below demonstrates the above unidirectional flow — collecting state from the ViewModel
, sending Events
to the ViewModel
, and the State
being updated by the ViewModel
.
MVI_ComposeTheme {
Surface(color = MaterialTheme.colors.background) {
val state = viewModel.viewState.collectAsState().value
Button(onClick = { viewModel.processEvent()}) {
Text(state.message)
}
}
}
Composition and Recomposition
Composition is the process in which your Composable functions are executed and the UI is created for the user. Recomposition is the process of updating the UI as a result of a State or Data Change that a Composable is using for its display. During recomposition, Compose is able to understand which data each Composable uses and only updates the UI components that have changed. The rest of the Composables are skipped.
Composition/Recomposition should not be equated to a LifeCycle.
- Composable functions may be recomposed as often as every frame (i.e. animation)
- Composable functions may be called in any order
- Composable functions may be executed in parallel
This means that you should never include logic that executes when a Composable function is executed — sometimes referred to as Side-Effects.
Compose_Theme {
MainScreen()
// DO NOT DO THIS
viewModel.makeAPICall()}
Stateful Composables & Good-Bye Saved Instance State!
While our goal is for our Composables to be largely Stateless, at times we need portions of them to be Stateful — for example to remember a scroll state, share a variable between Composables, etc. Since we know that recomposition can happen as often as every frame, it would not be ideal for the user to lose their scroll position every time recomposition would take place.
We can do this by creating and remembering a variable within our Composable:
val myInt = remember{ Random(10).nextInt() }
In the above example, the randomly generated integer will be remembered between compositions without recalculation. If this integer was not wrapped with remember, it would be recalculated on ever recomposition.
Taking this one step further, we can also remember this data between Configuration Changes too!
val myInt = rememberSaveable{ Random(10).nextInt() }
Sometimes we need to have a remembered variable that when updated by one Composable causes the recomposition of another Composable. In the example below, the button click increments the click counter, this counter is used within the Text
Composable for display. Thus, when the Button
is clicked, we want the Text
to be displayed with the updated count.
To complete this, we will use the remember function provided by Compose as we did above, but the Integer
will be wrapped with a MutableState
object. The MutableState
class is a single value holder whose reads and writes are observed by Compose and will cause recomposition of affected Composables.
Column{
// create state for buttonCount and a function to update it -
// setButtonCount val (buttonCount, setButtonCount) =
rememberSaveable { mutableStateOf(0) } Button(onClick = {
setButtonCount(buttonCount + 1)
}) {
Text(text = "Press Me!")
} // recomposes whenever button is pressed
Text(text = "Button Pressed $buttonCount")
}
Slot APIs
Compose introduces the concept of Slot APIs
. This allows Composables to be highly customizable without the Composable Functions providing infinite implementations for the various customizations you may apply. Since every use case and implementation may be different, Slot APIs
provide empty slots within the composable where your customized UI can live.
For example, many Buttons
provide more than just text inside of them. Some display loaders, some display icons on the left, some display icons on the right. Slots allow you to provide your own Composable that would provide these various customizations.
Other Composables, such as Scaffolds
, are built entirely of Slots. Think of these Composables as an outline for what your UI would look like with reserved space for various UI components — such as Toolbar
, BottomNav
, Drawer
, Screen Content, etc
Modifiers
Modifiers
could be compared to xml Attributes
that you traditionally use to style your UI, however, Modifiers
are much easier to use and have a few more tricks. Modifiers
allow you to decorate or change the default implementation of a Composable. You can change the appearance, add accessibility information, process UI event interactions, and more all through Modifiers
. Modifiers
are just Kotlin Objects so they can be added to for customized Modifiers as well.
Text(text = "Your Text", modifier = Modifier.padding(5.dp))
Modifiers
are powerful as they provide an option to give your Composable layers without nesting it within other Composables. For example, the UI below would not be able to be achieved within Android’s Legacy UI system without nesting multiple Views
.
However, with Modifiers
in Compose we can achieve this with just one Composable. This is because the order that modifiers are applied matters and by leveraging padding and coloring in various orders we can achieve many different UI combinations.
Text(text = "Fake Button",
modifier = Modifier.padding(5.dp)
.background(Color.Magenta)
.padding(5.dp)
.background(Color.Yellow))
Lazy Lists
LazyLists
is the Compose equivalent to RecyclerViews
. Let me tell you that I will not miss the days of writing RecyclerView
Adapters
, ViewHolders
, and all the other boilerplate code that goes along with them. The example below shows a LazyList
in Column form (vertical scroll) that shows different UI elements based on the modulus of the integer. With a RecyclerView
, this means we would need an Adapter
and at least two different ViewHolders
. With Compose, we just need our LazyColumn
Composable with an items function that dynamically adds our content.
val listSize = 100
LazyColumn {
items(listSize) {
if (it % 2 == 0) {
Text("I am even")
}else{
Text("I am odd")
}
}
}
That is it. This might be my favorite thing to come out of Compose.
Constraint Layout
Compose has its own version of the ConstraintLayout
that we have all come to know and love from the legacy UI system. ConstraintLayouts
were pushed strongly in the legacy UI system as they provided a way for you to build highly customized UI with dependencies on other Views
without a bunch of nested Views
. With Compose, nesting Views
is no longer the concern that it once was but at times we still need to leverage tools such as constraints
, barriers
, weights
, etc that the ConstraintLayout
has to offer.
ConstraintLayout {
// Creates references for the three Composables
val (button1, button2, text) = createRefs() Button(
// constraintAs is like setting the ID, required.
modifier = Modifier.constrainAs(button1) {
top.linkTo(parent.top, margin = 16.dp)
}
) {
Text("Button 1")
} // constraintAs is like setting the ID, required.
Text("Text", Modifier.constrainAs(text) {
top.linkTo(button1.bottom, margin = 16.dp)
centerAround(button1.end)
}) // Create barrier to set the right button to the right of the
button or the text, which ever is longer. val barrier = createEndBarrier(button1, text) Button(
modifier = Modifier.constrainAs(button2) {
top.linkTo(parent.top, margin = 16.dp)
start.linkTo(barrier)
}
) {
Text("Button 2")
}
}
Compose & Navigation
Navigation within Jetpack Compose can leverage many of the features that we are accustomed to with Jetpack Navigation. However, with Jetpack Compose we now have the ability to Navigate between screens with a single Activity
without the need to use Fragments
.
Simply create a NavHost
with your Screen
Composable nested within them.
val navController = rememberNavController()
NavHost(navController = navController, startDestination = "home") {
composable("home"){
val vm: HomeVM = viewModel()
HomeScreen(vm)
}
composable("settings"){
val vm: SettingsVM = viewModel()
SettingsScreen(vm)
}
composable("profile"){
val vm: ProfileVM = viewModel()
ProfileScreen(vm)
}
}
With the use of androidx.lifecycle:lifecycle-viewmodel-compose
you can create ViewModels
within your Composables:
val vm: MyVM = viewModel()
ViewModels
created within Composables will be retained until their scope (Activity/Fragment) are destroyed. This allows you to have ViewModels
for your NavHost
screens, much like you would do with Fragments
today. In my example, I have the ViewModels
created in the NavHost
, this is just an example so that you can see their use.
To apply your NavHost
to a BottomNav
or other Navigation
Component, leverage Scaffold
:
Scaffold(
bottomBar = {
BottomNavigation {
// your navigation composable here
}
},
) {
NavHost(
navController = navController,
startDestination = "home") {
// your screen composables
}
}
Wrap Up
The above concepts are just an introduction to what Compose has to offer. Compose is a complete shift in the way that Android Developers have always built UI, but it is a welcomed change that greatly simplifies many of the challenges that the legacy UI system has. If you have not done so yet, I would highly encourage you to check out the learning path provided for Compose. It is a fairly long path, but every bit of it is worth the time. Happy Composing!