Dynamic Theming in Jetpack Compose with Jetpack DataStore and View Models

TonyGnk
6 min readMay 12, 2024

--

Introduction

This article explores the implementation of dynamic theming in a Jetpack Compose application using Jetpack DataStore and View Models.

Dynamic theming allows users to personalize the app’s look and feel by letting them choose between light, dark, or system themes. It goes a step further with Material You by enabling dynamic colors, which seamlessly adapt the UI based on the user’s wallpaper.

We’ll delve into how DataStore helps you persistently store user preferences, while View Models provide a clean way to manage and expose this data within your composables, allowing for seamless theme adjustments based on user choices.

Final Result

Dependencies

In your `build.gradle.kts` file, add the following dependencies:

dependencies {
implementation("androidx.datastore:datastore-preferences:1.1.1")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0")
}

Check latest version here

Setup DataStore

1. Creating a DataStore Repository

This section explains how to create a StoreRepository class to manage theme preferences using Jetpack DataStore.

// Key for storing dynamic theme preference
const val IS_DYNAMIC_THEME = "is_dynamic_theme"
const val THEME_TYPE = "theme_type"

enum class ThemeType { SYSTEM, LIGHT, DARK }

The class defines constants for storing theme preference keys (IS_DYNAMIC_THEME, THEME_TYPE), and a ThemeType enum representing different theme options (system, light, dark).

class StoreRepository(private val dataStore: DataStore<Preferences>) {
private companion object {
val isDynamicTheme = booleanPreferencesKey(IS_DYNAMIC_THEME)
val themeType = intPreferencesKey(THEME_TYPE)
}

val isDynamicThemeFlow: Flow<Boolean> = dataStore.data.map { preferences ->
preferences[isDynamicTheme] ?: false
}

val themeTypeFlow: Flow<ThemeType> = dataStore.data.map { preferences ->
ThemeType.entries[preferences[themeType] ?: 0]
}

fun setDynamicTheme(value: Boolean, scope: CoroutineScope) {
scope.launch {
dataStore.edit { preferences ->
preferences[isDynamicTheme] = value
}
}
}

fun setThemeType(value: ThemeType, scope: CoroutineScope) {
scope.launch {
dataStore.edit { preferences ->
preferences[themeType] = value.ordinal
}
}
}
}

It also provides methods to retrieve theme preferences (isDynamicThemeFlow, themeTypeFlow) as flows, and functions to update theme preferences (setDynamicTheme, setThemeType) using coroutines.

Note: We take the scope as parameter and use it once without having to call the extra lamda launch every time we have to call it

2. Initializing DataStore in Application Class

This class uses the dataStore by preferencesDataStore delegate to conveniently create a DataStore instance named “app_preferences”. The onCreate method initializes the storeRepository with this DataStore instance.

private val Context.dataStore by preferencesDataStore("app_preferences")

class ApplicationSetup : Application() {
lateinit var storeRepository: StoreRepository

override fun onCreate() {
super.onCreate()
storeRepository = StoreRepository(dataStore)
}
}

Finally, we update the AndroidManifest.xml to set the application class name to ApplicationSetup.

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">

<application
android:name=".ApplicationSetup"
...
>
</application>

</manifest>

Observing Theme Preferences in Theme.kt

1. Creating View Models and Observing DataStore

We create a ModelProvider object that provides a centralized factory for creating view models. The viewModelFactory lambda allows us to specify how view models are created.

object ModelProvider {
val Factory = viewModelFactory {
initializer {
//Not yet implemented
ThemeModel(appViewModelProvider().storeRepository)
}

initializer {
//Not yet implemented
SettingsModel(appViewModelProvider().storeRepository)
}
}
}


fun CreationExtras.appViewModelProvider(): ApplicationSetup =
(this[AndroidViewModelFactory.APPLICATION_KEY] as ApplicationSetup)

The appViewModelProvider function is a helper function used within the factory to safely retrieve the ApplicationSetup instance from the context.

2. Create the Theme View Model

class ThemeModel(storeRepository: StoreRepository) : ViewModel() {
// Observe the DataStore flow for dynamic theme preference
val isDynamic: StateFlow<Boolean> =
storeRepository.isDynamicThemeFlow.map { it }.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = true
)

// Observe the DataStore flow for theme type preference
val isDarkTheme: StateFlow<ThemeType> =
storeRepository.themeTypeFlow.map { it }.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = ThemeType.SYSTEM
)
}

3. Update the Theme.kt

We retrieve the ThemeModel instance using the viewModel composable with the ModelProvider.Factory.

We then use collectAsState on both isDynamic and isDarkTheme StateFlow properties within the view model. This allows us to observe the latest theme preference values within the composable function’s lifecycle.

@Composable
fun MyTheme(content: @Composable () -> Unit) {
val model: ThemeModel = viewModel(factory = ModelProvider.Factory)

val isDynamic by model.isDynamic.collectAsState()
val isDarkTheme = when (model.isDarkTheme.collectAsState().value) {
ThemeType.SYSTEM -> isSystemInDarkTheme()
ThemeType.LIGHT -> false
ThemeType.DARK -> true
}

val colorScheme = when {
isDynamic && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (isDarkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(
context
)
}
isDarkTheme -> darkScheme
else -> lightScheme
}

// For status bar
val view = LocalView.current
if (!view.isInEditMode) {
SideEffect {
val window = (view.context as Activity).window
window.navigationBarColor = colorScheme.surfaceContainerHighest.toArgb()
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !isDarkTheme
WindowCompat.getInsetsController(window, view).isAppearanceLightNavigationBars = !isDarkTheme
}
}
MaterialTheme(colorScheme = colorScheme, typography = Typography, content = content)
}

Building the Settings Screen

1. Creating the Settings ViewModel and Data Classes

The SettingsModel class manages settings data and interacts with the DataStore (storeRepository) to update theme preferences.

It holds properties for dialog visibility (dialogVisibly), current theme (themeType), and dynamic theme preference (isDynamic).

class SettingsModel(private val storeRepository: StoreRepository) : ViewModel() {

val dialogVisibly = MutableStateFlow(false)

val themeType: StateFlow<ThemeTypeState> =
storeRepository.themeTypeFlow.map { ThemeTypeState(it) }.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = ThemeTypeState(ThemeType.SYSTEM)
)

val isDynamic: StateFlow<Boolean> =
storeRepository.isDynamicThemeFlow.map { it }.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = false
)

fun setDialogVisibility(value: Boolean) {
dialogVisibly.value = value
}

fun updateIsDynamicTheme() {
storeRepository.setDynamicTheme(!isDynamic.value, viewModelScope)
}

fun updateThemeType(themeType: ThemeType) {
storeRepository.setThemeType(themeType, viewModelScope)
}
}

Also it provides functions to update these and persist changes in DataStore.

//Holds selected theme and theme options.
data class ThemeTypeState(
val selectedRadio: ThemeType,
val radioItems: List<RadioItem> = listOf(
RadioItem(ThemeType.SYSTEM, "System"),
RadioItem(ThemeType.LIGHT, "Light"),
RadioItem(ThemeType.DARK, "Dark"),
),
)

//Simple holder for theme type and its title (for radio buttons).
data class RadioItem(val value: ThemeType, val title: String)

2. Building the Settings Screen with Composable Functions

The main SettingScreen retrieves data from the SettingsModel using viewModel and collectAsState to access state flows for visibility, theme type, and dynamic theme preference. It then conditionally renders the theme selection dialog and displays a column containing an app bar text and the ThemeAndDynamicTile composable.

@Composable
@Preview
fun SettingScreen() {
val model: SettingsModel = viewModel(factory = ModelProvider.Factory)
val dialogVisibly by model.dialogVisibly.collectAsState()
val isDynamic by model.isDynamic.collectAsState()
val themeType by model.themeType.collectAsState()

Surface(
//...
) {
if (dialogVisibly) ThemeDialog(model, themeType)

Column(
//...
) {
AppBarText() // Assume this displays an app bar title
ThemeAndDynamicTile(model, isDynamic)
}
}
}

The ThemeAndDynamicTile composable displays theme and dynamic theme options. It uses isDynamic to conditionally display the toggle for dynamic theme selection.

@Composable
fun ThemeAndDynamicTile(model: SettingsModel, isDynamic: Boolean) {
Column {
Text( text = "Appearance" )
Column {
ListItem(
headlineContent = { Text( "Theme" ) },
trailingContent = {
Icon(
imageVector = Icons.AutoMirrored.Default.KeyboardArrowRight,
contentDescription = "Change App Theme"
)
},
modifier = Modifier
.clickable { model.setDialogVisibility(true) }
)
HorizontalDivider()
// Hide Dynamic Color option for pre-Android 12 devices
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
ListItem(
headlineContent = { Text( "Dynamic Color" ) },
trailingContent = {
Switch(
checked = (isDynamic),
onCheckedChange = { model.updateIsDynamicTheme() }
)
},
modifier = Modifier
.clickable { model.updateIsDynamicTheme() }
)
}
}
}

The ThemeDialog composable displays a dialog for choosing the app theme. It uses the RadioButtons composable to present available theme options as radio buttons with corresponding text labels.

Lastly, RadioButtons loops through theme options, presenting them with radio buttons and updating the chosen theme in the model.

@Composable
fun ThemeDialog(model: SettingsModel, state: ThemeTypeState) {
AlertDialog(
onDismissRequest = { model.setDialogVisibility(false) },
title = { Text(text = "Choose Theme") },
text = { RadioButtons(model, state) },
confirmButton = {
Button( onClick = { model.setDialogVisibility(false) }
) { Text("Confirm") }
}
)
}

@Composable
fun RadioButtons(model: SettingsModel, state: ThemeTypeState) {
Column {
state.radioItems.forEach {
Row(
modifier = Modifier.selectable(
selected = (it.value == state.selectedRadio),
onClick = { model.updateThemeType(it.value) }
)
) {
RadioButton(
selected = (it.value == state.selectedRadio),
onClick = { model.updateThemeType(it.value) }
)
Text(text = it.title)
}
}
}
}

That’s it!

With DataStore, you can seamlessly manage theme preferences and provide a dynamic user experience. This approach allows users to personalize their app’s look and feel effortlessly, enhancing their overall enjoyment.

Photo by Kelly Sikkema on Unsplash

--

--