Android Q introduces dark themes. Or night mode? No idea. Anyways, it is here and can be helpful with using applications in dark environments or with bringing back that sweet Winamp skins vibe.
Implementing dark themes is surprisingly deep and affects the whole application. At times it feels like a redesign. I’ve tried to collect steps we’ve made to introduce the dark theme in the Juno rider application and make a (kind of) comprehensive guide. Let’s jump in!
Switching
It is important to start with this step to actually take a look at the dark theme.
AppCompatDelegate.setDefaultNightMode
is our friend here. Use AppCompat 1.1.0+ —
earlier versions do not work well with theme switching (activities don’t restart,
themes are not applied to the navigation bar).
- Android < Q
- Show the in-application switch. Save theme on each switch.
- Use
AppCompatDelegate.MODE_NIGHT_NO
andAppCompatDelegate.MODE_NIGHT_YES
. - In
Application.attachBaseContext
read saved theme and switch to it.
- Android ≥ Q
- Do not show in-application switch. The system one is enough.
- In
Application.attachBaseContext
switch toAppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
.
That’s it! From now on it is possible to use resources with the night
modifier
(values-night
, drawable-night
, etc). Unfortunately switching recreates
activities, like a regular configuration change.
⚠️ I ignore
AppCompatDelegate.MODE_NIGHT_AUTO_BATTERY
and go against Google suggestions of showing a gazillion of switches. At least macOS and Windows work without ad-hoc switches.
Colors
In the ideal scenario it is enough to re-declare colors in values-night/colors.xml
.
Unfortunately it is not always so simple. It might be important
to maintain brand colors but replace them with vibrant variants
for small elements like underlines and links. Or keep colors across themes for particular icons.
To resolve this I’ve found an approach of opting out from color changes between themes. We’ll declare two sets of colors — themed and themeless. Themed ones should be used by default but can be replaced with themeless variants to opt-out from theming. The colors naming gives mnemonics as a bonus — before using a color a developer should explicitly choose whether it should be themed or not.
<!-- values/colors.xml -->
<color name="themeless_black">#000000</color>
<color name="themeless_white">#ffffff</color>
<color name="themed_black">@color/themeless_black</color>
<color name="themed_white">@color/themeless_white</color>
<!-- values-night/colors.xml -->
<color name="themed_black">@color/themeless_white</color>
<color name="themed_white">@color/themeless_black</color>
Themes
There is a good chance that system status and navigation bars should have
different colors between themes. *BarColor
and windowLight*Bar
do the trick.
Unfortunately these attributes are available from different API versions
so we’ll use a known trick with Base.*
themes.
<!-- values/bools.xml -->
<bool name="theme_light">true</bool>
<!-- values-night/bools.xml -->
<bool name="theme_light">false</bool>
<!-- values/themes.xml -->
<style name="Base.Theme.Local" parent="Theme.AppCompat.Light.NoActionBar"/>
<style name="Theme.Local" parent="Base.Theme.Local"/>
💡 Notice that I’m not using
Theme.AppCompat.DayNight
.DayNight
switchesTheme.AppCompat
attributes between themes but it might be useless if attributes are already re-declared in the application-level theme.
<!-- values-v23/themes.xml -->
<style name="Base.Theme.Local.v23">
<item name="android:statusBarColor">@color/themed_white</item>
<item name="android:windowLightStatusBar">@bool/theme_light</item>
</style>
<style name="Theme.Local" parent="Base.Theme.Local.v23"/>
<!-- values-v27/themes.xml -->
<style name="Base.Theme.Local.v27" parent="Base.Theme.Local.v23">
<item name="android:navigationBarColor">@color/themed_white</item>
<item name="android:windowLightNavigationBar">@bool/theme_light</item>
</style>
<style name="Theme.Local" parent="Base.Theme.Local.v27"/>
Icons
Local
Avoid using bitmaps like a plague! Well, it makes sense to use bitmaps for illustrations but icons ideally should be in vector. Doing so allows to use colors directly in paths and brings automatic theme management.
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="@color/themed_black"
android:pathData="drawing-instructions"
/>
</vector>
Remote
Icons fetched from a backend are most likely bitmaps. It is possible to tint them locally but I would suggest avoiding doing so. Usually resources are placed on remote servers to achieve flexibility. One day remote icons are monochrome with transparent areas, the next day they are colorful and photo-realistic. Tinting will turn the latter ones into colored silhouettes.
A better solution is finding a middle-ground — remote icons should fit both light and dark themes.
Lottie Animations
Unfortunately Lottie animations do not use Android color resources. Colors are inlined in JSON files. The good thing is — it is possible to change colors in runtime using dynamic properties. In fact, I would advise to do so all the time, no matter if there is a dark theme or not. Colors change all the time but animation files are not changed with the same frequency.
The implementation is actually a breeze. The awkward part is finding
correct KeyPath
combinations — it is better to do that with a design team.
enum AnimationComponent(val path: KeyPath, @ColorRes val colorRes: Int) {
Circle(KeyPath("circle-group-42"), R.color.themed_black),
}
AnimationComponent.values().forEach { component ->
@ColorInt val componentColor = context.color(component.color)
animationView.addValueCallback(component.path, LottieProperty.COLOR) { componentColor }
}
Elevation
Elevations look good in light themes but are essentially invisible in dark ones. The workaround is described in the Material Design spec. In short — it is proposed to use overlays in addition to shadows for dark themes. The overlay changes its transparency depending on the current elevation. When the overlay is the white color the elevated surface becomes lighter. Neat!
The technical solution is available as well.
Material Components
implement
elevation overlays in components like NavigationView
, TabLayout
, Toolbar
and more.
Following attributes declared in the theme will activate overlays.
<!-- values/bools.xml -->
<bool name="theme_dark">false</bool>
<!-- values-night/bools.xml -->
<bool name="theme_dark">true</bool>
<!-- values/themes.xml -->
<item name="colorSurface">@color/themed_white</item>
<item name="elevationOverlayEnabled">@bool/theme_dark</item>
<item name="elevationOverlayColor">@color/themed_black</item>
What about custom components? Actually, it is handled by Material Components as well.
There is a Drawable
subclass called MaterialShapeDrawable
.
It is possible to supply it with the elevation to achieve the desired effect.
Actually this is what Material Components use under the hood.
class Surface(context: Context, attributes: AttributeSet) : FrameLayout(context, attributes) {
init {
background = MaterialShapeDrawable.createWithElevationOverlay(context, elevation)
}
override fun setElevation(elevation: Float) {
super.setElevation(elevation)
MaterialShapeUtils.setElevation(this, elevation)
}
}
📖 Take a look at the
ShapeAppearanceModel
to make cards with rounded corners, triangle edges and more via customEdgeTreatment
andCornerTreatment
implementations.
What about gradients and color transitions on elevated surfaces?
ElevationOverlayProvider
helps with that. This class is used under the hood
of MaterialShapeDrawable
. The following extension is a shortcut.
@ColorInt
fun Context.surfaceColor(elevation: Float): Int {
return ElevationOverlayProvider(this).compositeOverlayWithThemeSurfaceColorIfNeeded(elevation)
}
Talking about surface colors — let’s backtrack a bit.
Which color should be used for the elevationOverlayColor
attribute?
#ffffff
comes to mind. Unfortunately this is not always what
a design team wants. Most likely there will be a defined
color S
for surfaces and a color ES
for elevated surfaces.
The math behind ElevationOverlayProvider
is a bit tricky,
especially when it comes to ARGB colors with defined alpha channel.
The solution here is the color subtraction. To get a color for elevationOverlayColor
subtract S
from ES
. For example, using the surface color
#000000
■
and the elevated surface color
#5b5f65
■
we’ll get
#e6f0ff
■.
Using the resulting color is usually close enough to the design vision without compromising
the ability to automatically change surface color depending on the current elevation.
Oh, please don’t subtract colors manually,
use the special calculator.
📖 Since I’m horrible at explaining color math — refer to wonderful Alpha Compositing and Color Spaces articles for details.
HTML
Remote
From Chrome 76 it is possible to use the
prefers-color-scheme
CSS media feature. It allows switching website themes using OS-level settings.
This blog supports it!
Technically it is implemented like this:
:root {
--color-background: #ffffff;
}
@media (prefers-color-scheme: dark) {
:root {
--color-background: #000000;
}
}
body {
background-color: var(--color-background);
}
From Android 5.0 the WebView
is regularly updated
so there is a good chance that it will support this feature.
Chrome Custom Tabs
might work a bit better.
Of course, this kind of approach will require implementation from a frontend team behind web pages.
Local
There is a good chance that some web pages are rendered locally on a device. Good examples are the OSS license screen and the HTML-formatted content from a backend.
Of course it is possible to use the approach described above for remote pages
but until the WebView
is updated everywhere to the Chrome 76-backed version
it is possible to use templates.
body {
background-color: {{color_background}};
}
This is a Mustache-like template.
{{color_background}}
is replaced in runtime with a local color.
enum class CssColor(val mask: String, @ColorRes res: Int) {
Background("color_background", R.color.themed_white),
}
val css = CssColor.values().fold(cssTemplate) { css, cssColor ->
css.replace("{{${cssColor.mask}}}", "#${context.color(cssColor.res).colorHexRgba()}")
}
fun Int.colorHexRgba() = String.format("%08x", shl(8) + ushr(24))
💡 Notice the
colorHexRgba
extension. It is not possible to use Android colors as-is since HTML uses the RGBA notation while Android uses the ARGB one.
Maps
Both regular and lite
MapView
support styling via
MapStyleOptions
.
In fact, it is possible to place the light style in raw/map_style.json
and
the dark one in raw-night/map_style.json
. This approach gives automatic
map style switching via referencing R.raw.map_style
.
Using static maps
is more awkward.
The styling is still available
but since the style is sent via an HTTP request it means that domain-level entities of the application
will know about the presentation-level characteristic. This is an unpleasant
coupling. I suggest to migrate to the lite MapView
— it covers basically everything
the static map provides and renders it on a device instead of making network calls.
Also — it is free!
MOAR
Fullscreen views, color change animations, dealing with transparency and a lot of fine-tuning. The dark theme integration becomes a marathon, not a sprint. Having something like a design system definitely helps.
Is it worth it? I think so. It sheds a light on hacks and forces to make universal decisions. This is a good thing. Ah, yes, it looks nice!
📖 The design team have published their own view on the process. Feel free to take a look!
The title is a reference to the Midnight in a Perfect World track.