Implementing Dark Mode on Android — part two
Lessons learned from a single Activity, multi-flavored, dynamically themed Application
In the first part of this article, I explained how I implemented dark mode on the Expressen app. However, I only shared the happy path. In this follow-up article will address all the issues I bumped into.
When the user switches from dark to light and vice versa in the system a configuration change is triggered. This means your Activity is recreated and you have to handle that in a good way. So if you for some reason haven’t already built an application that can be recreated it’s about time.
Having multiple flavors
Here I chose the approach of having a separate theme-file for each flavor that extends the base theme. I inherit from the base theme since I want the styles, margins, etc to be the same over each and every application. The only thing I specify in each derived flavor theme is the colors that need to be overridden.
I also needed to have separate palette-files for the flavors (and their night representatives) since they could have a completely different header color scheme. And two of them also have the same header in both dark and light mode. So for example in
theme_kvp.xml I specify the
themeHeaderBackground styled attribute towards
kvp_mode_header_background instead of the default
mode_header_background. This way I keep all views clean and the same for every flavor. You might ask why you don’t override it by putting the colors in the corresponding
/flavorX folder? Good question. the answer is the next header — changing theme dynamically. Since I need to be able to use all themes from all flavors I need them in the
Changing theme dynamically
One of the biggest concerns for me was that I had to be able to change the theme for each article, thus also to the correct mode colors for header and navigation. And having the single Activity application made it harder. To understand that we have to understand how themes work on android. They need to be set before we call the super
onCreate in our Activity to be part of the activities context. And the activities context is later used by fragments (or layout inflators) on creation to get all the values resolved properly.
So my problem here is twofold. Firstly I need to be able to use the theme when creating my fragment (that we now know inherits from the Activity). Secondly, I need to get the colors out from the theme to update the navbar and toolbars that live in the Activity rather than the fragment created.
To solve the first issue we need to alter the context in which the fragment is created. Enter the
ThemeResolver helper class, from the given path of the article it resolves which theme to use and creates an inflater that uses that theme.
This clones the given
LayoutInflator with a
ContextThemeWrapper which is described like this in the source code.
The specified theme will be applied on top of the base context’s theme. Any attributes not explicitly defined in the theme identified by themeResId will retain their original values.
So that’s spot on what I needed. A new context that uses my custom section theme if applicable. And with the cloned
LayoutInflator I inflate the fragment in the view.
Phew, now that seems to work. Next, we need to get the theme-specific colors extracted so that we can use the colors to update our views dynamically (the child views of the Activity rather than the Fragment). Unfortunately, we can’t just use the resource
R class for this either.. as we would with drawables, colors, and styles directly on views. Nope, it’s not gonna be that easy.
To be able to retrieve a styled attribute we need to obtain it from the context containing the correct theme first and know what type of resource it should be before using the resource. See the example on how to retrieve the primary color of a wrapped context.
And we also need to make sure that we recycle the array received as well.
That aside we now have the color that we can use, extracted from our wrapped theme. So what I did in my
ThemeResolver was to create a data class
ColorSpec that contains all my custom theme colors that I need for navbars and toolbars. And each time the
ThemeResolver wraps a new theme, I create an instance of
ColorSpec containing that theme's colors, if not already created, and stores that in memory for other parts of the application to use.
The instance is created using similar code as shown above to retrieve all styled attributes.
Supporting API versions less than 29
First off, a big warning: If you only intend to support dark mode for API version 29+ you should specify your color resources with the API-version. Some distributions of android use the
values-night qualifier for other things than night, yes I learned it the hard way, in production.
So make sure to use
values-night-v29 instead to be sure it only affects users with dark mode support.
And now to the hustle, bringing the support to earlier versions. It’s actually quite simple, but with its own quirks.
What you need to do is to use at least
androidx.appcompat:appcompat:1.1.0 in your dependencies. Then you call
AppCompatDelegate.setDefaultNightMode(mode) where mode can be any of the following.
They are quite self-explanatory and I suggest only to use the tree on top. To read more about them read up on androids dark mode site. Since the update to 1.1.0, the
setDefaultNightMode function actually recreates the Activity as well to make the changes apply. And one quirk with this function is that it is not persistent.. sigh.
So we need to store it somehow to make it feel persistent and call the function at startup. (Don’t worry it won’t recreate the Activity, causing an infinite loop, if it hasn’t gotten into its started state yet). Preferably call it inside the application
And there it is, a working dark mode for a multi-flavored, dynamically themed, API 21+, single Activity application. Nope, remember Murphy?
The major quirk
If you for some reason have
ui among the configuration changes specified in your manifest, for yourself to handle. The
setDefaultNightMode will not work as intended. Even if you inside your
onConfigurationChanged function only calls
recreate() it will fail.
The biggest issue is that it’ll look good at first, and I was fooled more than once that I had solved it. But using the app will show a bunch of rendering issues and incorrect theming, so make sure to test.
One more thing that is affected by this flag is when having activities on the back stack, then switching to dark mode, and then backing your way through the back stack. The activities brought back aren’t updated properly so they run on the old uiMode… or at least on API version 28 and 29. It actually works on API version 24. So there is a bug filed on the issue and I haven’t tested thoroughly on all versions to know where the breaking point is, this inconsistency is enough for me to not handle the uiMode recreation myself.
For the curious ones, when setting the dark mode, the AppCompatDelegate actually recreates all Activities currently in the started state. And the activities in the back stack are in a stopped state, but when brought back they enter the started state and in fact, get recreated.
Problems I haven’t been able to fix
- If using a splash screen it will only obey the system setting of dark mode. So for everyone below API version 29, it will probably be light. And for the ones on 29, it will follow the system even if you force your app to be a certain mode.
- The dynamic theming does not change the status bar colors. Since I’m wrapping the theme for fragments and not the Activity it won’t affect those styles. I haven’t looked too deep into it though, it might be possible to actually do it programmatically but it is such a minor issue that other priorities on the app have left them behind.
Thank you for your time!