Jetpack Compose is a new modern, declarative UI toolkit developed by Google and used for building quick and efficient user interface components for Android applications. It leverages the power and expressiveness of the Kotlin language and is unbundled, similar to other Jetpack libraries, ensuring consistency across different API versions. It comes with all the functionality needed to build a rich and responsive application UI and features full interoperability with current Android views, making it easy for developers to implement in existing projects.
But why choose Compose when Android already has robust UI creation tools using views, widgets, and XML? Due to Compose’s powerful declarative syntax and structure, developers can create rich UI many times faster than with the old toolset. Due to how state and data are handled in Compose, developers are less likely to make costly errors and introduce hard-to-find bugs in their codebase.
UI and State
With many imperative, object-oriented UI toolkits, the UI is initialized by instantiating a tree of widgets. This is often done by inflating an XML layout file. Each widget maintains its internal state and exposes getter and setter methods that allow the app logic to interact with the widget. In contrast to this, in Compose’s declarative approach, widgets are relatively stateless and do not expose setter or getter functions. Widgets are not exposed as objects. The composables are responsible for transforming the current application state into a UI every time the observable data updates.
The concept where the UI is changed directly to reflect changes in the app’s state is not new and is embraced by many different modern development platforms. Some of these are also used for Android development, such as React and Flutter. The new Jetpack Compose toolkit is an evolution of this paradigm. At its core, it emits UI directly, smartly modifying only the views affected by the data change. In this way, the UI always reflects the current state of the app’s data and prevents issues caused by developers manually handling the synchronization task themselves.
When using Compose, the information flow will be unilateral – it will only flow in one direction from the data state to the views. This means that architectures such as MVP with a two-way communication flow might be more challenging to integrate with Compose. Preferable architectures such as Google’s recommended MVVM can use LiveData and ViewModels to observe state changes and automatically emit new UI using Compose if needed.
The core building blocks of Jetpack Compose are the composable functions. These functions take some input and describe a part of the app’s UI, generating what’s shown on the screen.
A composable function is created by adding the @Composable annotation to the function name. Similar to coroutines, composable functions can only be called from within the scope of other composable functions.
Composable functions can accept parameters that allow the app logic to describe the UI. These parameters can also include LiveData objects which can be updated dynamically. Each time a parameter is changed, the composable function will be triggered again so that a new UI can be emitted to reflect the change. Composable functions emit UI hierarchy by calling other composable functions. In this example, the Text() function creates the text UI element.
Composable functions don’t return anything. They describe the desired screen state and directly emit UI instead of constructing UI widgets. That is why they do not need to return anything.
Composable functions can be previewed inside the Android Studio IDE by adding the @Preview annotation before @Composable. Currently, only functions without any parameters can be previewed in this way.
In the old imperative UI model, to change a widget, a setter must be called on the widget to change its internal state. In Compose, the composable function is automatically called again when there is a change in the data. This causes the function to be recomposed, and in turn, the widgets emitted by the function are redrawn, if necessary, with the new data. The Compose framework can intelligently recompose only the components that have changed.
The process of calling your composable functions again when inputs change is called recomposition. When Compose recomposes based on new inputs, it smartly only calls the functions or lambdas in the code that might have changed and skips the rest. By skipping all functions or lambdas that don’t have changed parameters, Compose can recompose efficiently and greatly improve the performance of the UI.
Composable functions might be re-executed as often as every frame, such as when an animation is being rendered. It is very important to keep these functions fast to avoid jank during animations and view creation. Suppose expensive operations need to be executed, such as reading from shared preferences or making complex calculations. In that case, these should be done in a background coroutine and their values passed to the composable function as a parameter. Ignoring these best practices could lead to unresponsive UI and stutter, especially when scrolling lists and animating components.
Things to be aware of when using Compose:
- Composable functions can execute in any order.
- Composable functions can execute in parallel.
- Recomposition skips as many composable functions and lambdas as possible.
- Recomposition is optimistic and may be canceled.
- A composable function might be run quite frequently, as often as every frame of an animation.
Layouts and UI Hierarchy
At their core, composable functions are used to emit UI elements. A single function can emit several UI elements. However, developers should guide how these should be arranged to achieve the desired outcome. Special layout functions can be used, like Column that places items vertically on the screen, or Row that places them horizontally. Both Column and Row support configuring the alignment of the elements they contain. To display elements one on top of the other, developers can use the Box function.
These fundamental building blocks are often enough to create most layout configurations, but they can also be combined to form more elaborate structures if required. In contrast to Android views, where developers need to avoid nested layouts for performance reasons, Compose handles nested layouts very efficiently, making them a great way to design a complicated UI.
To customize, decorate, and augment composables, the Compose toolkit uses modifiers. These are standard Kotlin objects which can be created by calling one of the Modifier class functions. These functions can be chained together to set multiple properties at once. In this case, the order of chaining is important, as each function makes changes to the modifier object returned by the previous one, so that the sequence will affect the final result.
Modifiers enable developers to customize a large set of things, including:
- Change the composable’s size, layout, behavior, and appearance.
- Add information, like accessibility labels.
- Process user input.
- Add high-level interactions, like making an element clickable, scrollable, draggable, etc.
The list component is a fundamental building block of every Android app. Its main feature is the ability to render only the currently visible elements on the screen, thus improving the list’s performance and responsiveness. In the past, this was achieved by the Android RecyclerView which required cumbersome adapter classes to be created and maintained. Creating list headers and footers was also a pain, especially if these had custom behavior such as sticky headers.
With Jetpack Compose this functionality can be achieved with just a few lines of code using the new lazy composable functions, such as the LazyColumn() function. These are different from other Compose layout functions, as they have their own DSL (domain specific language) used to achieve the desired behavior. The main building block used by the Compose list is the items() function used to describe and lay out the items that will be displayed on the screen. This can be done by supplying the function with a list of objects that will emit the UI for a given item in the list.
Just like with RecyclerView, lists created with Compose can be arranged in different layout patterns. LazyColumn is used for vertical lists, while horizontal lists can be created with LazyRow. To create more complex grid patterns, LazyVerticalGrid can be used.
The app will need to react and listen to scroll position and item layout changes in some cases. The lazy components handle this use-case by employing the rememberLazyListState() function that provides a reference that tracks the list’s current state.
While it is a best practice to supply all the needed data up front as parameters to the Compose function, there will be times where it will be required to execute code asynchronously inside the function itself. The recommended way to do this is with the new coroutines introduced in Kotlin. To use coroutines inside a Compose function, a coroutine scope has to be acquired first. This is achieved by calling the special rememberCouroutineScope() function. After that, we can easily launch the coroutine by calling scope.launch().
The Jetpack Compose toolkit is being developed rapidly by Google and will soon become the preferred way of creating UI for Android applications. It will be beneficial to many developers to get acquainted with it as early as possible. This new powerful tool brings many improvements to the UI creation process, allowing users to achieve more with less code and produce a highly maintainable and less error-prone codebase.
Jetpack Compose does not only bring benefits, though. It presents additional challenges that have to be overcome by developers. They will have to learn and adapt to a completely new way of building the user interface of their apps while being more mindful of the performance and structure of their UI code. A key factor to successfully implementing Compose will be obtaining a fundamental understanding of how recomposition and state work inside the toolkit. These good practices will help developers achieve better optimization and performance and improve their app’s structure and data flow.