Jetpack Compose Arrives on the Scene
Mykal Cuin | 05/27/2021
Announced at Google I/O 2018 Jetpack Compose continues a great Google tradition of revolutionary changes that turn Android on its head. Moving into an Alpha state in August 2020, a Beta, production ready state in February 2021, and a planned 1.0 version release in July 2021 Google is all in on supporting this new UI framework for the Android ecosystem. Compose is a new Declarative UI Toolkit that is replacing the previous separate XML and Activity file based view system. It can be a sudden and overwhelming shock for Android developers that are ingrained into the XML supported view system. This post is going to explore Compose with a deep dive into the basics of building a basic app by only using the toolkit in hopes of making Compose less of a beast when first looking into it. As this is just a top level overview deeper topics will not be covered here such as how coroutines fit in or making a custom layout that relies on measurements. These topics will be covered in future posts. A companion calculator app has been developed alongside to show off some basic Compose usages and differences to an XML based application. To peruse the app in its entirety and follow along with some of the code presented in this post the app can be found here.
Why Compose?
Google is trying to unify components and answer many complaints that developers have had with the XML layout system with the move to Compose. The most far reaching change is the move to combine the layout and activity files into one entity. While this isn’t Google’s first stab at this type of combination, Compose is the most complete. Their most recent try was using Data Binding to directly link XML declared UI object actions, such as a button’s onClick, to a function declared in the Activity. Data Binding still required separate XML and Activity files though. Compose is the opposite of Data Binding where the object declaration and onClick action are now a part of the Activity code, and the XML file has been completely removed from the equation. By condensing the UI and controller into one file UI components are easier to test, and when using a VMMV architecture there is now only one view file to deal with when pushing view events and updates. Combining these files also means that everything is now completely handled by Kotlin. By using Kotlin for creation, Composables are at their core just functions, and can be reused just like any other method to build a reusable UI element. As a final benefit Compose can be integrated quickly into any existing Android project. The toolkit is fully interoperable with the previous view system and can be dropped in at any time whether it’s a new screen just being built or an XML view system screen that needs a new component piece built.
Compose Deep Dive
Jetpack Compose is truly a revolutionary change from what Android developers have been using when designing and building their layouts. There is no more drag and drop of UI pieces into a mock screen view or view binding functions in Activities to call UI objects by their XML declared ids. Everything is now handled by declaring a Composable annotated Kotlin function and creating the design and action magic inside these Composables. Composable UI object declarations include their own list of optional parameters that are used to fill in pieces of the UI. For example a Text composable has an optional text parameter to fill the object with text on load. The most important of these optional parameters is Modifier. The new Modifier class is what applies size, padding, and most visual modifications to Composables in the vein of xml attributes like width, height, etc. Using our calculator example app this is what the number entry TextField Composable looks like.
val text = calculatorViewModel.entryLive.observeAsState("")
TextField(value = text.value, onValueChange = {}, textStyle = TextStyle(fontSize = 50.sp, textAlign = TextAlign.Center), maxLines = 1, readOnly = true, enabled = true, modifier = Modifier
.weight(2f)
.fillMaxHeight())
For now ignore the viewmodel call. It is only here to demonstrate how text is consumed by the field, and will be covered later. The important part demonstrated here is that the Modifier attribute tells the TextField that it should fill all the height that it is given by its parent layout, and that it should fill most of the width because it has the highest weight. MaxLines, readOnly, etc are all XML like, optional attributes that can be leveraged to get desired behavior from the TextField. Behavior such as preventing the keyboard from opening if the user tapped on the TextField because all entry is being done via the number buttons. Not all attributes are optional in a declaration. Value and onValueChange are required pieces of a TextField declaration. Value is leveraging a viewmodel that is holding on to the text value that has been entered by the user via the number buttons. With text entry being performed by button actions rather than the keyboard the onValueChange callback function is still required, but can be left blank. If this were a normal TextField declaration the callback would be used to assign keyboard entry to the text value.
onValueChange = { text.value = it }
Replacing Linear layouts are the Column and Row Composables. Column will stack objects on top of each other vertically and Row will stack them next to each other horizontally. Simpler layouts can be built using Column and Row just like a vertical or horizontal Linear layout stacks.
Column {
Row(modifier = Modifier
.fillMaxWidth()
.fillMaxHeight(0.25f)) {
TextField(value = text.value, onValueChange = {}, textStyle = TextStyle(fontSize = 50.sp, textAlign = TextAlign.Center), maxLines = 1, readOnly = true, enabled = true, modifier = Modifier
.weight(2f)
.fillMaxHeight())
Backspace()
}
}
Column tells the view system to set aside a piece of vertical screen real estate for these objects. Row tells the system to stack the TextField and Backspace button next to each other horizontally while using Modifier to fill the whole width of the screen and 25% of the height of the screen. While this setup works for simpler layouts that do not depend much on sizing relationships, complex layouts will need a different approach. For complex layouts ConstraintLayout has been transformed into its own Composable object. Compose Constraint setup is similar to the previous setup for creating or changing the constraints in code.
val buttonsSet = ConstraintSet {
val seven = createRefFor("7")
val eight = createRefFor("8")
val nine = createRefFor("9")
val divide = createRefFor("/")
constrain(seven) {
start.linkTo(parent.start)
top.linkTo(parent.top)
end.linkTo(eight.start)
bottom.linkTo(parent.bottom)
height = Dimension.fillToConstraints
width = Dimension.fillToConstraints
}
...
ConstraintLayout(buttonsSet, modifier = Modifier.layoutId("lineOne")) {
NumberButton(calculatorViewModel = calculatorViewModel, number = stringResource(id = R.string.seven))
NumberButton(calculatorViewModel = calculatorViewModel, number = stringResource(id = R.string.eight))
NumberButton(calculatorViewModel = calculatorViewModel, number = stringResource(id = R.string.nine))
NumberButton(calculatorViewModel = calculatorViewModel, number = stringResource(id = R.string.div))
}
Even though it is similar there are a couple notable changes in this setup. First up is putting a string layout id on the view where in the previous system when building from code the id was randomly generated. The improvement in this instance is it is much easier to match up these id strings in a human readable way, and track where they are connected. The second change tells views how they need to size. Wrap_content still exists as an option for sizing, match_parent has been replaced with fillMax, and the option to fill remaining space with 0dp has been completely renamed to fillToConstraints under the Dimension class. Changing to using only Kotlin also brought changes to how objects have their data updated. Compose lays out views and items without much references to each other, and is for the most part stateless. After a view has been composed and presented to the user the system relies on the developer keeping track of the state of the data in that object to update anything that needs to be updated. There is no more setText() on TextViews to update their text, or view.visibliy calls when determining the visibility of a view. These types of actions rely on the developer to keep track of that text or visibility and tell the system to rebuild the UI when that data changes. This UI rebuilding is called Recompose. Recomposing views relies on a new type of variable called State which takes a specific type for input, and triggers a Recomposition when this variable has changed.
val backSpaceVisible = remember{mutableStateOf(false)}
val backSpaceVisible - rememberSavable{mutableStateOf(false)}
val text = calculatorViewModel.entryLive.observeAsState("")
A mutable state variable is stored by a Remember function, but will not survive an orientation change which is where a RememberSaveable type comes in. This type will save to the bundle automatically when the orientation is changed. They are both declared and used in the same way to track the state of the given input locally within the Activity. As seen in the example given the backspace starts out with false for visibility to the user. As a third option these state variables can be moved into an Activity connected Viewmodel. This shields the state from being lost in orientation change and moves any actionable event information away from the view, and into a VMMV structure, Google’s preference for architecture. The Recompose system has been designed to be smart enough to only change the UI that has updated. By changing only the elements that need updates the system is saving resources by not recreating the entire screen layout each time one object is changed. All of these points were required to build the basic calculator companion app to this post. It is a lot to unpack and was thrown out quickly, but it should start to click easily when starting to build your own apps with Compose.
Struggles in the Early Days
The biggest struggle is the fact that this setup is completely different to what developers are used to. The move from XML to Kotlin might make it easier on a developer now only needing to use one language and file, but there is a lot of translation to new objects and parameters that need to be learned. Label has been renamed to Text, EditText is now TextField, etc. Not to mention some expected functionality from XML objects might not have even made it into the new Kotlin object. For example an XML Button has a textSize attribute for changing the size of the text displayed to the user. Composable Buttons are made up of 2 objects which are a Button and Text Composable, and the Text object declaration is not a required piece of the button. The fontSize attribute, renamed from textSize, must be applied to the Text part of the button and does not exist at all in the Button’s view.
@Composable
fun NumberButton(calculatorViewModel: CalculatorViewModel, number: String) {
Button(shape = RectangleShape, border = BorderStroke(1.dp, Color.Black),
onClick = { calculatorViewModel.onTextChange(number) },
modifier = Modifier
.layoutId(number)) {
Text(text = number, fontSize = 30.sp)
}
}
Dealing with the state of an object is the most fundamental change from the previous view system. Where developers previously had complete control over the view through the activity code some of that has been removed from developer access and the Recompose system must handle the updates. A basic example of this change in action is when changing object visibility. In the XML view system hiding or showing an object was handled by the visibility parameter of the object: view_name_here.visibility = View.Gone or view_name_here.visibility = View.Visible. With Composables being functions there is no attribute for visibility attached to them. A State Boolean is needed to tell the system about visibility. When that visibility Boolean is changed the system recomposes the UI based on what that boolean is.
@Composable
fun EntryTextInput(calculatorViewModel: CalculatorViewModel = viewModel()) {
val text = calculatorViewModel.entryLive.observeAsState("")
val backSpaceVisible = rememberSaveable{mutableStateOf(false)}
Column {
Row(modifier = Modifier
.fillMaxWidth()
.fillMaxHeight(0.25f)) {
TextField(value = text.value, onValueChange = {}, textStyle = TextStyle(fontSize = 50.sp, textAlign = TextAlign.Center), maxLines = 1, readOnly = true, enabled = true, modifier = Modifier
.weight(2f)
.fillMaxHeight())
backSpaceVisible.value = text.value.isNotEmpty()
if (backSpaceVisible.value) {
Backspace()
}
}
}
}
The backspace button visibility is dependent on if there is any text entered into the calculator entry field. If there is no text in the field, such as first opening the app, the button creation is skipped. If text is entered the backspaceVisible is changed to true, and triggers Recomposition. The view is Recomposed and the Backspace button is created, and when text is deleted the view is Recomposed and removes the Backspace button. After struggling with state comes the issues with Modifier. Modifiers are a bit of a pain to flesh out, but have the least amount of changes. This is where the similarities to XML come through because the Modifier parameter is where most of the XML attributes that affect size for an object are derived from, and the format for chaining them together looks most like the XML attributes list. Unlike in XML though there is a certain way these must be chained together to prevent odd behavior. Modifiers are read by the system from left to right. Switching around padding and size attributes for instance will cause different object sizes that might not be ideal. Finally some struggles came from Compose still being in beta, and components slowly being moved out into their own gradle libraries. ViewModel for instance has become it’s own library now that must be imported to be able to use Viewmodel functionality. There was no documentation about this change when following Google’s own developer guides, and was only found out when searching around for anything that was missing from the project.
Final Thoughts
Google is highly encouraging developers to start learning and migrating to Compose. Google I/O 2021 announced their full 1.0 release coming in July 2021, only a few months after their beta release. They have made every effort to make it accessible to old projects by staying within the Kotlin language and making it completely interoperable with the XML view system. With Google showing this kind of effort for any type of development it means this is a serious change to their development philosophy. While Google is pushing this change full force some changes and issues come down more to personal preference. The move into a single file for view actions is a welcome addition to project setup, and keeping it in Kotlin will help developers adjust to this new system. Once the theory and rules behind declarative UI are understood the new setup starts to click because it is not too dissimilar to XML. It was a bit of a mistake to change the names of some of the objects or attributes on top of needing to get used to this new setup though. It is easy to see that this new toolkit can speed up development time by forcing developers to think about design and implementation at the same time, but there is a bit of a ramp up time for a developer to get their head around the new system. Jetpack Compose is the future that Google wants whether or not developers are of the same opinion, but it does seem to be worth the learning curve.