Supporting User-selected Themes with Jetpack Compose

Jetpack Compose provides excellent support for switching between light and dark themes. If you use the Android Studio dialog to add an Empty Compose Activity, it comes for free in the boilerplate code the IDE adds for you. In ui/Theme.kt
you will see a function similar to this:
@Composable
fun MyTheme(darkTheme: Boolean = isSystemInDarkTheme(), ...) {}
Thanks to the default value of darkTheme
, you don’t have to worry about when the user switches between light and dark theme in system settings––your app will automatically respond to the change. You will then use this function as a sort of wrapper at the top level of your Compose UI, simply omitting the darkTheme
parameter from the function call.
MyTheme {
Screen()
}
But what if your app allows your users to choose a theme from the app’s preferences? It is extremely common for apps to give the user the choice between Light theme, Dark theme, or matching the system theme. It is not uncommon for apps to provide other themes as well — both a Dark theme and a Black theme, or maybe something more extravagant.
So how can we do this in Compose? If you think about it, we really aren’t doing anything fundamentally different in this case––we’re just observing and responding to an app-level preference rather than a system-level preference. So let’s look at how the boilerplate dark theme support works to get an idea of where to start.
First notice that when we called the MyTheme
function we simply omitted the darkTheme
parameter. We could have called the function like this:
MyTheme(darkTheme = false) {
Screen()
}
But instead we delegate the value to the function isSystemInDarkTheme()
. If we examine isSystemInDarkTheme()
, we see that it is a composable function that returns a boolean. Since it is able to force recomposition when the user changes their system setting, we know that it must be observing a mutable State. Conceptually, something like this:
@Composable
fun isSystemInDarkTheme(): Boolean {
val darkTheme by remember { mutableStateOf(false) }
return darkTheme
}
In reality, of course, rather than a boolean literal false
the function is observing the system dark theme setting. But conceptually this is what is going on behind the curtain. Changing the value of the State will force a recomposition, which cascades throughout the entire visible UI as a change in the app theme.
So in order to support user-selected themes we need to tweak our `MyTheme`function to accept an enum Theme parameter instead of a boolean and set its default value to a composable function similar to `isSystemInDarkTheme` which observes the current app theme. Let’s unpack that a bit.
Suppose we have an enum for our app theme that looks like this:
enum class Theme {
Light,
Dark,
Auto
}
Instead of passing isSystemInDarkTheme()
as a parameter to our theme, we’ll pass a function that observes our current theme, like this:
@Composable
fun MyTheme(theme: Theme = currentAppTheme(), ...) {}
@Composable
fun currentAppTheme(): Theme {
val theme by themeFlow.collectAsState()
return theme
}
themeFlow
here is a StateFlow
representing the current app theme. It could come from a ViewModel, from a DataStore, or from some other abstracted data source. The crucial part is that it is observable—whenever the user changes their theme preference in your app, the new value must be emitted by this observable source.
Note that if it is a pure Flow
(like from a DataStore) then the call will require an initial value as in themeFlow.collectAsState(initial = Theme.Auto).
It could also be a LiveData
, in which case you would call themeLiveData.observeAsState().
That’s all there is to it! Your compose UI will now automatically change themes whenever the user changes their theme preference.
In the above implementation, you could have as many themes as you want — a Red theme, a Blue theme, etc. But note that in this specific example where our only themes are Light, Dark, and Auto, we can easily combine this with isSystemInDarkTheme()
for the Auto theme.
@Composable
fun isAppDarkTheme(): Boolean {
val theme by themeFlow.collectAsState()
return when (theme) {
Light -> false
Dark -> true
Auto -> isSystemInDarkTheme()
}
}
Or we could of course combine this with an additional custom theme:
enum class Theme {
Light,
Dark,
Auto,
Rainbow
}@Composable
fun currentAppTheme(): Theme {
val theme by themeFlow.collectAsState()
return when (theme) {
Auto -> {
if (isSystemInDarkTheme()) {
Dark
} else {
Light
}
}
else -> theme
}
}
Follow for more on best practices in Kotlin and Android development.