'Jetpack Compose: How to change theme from light to dark mode programmatically onClick

TL;DR change the theme and recompose the app between light and dark themes onClick.

Hello! I have an interesting issue I have been struggling to figure out and would love some help. I am trying to implement a settings screen which lets the user change the theme of the app (selecting Dark, Light, or Auto which matches system setting).

I am successfully setting the theme dynamically via invoking the isSystemInDarkTheme() function when choosing the color palette, but am struggling to recompose the app between light and dark themes on the click of a button.

My strategy now is to create a theme model which hoists the state from the settings component which the user actually chooses the theme in. This theme model then exposes a theme state variable to the custom theme (wrapped around material theme) to decide whether to pick the light or dark color palette. Here is the relevant code -->

Theme

@Composable
fun CustomTheme(
themeViewModel: ThemeViewModel = viewModel(),
content: @Composable() () -> Unit,
) {
   val colors = when (themeViewModel.theme.value.toString()) {
       "Dark" -> DarkColorPalette
       "Light" -> LightColorPalette
       else -> if (isSystemInDarkTheme()) DarkColorPalette else LightColorPalette
   }

   MaterialTheme(
       colors = colors,
       typography = typography,
       shapes = shapes,
       content = content
   )
   }

Theme model and state variable

class ThemeViewModel : ViewModel() {
private val _theme = MutableLiveData("Auto")
val theme: LiveData<String> = _theme

fun onThemeChanged(newTheme: String) {
    when (newTheme) {
        "Auto" -> _theme.value = "Light"
        "Light" -> _theme.value = "Dark"
        "Dark" -> _theme.value = "Auto"
    }
}
}

Component (UI) code

@Composable
fun Settings(
   themeViewModel: ThemeViewModel = viewModel(),
) {
   ...
   val theme: String by themeViewModel.theme.observeAsState("")
   ...
   ScrollableColumn(Modifier.fillMaxSize()) {
       Column {
        ...
        Card() {
            Row() {
                Text(text = theme,
                    modifier = Modifier.clickable(
                        onClick = {
                            themeViewModel.onThemeChanged(theme)
                        }
                    )
                )
            }
        }
   }

Thanks so much for your time and help! ***I have elided some code here in the UI component, it is possible I have left out some closure syntax in the process.



Solution 1:[1]

One possibility, shown in the Jetpack theming codelab, is to set the darkmode via input parameter, which ensures the theme will be recomposed when the parameter changes:

@Composable
fun CustomTheme(
  darkTheme: Boolean = isSystemInDarkTheme(),
  content: @Composable () -> Unit
) {
  MaterialTheme(
    colors = if (darkTheme) DarkColors else LightColors,
    content = content
  )
}

In your mainActivity you can observe changes to your viewModel and pass them down to your customTheme:

val darkTheme = themeViewModel.darkTheme.observeAsState(initial = true)

CustomTheme(darkTheme.value){
//yourContent
}

This way your compose previews can simply be styled in dark theme:

@Composable
private fun DarkPreview() {
    CustomTheme(darkTheme = true) {
        content
    }
}

Solution 2:[2]

In case you want a button/switch to change the theme and make it persistent as setting, you can also achieve this by using Jetpack DataStore (recommended) or SharedPreferences, get your theme state in MainActivity and pass it to your Theme composable, and wherever you want to modify it.

You can find a complete working example with SharedPreferences in this GitHub repo.

This example is using a Singleton and Hilt for dependencies and is valid for all the preferences you want store.

Solution 3:[3]

Based on the docs, the official way to handle theme changes triggered by a user's action (ie. choice of a theme other than the system one through a custom built setting) is to use

AppCompatDelegate.setDefaultNightMode()

This call alone will take care of most things, including restarting any activity (thus, recomposing). For this to work, we need:

  • The Activity which calls setContent to extend AppCompatActvity
  • The user's choice to be persisted and applied at each start of the app (through AppCompatDelegate)
  • To define whether dark mode is enabled, your CustomTheme should also consider the value of the user's defaultNightMode preference:
@Composable
fun CustomTheme(
  isDark: Boolean = isNightMode(),
  content: @Composable () -> Unit
) {
  MaterialTheme(
    colors = if (darkTheme) DarkColors else LightColors,
    content = content
  )
}

@Composable
private fun isNightMode() = when (AppCompatDelegate.getDefaultNightMode()) {
    AppCompatDelegate.MODE_NIGHT_NO -> false
    AppCompatDelegate.MODE_NIGHT_YES -> true
    else -> isSystemInDarkTheme()
}

this is nice to have as it avoids the need to get this value in an Activity just to pass it to the theme with CustomTheme(isDark = isDark).

This article goes through all of the above providing more details.

Solution 4:[4]

It might not be the recommended way, but one option is to use the recreate method (available since API level 11).

In order to use it outside of the activity and within your composable, you could pass the function call. Taking your code as a basis

class SomeActivity {
    override fun onCreate(savedInstanceState: Bundle?) {
        ...
        setContent {
            ...
            themeViewModel.onRecreate = { recreate() }

            CustomTheme(themeViewModel) {
                ...
            }
        }
    }
}

In your viewModel

class ThemeViewModel: ViewModel() {

    lateinit var onRecreate: () -> Unit

    private val _theme = MutableLiveData("Auto")
    val theme: LiveData<String> = _theme
    
    fun onThemeChanged(newTheme: String) {
        when (newTheme) {
            "Auto" -> _theme.value = "Light"
            "Light" -> _theme.value = "Dark"
            "Dark" -> _theme.value = "Auto"
        }
        onRecreate
    }
}

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1 jns
Solution 2
Solution 3
Solution 4 flyakite