Jetpack Compose is coming sometime
this year. Although it is still under heavy development, given its significance, I think now is
the right time to look at what it brings to the table.
This isn’t a technical tutorial or introduction to Compose (there are many of these floating
around, but be careful, as many of them are already out of date), but rather a collection of
more or less random points, notes, and findings. Let’s find out if the hype is justified!
Executive summary, but for developers
Compose is going to be one of the biggest changes Android development has ever
seen.
Yes, perhaps even bigger than reactive programming, Kotlin, or coroutines. UI is a crucial part
of any application and a UI toolkit built on the mindset from the 2010s instead of the 1990s is
indeed a very welcome upgrade.
Also, because it relies on Kotlin-exclusive features, Compose is another nail into Java’s coffin
on Android.
Making UIs is fun again!
This is Compose’s equivalent of RecyclerView with different
item
types:
LazyColumn {
items(rows) { row ->
when (row) {
is Title -> TitleRow(row.title)
is Item -> ItemRow(row.text)
}
}
}
Of course, everything isn’t that simple, but Compose really excels at its main goal—creating
sophisticated and highly reusable custom components and complex layouts in a simple, effective,
and safe manner.
The mental model is radically different from what we are used to in Android.
Unidirectional UI toolkits were the rage with web folks some time ago, and now they’ve finally
arrived on mobile platforms.
The good news is that because we are late to the party, the paradigm has matured, and perhaps
Compose won’t repeat at least some of the mistakes that caught up with early implementations on
other platforms. The bad news is that the paradigm requires a significant mindset shift (say on
a scale of reactive programming)—but it’s for the better, I promise.
Compose has a huge multiplatform potential.
Compose comprises several
artifacts, and only some of them are Android-specific.
JetBrains already work on desktop port, and
covering other platforms is certainly not impossible.
Building on a combination of existing platform-specific UI toolkits and Kotlin Multiplatform
features such as expect/actual declarations, one can imagine a
distant future where a single UI
toolkit provides the holy grail of unified implementation, native performance, and
platform-specific look’n’feel.
Creating UI
There are no XML layouts, no inflaters and no objects representing the UI
components.
There are no setters to mutate the current UI state because there are no objects representing the
UI views (@Composable function calls only look like
constructor calls, but don’t let that fool you), which means there cannot even be any internal
UI state (well, the last point isn’t entirely true, but we’ll get to that later). Then you must
think about states and events traveling through your UI tree in various directions and whatnot.
If you’ve never experienced a unidirectional toolkit, it will feel alien, strange, and maybe even
ineffective, but the benefits are worth it.
String, font, and drawable resources are staying.
Compose doesn’t want to get rid of these and works with them just fine. However, only bitmap and
vector drawables make sense with Compose. Other types such as layer list drawables, state list
drawables, or shape drawables are superseded by more elegant solutions.
Colors
and dimensions
should be defined entirely in Kotlin code if possible, but traditional resources still may be
used if needed.
There are no resource qualifiers.
Compose has the power of Kotlin at its disposal. If you need to provide alternative values
depending on the device configuration (or any other factor), simply add a condition to your
composable function—it’s an explicit and unambiguous way to specify the value you want.
And of course remember to keep your code DRY—if you find yourself repeating the same bit of logic
in many places, refactor.
There are no themes and styles (sort of).
Compose contains basic components that expose a number of parameters to change their look and
behavior. Because everything is written in Kotlin, these parameters are rich, and most
importantly, type-safe.
If you need to style a bunch of components in the same way, you simply wrap the
original composable function call with your own composable, setting the parameters you need to
change (or exposing new ones), and use this in your code.
Simple, efficient (because there is virtually no penalty for nested calls), and without hidden
surprises.
Compose comes with Material Design implementation out of the box.
Although there are no themes or styles as such, there is a way to create and use application-wide
themes.
Compose comes with Material
Design implementation. Just wrap your root composable with MaterialTheme,
customize colors, shapes, or typography to fit your brand, and you’re good to go. You can have
different MaterialTheme wrappers for different parts of your UI,
effectively replacing theme overlays from the legacy system.
Often this is all you’ll ever need, but if your design system is more sophisticated or simply
won’t fit the predefined Material Design attributes, you can implement your
own from scratch. However, this is quite difficult and requires advanced knowledge of
Compose to get it right.
See this
blog series for valuable insights on custom design systems in Compose and this post for a
comparison of different theming approaches.
We can’t completely get rid of the legacy theme system (yet).
Compose theming reaches only the parts of the UI that are managed by Compose. We might still need
to set a legacy theme for our activities (to change window’s background, status bar, and
navigation bar colors, etc.), or to style View-based components that don't have Compose
counterparts.
Don’t expect component or feature parity with legacy View-base components or Material
Design
specs any time soon.
It’s the old story all over again: Writing a new UI toolkit from scratch means that there is
going to be a long period in which a number of components (or at least their features) won’t be
officially available.
For example, Compose’s TextField
doesn’t have the same features (and API) that TextInputLayout
has, and both of these implementations aren’t 100 % aligned with the Material Design spec.
This situation may be slightly annoying, but at least with Compose, it’s significantly easier to
write custom components yourself.
Finally, an animation system so simple that you’ll actually use it.
Animating many things is as simple as wrapping the respective value in a function
call, and for more complex animations, Compose superbly leverages the power of Kotlin.
Done right, animations are a great way to enhance user experience. With Compose animation APIs,
their implementation is at last effective and fun.
Internals
Composable functions are like a new language feature.
Technically, @Composable is an annotation, but you need to think
about it more like a keyword (suspend is a good analogy, more on that later). This “soft
keyword” radically changes generated code, and you need to have at least a basic idea of what
goes on under the hood, otherwise, it’s very well possible to shoot yourself in the foot
even with innocent-looking composable functions.
The knowledge of the internals is important for creating performant UIs.
Compose compiler does a lot in this regard (like positional memoization, and fine-grained
recomposition), but there are situations when the developer has to provide optimization clues,
or declare and then actually honor contracts that the compiler cannot infer on its own (such as
marking data classes as truly immutable).
However, I expect the compiler to become smarter in the future, alleviating the need for many of
these constructs.
States
Compose UIs are declarative, but not truly stateless.
UIs in Compose are declared by constructing deeply nested trees of composable functions where states flows down and events
up. At the root of the tree, there is a comprehensive, “master” state coming from some
external source (the best candidate for this task is the good old view model). When the state
changes, parts of the UI affected by the change are re-rendered automatically.
In theory, we want the UI to be perfectly stateless. The root state should be completely
externalized and should contain everything that must be set on the screen. That would
mean not
just obvious things like text field strings, checkbox states, and so on, but also, for example,
all styling attributes for all the views, internal animation states including clock,
current
animated values, etc.
In practice, this would be too cumbersome (argument lists would grow unacceptably large and
“interesting” arguments like user inputs would get mixed up with purely technical ones like
animation states), so besides explicit state that is passed via composable function arguments,
Compose has several other ways to provide data down the component tree.
Composable functions can have their own internal state.
Yes, function can have state encapsulated in it that survives between its invocations. This is a
pragmatic decision that simplifies its signature and enables some optimizations, and is
especially handy for animations and other things that don’t need to be changed and/or observed
from outside.
Ambients are like service locators for data passed through the UI tree.
Ambient
holds a kind of global variable defined in the tree node somewhere up in the hierarchy,
statically accessible to nodes below it. If this rings an alarm bell in your head, you’re
right—statically accessed global variables create invisible coupling and other problems.
However, this is a trade-off that is often worth it. Ambients are best suited for values that we
need to set or change explicitly but don’t want to explicitly pass through the tree.
Theme attributes and properties are a prime example of such things.
State management is now more important than ever.
So we have (at least) 3 ways to store and manipulate state in Compose, and they can even be
combined along the way. The question of which method to use for which part of the state becomes
essential. Sometimes, the answer can be difficult, and choosing the wrong one can lead to all
kinds of messy problems.
Also, especially for larger screens, both the structure and the content of the state object is
crucial.
Until next time
Well, that concludes part 1. In the second and final part of this series, we’ll look at the
ecosystem, performance, stability, and even the magic that makes Compose possible. Take care and
stay tuned!