Dynamic Themes

Create themes that can change over time.

Overview

There are scenarios where you need your themes to automatically update.

One common scenario is styling your app according to system context, such as implementing light and dark modes. Another scenario is when you do not have all available resources ahead of time, and you need to load them from disk (such as loading custom fonts).

Themes built with Unstyled are composable functions and can recompose when one of its properties is updated.

The buildTheme function

Compose Unstyled's buildTheme function itself is not a @Composable function, but the scope it provides for defining your properties is.

This means that you can call any @Composable function, composition locals, or even launch effects in order for using coroutines and load resources that might be blocking.

This enables dynamic themes, which will recompose once their values change.

Implementing Dark Mode

Compose Foundation comes with a isSystemInDarkTheme() function that emits whether the system is in dark mode.

You can use this information to provide a light and dark color scheme respectively when defining your theme:

import androidx.compose.foundation.isSystemInDarkTheme

val colors = ThemeProperty<Color>("colors")
val background = ThemeToken<Color>("background")
val onBackground = ThemeToken<Color>("on_background")

val LightDarkTheme = buildTheme {
    val isDark = isSystemInDarkTheme()
    properties[colors] = if (isDark) {
        // dark palette
        mapOf(
            background to Color(0xFF020617),
            onBackground to Color(0xFFf1f5f9),
        )
    } else {
        // light palette
        mapOf(
            background to Color(0xFFFAFAFA),
            onBackground to Color(0xFF0C0A09),
        )
    }
}

That's it. When the system option changes, the scope of the buildTheme will recompose, updating the colors of your app.

Loading Resources Asynchronously

Since the buildTheme scope is composable, you can load resources that might not be immediately available. For example, loading custom fonts from disk or fetching theme data from a network source:

val typography = ThemeProperty<FontFamily>("typography")
val body = ThemeToken<FontFamily>("body")

val AsyncTheme = buildTheme {
    val scope = rememberCoroutineScope()
    var fontFamily by remember { mutableStateOf<FontFamily?>(null) }
    
    LaunchedEffect(Unit) {
        scope.launch {
            fontFamily = loadCustomFontFromDisk()
        }
    }
    
    properties[typography] = mapOf(
        body to (fontFamily ?: FontFamily.Default)
    )
}

The theme will initially use the default font, then automatically update once the custom font loads.