Dark Theme Switch in Jetpack Compose with CompositionLocal
As app developers, we should provide users a settings option to switch the theme (light/dark mode) inside our android apps. With Compose being still fairly new in 2022, there wasn’t the same abundance of “how-tos” as there is for the View
-based UI. So at first, I wasn’t certain how to handle that. But looking closer at the Theme
of a Compose project and the documentation of the isSystemInDarkTheme()
function, however, I found a good lead:
“It is also recommended to provide users accessible overrides […] You should provide the current theme value in a CompositionLocal or similar.”
I didn’t know what a CompositionLocal
was, and maybe you don’t know yet either, so let’s take a look.
CompositionLocal
The Google guide “Locally scoped data with CompositionLocal” sums it up as follows:
“Compose offers
CompositionLocal
which allows you to create tree-scoped named objects that can be used as an implicit way to have data flow through the UI tree.”
And: “CompositionLocal
is what the Material theme uses under the hood.”
In other words, to provide data to the composable tree without having to pass it as parameters to each composable function, we can use CompositionLocal
objects that pass their data implicitly. Nice 😎
Implementation
To create a CompositionLocal
for our light or dark theme value, we first create a CompositionLocal
with a default value. It will be a global object, so you can decide freely where it makes the most sense to declare it inside your app. (I put mine inside the ui.theme
package.)
// Define a CompositionLocal global object with a default value.
// This instance can be accessed by all composables in the app.
val LocalTheme = compositionLocalOf { false }
There are two options for creating a CompositionLocal
:
compositionLocalOf
: Changing the value provided during recomposition invalidates only the content that reads itscurrent
value.staticCompositionLocalOf
: UnlikecompositionLocalOf
, reads of astaticCompositionLocalOf
are not tracked by Compose. Changing the value causes the entirety of thecontent
lambda where theCompositionLocal
is provided to be recomposed, instead of just the places where thecurrent
value is read in the Composition.
For more information about when to choose
staticCompositionLocalOf
orcompositionLocalOf
check out the documentation. In short: “If the value provided to theCompositionLocal
is highly unlikely to change or will never change, usestaticCompositionLocalOf
.”
Even though we only hold a single value (Boolean) in the CompositionLocal, I chose to have a class holding that value so that I could use the parameter name for better readability later on.
data class DarkTheme(val isDark: Boolean = false)
val LocalTheme = compositionLocalOf { DarkTheme() }
// Example of usage in code later on:
if (LocalTheme.current.isDark) //...//
// Instead of declaring LocalTheme as "compositionLocalOf { false }":
if (LocalTheme.current) //...//
The named parameter provides further information about what I am actually checking for. But the version without the class works too, of course— but you’d have to remember what it stands for because it will only read true or false.
In the main activity, we use a CompositionLocalProvider
that provides the current value to our CompositionLocal
. (How to set that value will be shown in the next segment.) Wrap it around the MyAppTheme
Composable (your standard theme Composable will be named after your app — check your Theme.kt
file). Use the LocalTheme.current.isDark
value as argument for the parameter darkTheme
of MyAppTheme
.
// MainActivity inside onCreate():
setContent {
val darkTheme = // current theme value
CompositionLocalProvider(LocalTheme provides darkTheme) {
MyAppTheme(darkTheme = LocalTheme.current.isDark) {
// content
}
}
}
For a more in-depth look at
CompositionLocal
, you can check out Neil Davies’s article “Jetpack Compose CompositionLocal -What You Need to Know”.
Setting and Getting the current theme value
For this step, you can follow “Handling user selectable themes in Jetpack Compose” by Francesc Vilarino Guell on how to set up the theme value preservation with SharedPreferences and a simple UI. So read that first and then come back here because I only made a few changes to use it with CompositionLocal
.
Note: If you don’t use any dependency injection framework, you can get your ViewModel with the
viewModel()
function to access the user setting for the theme.
// Based on the mentioned article
// Changes to use CompositionLocal
// MainActivity inside onCreate():
setContent{
val viewModel: MainViewModel = viewModel()
val themeUserSetting by viewModel.themeUserSetting.collectAsState()
val darkTheme = when (themeUserSetting) {
AppTheme.MODE_AUTO -> DarkTheme(isSystemInDarkTheme())
AppTheme.MODE_DAY -> DarkTheme(false)
AppTheme.MODE_NIGHT -> DarkTheme(true)
}
CompositionLocalProvider(LocalTheme provides darkTheme) {
// Theme and content.
}
}
Your app theme will now be selectable by the user from inside your app 🎉
As you could see in Francesc Vilarino Guell’s article, this can be done without using CompositionLocal
. So, why use it?
Remember, the cool thing about composition locals was that they provide data to the composable tree implicitly. That way, we can now use the provided value inside any Composable in the composable tree inside with our LocalTheme
. You can call LocalTheme.current.isDark
to set an icon, for example:
IconButton(/* switch theme */) {
Icon(painter = painterResource(
if (LocalTheme.current.isDark) R.drawable.ic_round_light_mode_24
else R.drawable.ic_round_dark_mode_24),
contentDescription = "switch theme"
)
}
And that’s it. Have fun coding 😊