Dark Theme Switch in Jetpack Compose with CompositionLocal

Sibel Nalop
4 min readJun 30, 2023

--

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 its current value.
  • staticCompositionLocalOf: Unlike compositionLocalOf, reads of a staticCompositionLocalOf are not tracked by Compose. Changing the value causes the entirety of the content lambda where the CompositionLocal is provided to be recomposed, instead of just the places where the current value is read in the Composition.

For more information about when to choose staticCompositionLocalOf or compositionLocalOf check out the documentation. In short: “If the value provided to the CompositionLocal is highly unlikely to change or will never change, use staticCompositionLocalOf.

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 😊

--

--

Sibel Nalop

Programming Android apps since 2022. Always curious, I love to learn and create new things.