Putting view theming into Context
Some words about how to theme views in code by the Context they’re inflated with.
My first recommendation would be to take a look at how to use theme attributes to theme your Android apps 🎨 since it’s very related.
Also, do not miss this talk by Nick Butcher and Chris Banes from Android Developer Summit about the differences between themes and styles. It contrasts both and highlights things like how “parent” themes are applied per Context
and for complete View
hierarchies inflated using it.
Finally, here’s also a nice read by Ataulm about theme overlays that also contains some interesting bits about default styles and how the Context
is themed.
Styles and themes per View
Styling Views
directly on XML layouts is okay, as long as you reuse a common set of styles across the app to keep coherence, since that’s the ultimate goal, isn’t it?
On top of that, you can also theme Views
by using the android:theme
attribute directly on them. That applies the theme to that specific View
and cascade down to all its descendants. That comes handy to theme certain subsections of your layouts differently. Keep in mind this overrides any themes applied at any other level for this View
.
Both approaches usually take place when you’re not super skilled with styles and themes in Android, so it becomes hard for you to have a proper mental mapping on how to structure those.
Here’s their inherent weakness: They’re applied per View
occurrence, hence it’s tedious and prone to errors. These approaches can potentially affect visual coherence across screens if you forget to apply a style or a theme where required, or by using the wrong ones in the wrong place.
Recommended approach
It is always preferable to have styles & themes imposed by the ApplicationTheme
whenever possible. This leverages coherence and reusability, plus unlocks design systems (e.g: like Material Design).
You can achieve that by using default styles. There’s good details on what default styles are on this talk and this post, both already recommended above.
For summarizing here a bit, default styles are applied through standard theme attributes (i.e: textInputStyle
) that you can give a value to in your ApplicationTheme
. Those will impose a global style for all the occurrences of the corresponding component across the app (i.e: TextInputLayout
).
<style name="ApplicationTheme" parent="Theme.MaterialComponents.DayNight">
<!-- Default style used to theme all `TextInputLayouts`. -->
<item name="textInputStyle">@style/ApplicationTheme.Input</item>
</style>
<style name="ApplicationTheme.Input" parent="Widget.MaterialComponents.TextInputLayout.FilledBox">
<!-- Some style item definitions here -->
</style>
Then Views
pass it over to the super()
constructor as the default style to use. This is how TextInputLayout
from Material Components does it.
public TextInputLayout(@NonNull Context context, @Nullable AttributeSet attrs) {
this(context, attrs, R.attr.textInputStyle);
}
Note how this is a style, not a theme. The fact that you’re using a theme attribute to assign it doesn’t mean it’s a theme neither a theme overlay. It is a theme attr
that points to a style, or in other words, you theme your app using some default styles.
The style will inherit from the material
TextInputLayout
style as we did in XML above.
If you’re able to theme your complete application using default styles defined in your ApplicationTheme
, and you make sure that your activities are inheriting from this theme, you’ll unlock theming by Context.
Theming by Context
So you got your design system in place. Your activities inherit ApplicationTheme
, and all views in the app should be reusing theme attributes for their colors. Material components already do this by themselves, but you’ll want to do the same for any other views.
This makes you able to swap your ApplicationTheme
by any other theme and the app will automatically update its colors transparently.
This happens essentially because your app theme is applied to the application Context and the Context of your activities.
Any Views
, Dialogs
, DialogFragments
, BottomSheets
, or any other UI bit in the hierarchy inflated with the same Context
will be themed following it.
So here’s a remainder:
It’s the closest
Context
to theView
the one imposing how theView
(or other UI elements) looks.
Context wrapping
For theming, material wraps the Context into a ContextThemeWrapper before passing it to View
parent constructors, and the proper application theme is passed for it. This is very well explained by Ataul on his post.
Following docs, a ContextThemeWrapper
is:
A context wrapper that allows you to modify or replace the theme of the wrapped context.
The key implication for this fact is that you must take care of using the closest Context
whenever you’re inflating your own views in code. Same for dialogs and everything. Otherwise they can potentially skip the theme needed.
Here’s how you can use it by yourself, in case you don’t have some specific Views
directly linked to the proper Context
because reasons, or you want to override it from code.
val themedContext = ContextThemeWrapper(
context,
R.style.YourCustomTheme // apply the theme you need to the Context.
)
val view = MyCustomView(themedContext)
val otherView = LayoutInflater.from(themedContext).inflate(...)
Et voilĂ , everything inflated with that Context
will be themed accordingly.
This might not feel like an issue you easily fall into if you use app wide theming through default styles, but it can come handy in legacy codebases that are not following this pattern and are using styles arbitrarily. It’s usually not easy to refactor the whole thing all at once, is it?
Another scenario where you can find an issue is on instrumentation tests or when you’re unit testing Android UI using tools like Robolectric or similar. Whenever you’re using a Context
for instantiating your own views in tests, you should always ensure it’s properly themed. Otherwise you will run into two potential failures:
-
If those
Views
are reusing theme attributes (i.e:?attr/colorSurface
), those attributes will not be resolved and crash at runtime 🙀, so your test will fail. -
If you’re asserting over UI you can potentially get some assertion errors, since the
View
will not be themed as expected.
To solve it, once again, wrap the Context
with ContextThemeWrapper
to provide the theme you need before proceeding to inflate any views using it.
You can grab ContextThemeWrapper
from the SDK and it was actually also ported to AppCompat so you can also grab it from there. Both are equivalent for what is worth for the end user. The Android team did this mostly for backporting a specific constructor that the Android team needed.
AndroidX Appcompat artifact:
implementation "androidx.appcompat:appcompat:1.1.0"
testImplementation "androidx.appcompat:appcompat:1.1.0"
Final words
The difference between themes and styles is highly important, so I can’t recommend enough this talk. Never try to use themes as styles, or vice versa. Writing these posts is also helping me to polish my mental mapping on this.
Don’t for get to take a look to the previous post in the series, if you didn’t! And big thanks to Ataul, Nick Butcher and Chris Banes for their helpful resources.
For anything else you can always find me on Twitter, feel free to ping me there. You can also find me on Instagram.
See you soon! đź‘‹
Want to support me?
If you reached this point you might consider supporting me for boosting my will to write. If that’s the case, here you have a button, really appreciated! 🤗
Supported or not, I will keep writing and providing content for free âś…