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:

There are some advantages, too:

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:

  1. It is completely disconnected from Android’s resource system.
  2. 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:

And on the bad side:

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.GreenLightGray. 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. Ambients 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:

And on the bad side:

Comparisons to “real” dependency injection tools

So what do we see if we compare these to a “real” DI tool like Dagger?

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.