Using Material Theme Overlay in your custom Views

An updated version of this post is available here.

I watched Developing Themes with Style a couple of weeks ago and learned everything about themes and styles that I’d deferred for the last six years.

One of the concepts that was new to me was theme overlays. It’s a powerful technique that allows us to override only the attributes that are specified in the overlay itself.

The typical way to apply a theme overlay is using the android:theme attribute on our View. This post summarises how we can do this, as well as introducing materialThemeOverlay, a theme attribute that (kind of) lets us set a theme overlay on a default style.

Using Theme Overlays

In our AndroidManifest, we’ll set the app theme by specifying the theme attribute on the application element, android:theme="@style/Theme.Demo". Our theme defines the colorPrimary theme attribute:

<style name="Theme.Demo" parent="Theme.MaterialComponents.DayNight.NoActionBar">
  <item name="colorPrimary">@color/material_red_500</item>
</style>
<LinearLayout
  android:layout_width="match_parent"
  android:layout_height="match_parent">

  <com.google.android.material.button.MaterialButton
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:text="Apply" />

  <com.google.android.material.button.MaterialButton
    style="@style/Widget.MaterialComponents.Button.TextButton"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:text="Cancel" />
</LinearLayout>

Our layout has two buttons, which are now red. MaterialButton uses the theme’s colorPrimary attribute as its… primary color. That red “cancel” button is very prominent though, let’s change it to gray.

While we could just set the android:textColor on the View directly, we’d still have a red ripple, which we don’t want. Also, we’re unable to change colorPrimary in Theme.Demo because that would affect everything in our app (like “apply”).

Instead, let’s use a theme overlay:

...

<style name="ThemeOverlay.Demo.GrayPrimary" parent="">
  <item name="colorPrimary">@color/gray</item>
</style>
...

<com.google.android.material.button.MaterialButton
  style="@style/Widget.MaterialComponents.Button.TextButton"
  android:layout_width="match_parent"
  android:layout_height="wrap_content"
  android:text="Cancel"
  android:theme="@style/ThemeOverlay.Demo.GrayPrimary" />

This overlays the ThemeOverlay.Demo.GrayPrimary theme on top of Theme.Demo, for this specific button only. Since we only specified colorPrimary, only this attribute is overridden, and so any usages of colorPrimary for this button (or if it was a ViewGroup, any of its descendants) will resolve as gray:

A theme overlay isn’t much different from a regular style/theme. We explicitly set a null parent (parent="") so that we could be sure that we weren’t accidentally override attributes from any theme ancestors.

The LayoutInflater class will read android:theme and use a ContextThemeWrapper to layer this overlay on top of any ancestor overlays or themes. (See AppCompatViewInflater if using AppCompatActivity or MaterialComponentsViewInflater if using a Material theme).

It’s like an onion with many layers; if a theme attribute is requested, it’ll start from the outside and peel away layers until it finds the target. This is why it’s important to inflate our Views with the correct Context—the one closest to the View we’re inflating—otherwise, we might accidentally skip a theme override.

Default styles and theme overlays

When we write Views, we can specify a default style resource (defStyleRes: Int) which means we don’t have to specify it every time it’s used.

MaterialButton does this. Notice below that we only specify the style for the second instance, because we want to deviate from its default style, which is Widget.MaterialComponents.Button.

<LinearLayout
  android:layout_width="match_parent"
  android:layout_height="match_parent">

  <com.google.android.material.button.MaterialButton
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:text="Apply" />

  <com.google.android.material.button.MaterialButton
    style="@style/Widget.MaterialComponents.Button.TextButton"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:text="Cancel" />
</LinearLayout>

We didn’t write MaterialButton, but we can still override the default style because it also specifies a defStyleAttr—a default style theme attribute, which we can set in our theme, and it’ll use this instead of the default style resource:

MaterialButton(Context context, AttributeSet attrs) {
  this(context, attrs, R.attr.materialButtonStyle);
}

In our theme, we can define materialButtonStyle and point to a style that we define:

<style name="Theme.Demo" parent="Base.Theme.Demo">
  ...
  <item name="materialButtonStyle">@style/Widget.Demo.Button.Big</item>
</style>

<style name="Widget.Demo.Button.Big" parent="@style/Widget.MaterialComponents.Button">
  <item name="android:minHeight">72dp</item>
</style>

Without any changes in our layout file, we’ve changed the minimum height of a MaterialButton:

So, let’s combine what we learned in this section and the previous one: let’s change colorPrimary for all MaterialButton usages using a theme overlay in the default style:

<style name="Widget.Demo.Button.Big" parent="@style/Widget.MaterialComponents.Button">
  <item name="android:minHeight">72dp</item>
  <item name="android:theme">@style/ThemeOverlay.Demo.GrayPrimary</item>
</style>

And… nothing happens!

We can’t use android:theme in a style resource because nothing is looking for it here (it’ll just be ignored). However, we can use materialThemeOverlay instead of android:theme in our style resources:

<style name="Widget.Demo.Button.Big" parent="@style/Widget.MaterialComponents.Button">
  <item name="android:minHeight">72dp</item>
  <item name="materialThemeOverlay">@style/ThemeOverlay.Demo.GrayPrimary</item>
</style>

Hey, this doesn’t work in my custom Views!

This attribute requires explicit support in your custom View because it’s a custom attribute that needs to be handled explicitly.

MaterialButton has support for this attribute. Instead of passing the Context directly to the super constructor, it delegates to an internal class in the Material Components library, ThemeEnforcement.createThemedContext(context, ...).

This function will check for the presence of materialThemeOverlay, then if it’s present and the theme specified is different from the existing one, it’ll wrap the context with ContextThemeWrapper, similar to the way LayoutInflater does.

So… what can I do?

The easiest thing is to copy the function into our project, and use it in the same way, e.g.:

class MyCustomView(ctx: Context, attrs: AttributeSet) : View(
  ThemeEnforcement.createThemedContext(
    ctx,
    attrs,
    R.attr.myCustomViewStyle,
    0
  ),
  attrs,
  R.attr.myCustomViewStyle
)

Apart from that, there’s an open issue on the Material Components Android repository to open the class so that we can use this function directly, which could do with some thumbs up.

Let me know if you found this post useful by reaching out on Twitter, @ataulm.