Crouching Theme, Hidden DI
Since I wrote about views recently, and since I’ve been doing more view work, I’ve had an uncomfortable wrinkle that keeps on showing up in my effort to make views more standalone:
Themes.
I mentioned this in my original write up, but it keeps on nagging at me: every time I try to stand up a view by itself I almost always run into a theming issue. And given that my whole goal was to minimize dependencies, the truth is clear:
Themes are dependencies, and are provided via a dependency injection framework.
So how do theming mechanisms work? What makes them different from other DI tools? And can we learn something about our current approach by comparing them to the state of the art in other arenas?
Android Themes
In Cash App, theming is currently handled by two different mechanisms: the resource theming system, and Cash’s new internal theming framework. Let’s talk about the Android mechanism first.
The resource theme mechanism is as old as my knowledge of Android, which starts right at around Froyo. It has a few major components:
The first is the idea of view attributes. In XML, all configuration of widgets is done by setting view attributes to values:
<TextView
android:id=”@+id/title”
android:layout_width=”wrap_content”
android:layout_height=”wrap_content”
android:gravity=”center’
android:textColor=”@color/textColorPrimary”
android:textSize=”@dimen/large_title_text”
/>
All of the XML attributes listed here (with the exception of those prefixed with layout_
, which are treated specially) are view attributes, and are integrated into the theming system.
Next is the notion of styles. Styles are collections of view attributes and values that may all be referred to together.
<style name="TitleText" parent="None">
<item name="android:gravity">center</item>
<item name="android:textColor">@color/textColorPrimary</item>
<item name="android:textSize">@dimen/large_title_text</item>
</style>
This TitleText
style can then be used to set a whole bunch of attributes on a view at one time, giving it a specific look and feel which you can reuse elsewhere:
<TextView
android:id=”@+id/title”
android:layout_width=”wrap_content”
android:layout_height=”wrap_content”
style=”@style/TitleText”
/>
And now for the last bit: the theme. If you call Activity.setTheme
passing in TitleText
(or set the theme in your AndroidManifest.xml
, that style becomes your activity’s theme:
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setTheme(R.style.TitleText)
}
The theme style is set on every single view in the activity.
If that sounds useless in the case of R.style.TitleText
, you’d be right. Most themes do not define view attributes the way that a style like TitleText
does. Instead, they define well known attributes called theme attributes:
<style name="Theme.Cash.Dark" parent="Theme.MaterialComponents.NoActionBar.Bridge">
<item name="colorPrimary">@color/themed_dark_background</item>
<item name="colorPrimaryDark">@color/themed_dark_background</item>
<item name="colorAccent">@color/standard_white_normal</item>
...
</style>
Theme attributes are referred to indirectly within some widgets in the toolkit, and can also be referred to indirectly from your layout XML, or within your styles::
<style name="TitleText" parent="None">
<item name="android:gravity">center</item>
<item name="android:textColor">?attr/colorPrimary</item>
<item name="android:textSize">@dimen/large_title_text</item>
</style>
The theme attribute colorPrimary
is dereferenced at inflation time, which resolves to a resource reference, which can then be resolved into a string, drawable, font, or any other thing that lives in the resource system — including other styles.
And finally, there’s the ContextThemeWrapper
. Want a different theme on a specific View?
val wrappedContext = ContextThemeWrapper(context, R.style.Theme_Default)
Wrap your Context in a ContextThemeWrapper
with a different theme. Done.
Upsides and downsides of resource themes
The most important thing to note about Android’s resource theme mechanism is that attributes and theme attributes “feel” quite different. A theme attribute is just a value that is dereferenced; a regular attribute like <item name="android:text">Ok</item>
, on the other hand, is directly applied to a view.
That’s easily explained, but boy what a difference there is between the two! Simple attributes are easily understood; finding their documentation is straightforward. Theme attributes, on the other hand, are a portal into arcane knowledge. Which theme attributes are available to be set? On which versions do they apply? Which widgets consume them? Which themes override them, and how? If one of these attributes is unset, how do I provide it? What type is the attribute? The theme attribute may even resolve to a style, which opens a whole other can of worms. Some have shed light on this darkness, but it’s still not an easy space to explore.
Let’s boil this down to some specific critiques:
- It entangles setting values on view properties with setting values on well-known named theme attributes.
- Theme attributes are dynamically typed and do not self-document in any way. You either have to find call sites to see how they are used, or attempt to use them yourself and see what type they are used as. If they were fun to use that might make up for all that, but they’re not. They’re the least fun thing to use in the whole Android UI stack.
- Failure to provide a theme dependency is detected at runtime, not at compile time.
- Theme attributes are not clearly documented and are globally scoped, making it difficult to find out which end is up when investigating them.
There are some advantages, too:
- Any property exposed via an XML attribute is automatically also available for use in the style/theme system. That’s pretty cool.
- But on the flip side: not really. A style for a particular kind of button can easily be created, but your own theme? I haven’t tried to do it myself, but if I look into my crystal ball, I can see a world in which I attempt to do so and become bitter and old and frustrated.
- There isn’t much to it, technically speaking.
- It is absolutely sufficient in terms of power to get the job done, in spite of having almost nothing to it in terms of technical concepts.
Cash Theme
The Cash theming system, by comparison, is in some ways more stripped down. Here I document it as it is today, with some details elided. It is a Kotlin data class that looks approximately like this:
data class ThemeInfo(
val colorPalette: ColorPalette,
val textEntryField: TextEntryFieldInfo,
val primaryButton: ButtonThemeInfo,
val secondaryButton: ButtonThemeInfo,
...
)
Each view accesses the ThemeInfo
through an extension method on View
:
val themeInfo = themeInfo()
themeInfo()
searches through the chain of context wrappers for an instance that implements HasThemeInfo
, which has a getter implementation. So the Cash theme for a portion of the view may be overridden with a context wrapper in the same way Android themes are.
Most of these properties are various kinds of ThemeInfo
objects. The first property, the ColorPalette
, is a special case. This is a data class with properties corresponding to colors in our design language (documented in Figma):
data class ColorPalette(
// Tint.
val tint: Int,
val bitcoin: Int,
val lending: Int,
val investing: Int,
// Backgrounds.
/** The color for the main background of an interface. */
val background: Int,
...
// Buttons.
val primaryButtonBackground: Int,
val primaryButtonTitle: Int,
...
)
These values are populated in one of the two themes defined in the app — light, and dark.
All of these are resolved color literal values, not resource values. They are often directly referred to in the view code:
val colorPalette = themeInfo().colorPalette
private val background = View(context).apply {
setBackgroundColor(colorPalette.background)
}
But standard components will pull automatically from them by default.
private val acceptButton = MooncakePillButton(
context,
size = LARGE,
style = SECONDARY,
)
The remaining properties are all data classes consumed by specific widgets in the theme system (called “components”) with types like CardEditorThemeInfo
:
data class CardEditorThemeInfo(
@ColorInt val textColor: Int,
@ColorInt val hintColor: Int
)
These narrow ThemeInfo
objects provide colors and/or dimensions that the components internally refer to when theming themselves.
The Cash theme system is wildly different from the Android system, but the two most important differences I believe are the following:
- It is completely disconnected from Android’s resource system.
- It is in no way a generic mechanism: the component system does not allow every aspect of the views to be altered. For the same reason, it’s not a library: it is a pattern that we have implemented internally.
Not only is it not generic, but it is less generic today than it was when it was first built. As the themed component system has continued to be refined and fleshed out, we have driven more and more configuration out of the various ThemeInfo
objects and into components working off of the shared color palette. Eventually, we expect that this palette will be all we need.
Upsides and downsides to Cash Theme
Let’s rattle off the advantages:
- Everything is clearly typed! It’s just Kotlin code, after all, and so you write out whatever type is best.
- We’ve documented it, too, which is nice. Most things even correspond to designs in Figma, which is doubly reassuring.
- Because everything in the theme is Kotliny, there is a compile time guarantee that every theme dependency will be provided if you have an instance of
ThemeInfo
. - Most of it is not tied to the Android resource system, and so it is possible to drive large chunks of it from the server.
- Many of us are building all our new UI in Kotlin (see Contour). Not having to ever switch between XML and Kotlin is wonderful.
- There’s nothing stopping you from putting any damned thing you want into the theme.
And on the bad side:
- While
ThemeInfo
is typesafe once you have it, it is provided dynamically. That means that if you forgot to include it, you won’t find out until runtime. - There’s nothing stopping you from putting any damned thing you want into the theme. I’m not sure how bad this is, but it’s certainly disconcerting. We’re reliant on our colleagues to keep the guard rails on, because the tool provides none.
- It’s one whole theme. Everything has to be built into it. There is no way to define a portion of theming that is specific to your own module, and so the single global theme is bound to accrete extensions as time goes by.
- It only has exactly the amount of flexibility that has been designed into it. Android’s is more powerful and general.
- New developers on the project have to learn it. Our system is now expressive enough to correspond closely with our Mooncake design system, which they will need to learn anyway, so for Cash this is not too problematic. Building it was a major investment, though. A smaller team may be better served by tweaking the out-of-the-box theming system.
Compose Theming
Compose has a theming system, too. To explain how it works without first explaining all of Compose is perhaps impossible. Let’s try!
To build a view in Compose, one calls a composable function. These functions are named in CapsCase, like classes, because they fill the same role that classes would in building your hierarchy in the View system.
Text(text = "Hello World")
To draw your text on top of a green background, use the function call hierarchy: call the Text
function from within a Surface
function that will draw the background color:
Surface(background = Color.Green) {
Text(text = "Hello World")
}
Out-of-the-box Material theming is provided through the MaterialTheme
composable. Unlike Surface
and Text
, the MaterialTheme
composable does not describe something that is drawn to the screen. Instead, it provides values:
MaterialTheme(colors = darkColors(background = Color.Green) {
Surface(background = MaterialTheme.colors.background) {
Text(text = "Hello World")
}
}
Within the context of Surface
’s callback block, MaterialTheme.colors.background
will be bound to Color.Green
. Outside of that callback block, MaterialTheme.colors.background
will be bound to the default value, currently found in androidx.compose.material.lightColors
. Values for typography and “shapes” are provided as well.
This technique is implemented using a power tool called Ambient
. Ambient
s are a whole concept worth reading up on in themselves, but for our purposes here it is enough to say that they achieve the same hierarchical semantics that resource theming and Cash theming achieve with Context
wrappers, but by using the scope of a call stack instead of Context
.
Unlike the Android resource theme system, the MaterialTheme
API is specific to composables defined in the Material toolkit found in androidx.compose.material
. It does not program the look and feel of the base widgets in the toolkit. Values from MaterialTheme
may be referred to by hand when using basic building blocks like Box
, Text
and Surface
, but they are not used by default. Instead, these values are used either by hand, or to program widgets defined in Material
toolkit found in androidx.compose.material
, which is built on top of the base widgets.
That means that it shares the Cash theming system’s two major differences with the Android resource theming system: it is completely disconnected from the Android resource theming system, and it is not generic. You can even skip the Material theme system and build your own.
For more details, I can’t do any better than point you to this video from Google’s Nick Butcher. I’ve linked directly to the section on building your own theme.
Upsides and downsides to Compose’s theming system
And now for the rundown. The nice things are similar to the Cash system:
- Everything is strongly typed. There’s even a nifty new
Color
class. - Finding everything that is themeable is straightforward: it’s whatever you can find defined on
MaterialTheme
. And with IntelliJ’s Find Usages, finding everything that uses a particular theme component is straightforward as well. - The
Ambient
mechanism has a static default value, so the whole thing is not only type safe at compile time, but will also not crash at runtime. - No ties to XML.
MaterialTheme
works well as a building block in your own theming system if you want to use it that way. It is not extensible, but the fact that it is built using normal Compose tooling means that it doesn’t need to be: your own custom system can have just as much power.- You can also use the
Ambient
mechanism to build theming that is local to your own module.
And on the bad side:
- Just like with Cash’s system, the power of the theming system means that you can put anything you want into it. Be on your guard.
- It only has exactly the amount of flexibility that has been designed into it. Android’s is more powerful and general.
- While it is nice that it will not crash at runtime, this also means that it is possible to introduce a silent error: running the view without the appropriate theme dependency. In your own custom theme, you may prefer to go without this safety by providing
error(“Not provided”)
for your ambients by default. Ambient
is a real footgun at the ready. It should be used sparingly, if at all.
Comparisons to “real” dependency injection tools
So what do we see if we compare these to a “real” DI tool like Dagger?
- Values are always referred to by name, never by type. All generic object injection systems assume that the most common injection case is one where you need some properly configured instance, and any instance will do. But in the view world, the default is the opposite: you want to choose one specific color out of a universe of valid instances.
- Scoping is more common. Cash’s Dagger setup has
@Singleton
,@Activity
… and that’s it. I haven’t seen many more than that in my production experience. By comparison, it may be necessary to theme one section of the screen to match, say, the Bitcoin design language, and the remainder to match the default global theming. Or more commonly, the designer will tweak one aspect of an element ever so slightly, requiring a tweak to the theme. - The tactic of providing a “fake” dependency graph isn’t universal in Dagger, but in the view world it doesn’t exist at all.
- Spinning up these “dependency graphs” is trivial — unlike Dagger. The default theme for both framework systems is spun up for the developer without their having to lift a finger.
- The app’s theming may be dynamic, but a given theme dependency graph doesn’t change once it has been defined. The sophisticated dependency graph tooling that Dagger gives you is not necessary.
Conclusions
A tool like Dagger is much more powerful than any theming system I’ve ever used. It has modules, it can be extended to provide “assisted” dependencies or lazy dependencies, and it gracefully handles complex dependency graphs without much programmer input. But at the same time, both of these theming systems make tasks like named instances and scoping trivial.
Do we need “Dagger, but for views”? An old mentor of mine used to tell me that “Anything worth doing is worth doing poorly.” Before we had DI frameworks, we had manual dependency injection, and before that we had static state. If “Dagger, but for views” were worth doing, we’d probably already have a bad version of it out there.
But theming is worth doing, and it has been done poorly. We should expect better than the resource theming system.
Better solutions are out there. Our example at Cash may not be something that you can port directly into your own application, but it does show that doing it yourself isn’t as huge a chore as you might think. You aren’t stuck with the Android system. And when Compose happens, you won’t even have to build it yourself.
Above and beyond the usual Cash Code Blog review suspects (Alec Strong and Jesse Wilson), thanks to Nick Butcher and Chris Banes from Google for their review of a draft of this post. Thanks also to Matt Precious, the architect of Cash’s theming system, for technical feedback.