From bd508c477a975f5fbc0b33f048b6aa338b71e4e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Mlynari=C4=8D?= Date: Wed, 10 Apr 2024 11:51:39 +0200 Subject: [PATCH] Optimized handling of dark/light mode - Activity handles uiMode config change to prevent re-creating the whole activity - Wrap uiState logic into its classes - Optimize startup to prevent recomposing twice on startup - Optimize startup to call enableEdgeToEdge only once or when change occurs Change-Id: I6f7a48b3b6ce9b55db4ab2ec1770583028e9bc50 --- app/src/main/AndroidManifest.xml | 1 + .../samples/apps/nowinandroid/MainActivity.kt | 151 +++++++----------- .../nowinandroid/MainActivityViewModel.kt | 39 ++++- .../apps/nowinandroid/util/UiExtensions.kt | 49 ++++++ 4 files changed, 146 insertions(+), 94 deletions(-) create mode 100644 app/src/main/kotlin/com/google/samples/apps/nowinandroid/util/UiExtensions.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 0b0482c13..961b0bec6 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -39,6 +39,7 @@ diff --git a/app/src/main/kotlin/com/google/samples/apps/nowinandroid/MainActivity.kt b/app/src/main/kotlin/com/google/samples/apps/nowinandroid/MainActivity.kt index d73c4bd63..5b10f7ec2 100644 --- a/app/src/main/kotlin/com/google/samples/apps/nowinandroid/MainActivity.kt +++ b/app/src/main/kotlin/com/google/samples/apps/nowinandroid/MainActivity.kt @@ -22,12 +22,9 @@ import androidx.activity.SystemBarStyle import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.activity.viewModels -import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass -import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue @@ -38,27 +35,22 @@ import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import androidx.metrics.performance.JankStats import androidx.tracing.trace -import com.google.samples.apps.nowinandroid.MainActivityUiState.Loading -import com.google.samples.apps.nowinandroid.MainActivityUiState.Success import com.google.samples.apps.nowinandroid.core.analytics.AnalyticsHelper import com.google.samples.apps.nowinandroid.core.analytics.LocalAnalyticsHelper import com.google.samples.apps.nowinandroid.core.data.repository.UserNewsResourceRepository import com.google.samples.apps.nowinandroid.core.data.util.NetworkMonitor import com.google.samples.apps.nowinandroid.core.data.util.TimeZoneMonitor import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme -import com.google.samples.apps.nowinandroid.core.model.data.DarkThemeConfig -import com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand import com.google.samples.apps.nowinandroid.core.ui.LocalTimeZone import com.google.samples.apps.nowinandroid.ui.NiaApp import com.google.samples.apps.nowinandroid.ui.rememberNiaAppState +import com.google.samples.apps.nowinandroid.util.isSystemInDarkTheme import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.flow.collect -import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.launch import javax.inject.Inject -private const val TAG = "MainActivity" - @OptIn(ExperimentalMaterial3WindowSizeClassApi::class) @AndroidEntryPoint class MainActivity : ComponentActivity() { @@ -82,60 +74,64 @@ class MainActivity : ComponentActivity() { lateinit var userNewsResourceRepository: UserNewsResourceRepository val viewModel: MainActivityViewModel by viewModels() - override fun onCreate(savedInstanceState: Bundle?) { val splashScreen = installSplashScreen() super.onCreate(savedInstanceState) - var uiState: MainActivityUiState by mutableStateOf(Loading) + // We keep this as a mutable state, so that we can track changes inside the composition. + // This allows us to react to dark/light mode changes. + var themeSettings by mutableStateOf( + ThemeSettings( + darkTheme = resources.configuration.isSystemInDarkTheme, + androidTheme = false, + disableDynamicTheming = true, + ), + ) // Update the uiState lifecycleScope.launch { lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { - viewModel.uiState - .onEach { uiState = it } - .collect() + combine( + isSystemInDarkTheme(), + viewModel.uiState, + ) { systemDark, uiState -> + ThemeSettings( + darkTheme = uiState.shouldUseDarkTheme(systemDark), + androidTheme = uiState.shouldUseAndroidTheme, + disableDynamicTheming = uiState.shouldDisableDynamicTheming, + ) + } + .distinctUntilChanged() + .collect { newThemeSettings -> + trace("niaEdgeToEdge") { + // Turn off the decor fitting system windows, which allows us to handle insets, + // including IME animations, and go edge-to-edge. + // This is the same parameters as the default enableEdgeToEdge call, but we manually + // resolve whether or not to show dark theme using uiState, since it can be different + // than the configuration's dark theme value based on the user preference. + enableEdgeToEdge( + statusBarStyle = SystemBarStyle.auto( + lightScrim = android.graphics.Color.TRANSPARENT, + darkScrim = android.graphics.Color.TRANSPARENT, + ) { newThemeSettings.darkTheme }, + navigationBarStyle = SystemBarStyle.auto( + lightScrim = lightScrim, + darkScrim = darkScrim, + ) { newThemeSettings.darkTheme }, + ) + } + + themeSettings = newThemeSettings + } } } // Keep the splash screen on-screen until the UI state is loaded. This condition is // evaluated each time the app needs to be redrawn so it should be fast to avoid blocking // the UI. - splashScreen.setKeepOnScreenCondition { - when (uiState) { - Loading -> true - is Success -> false - } - } - - // Turn off the decor fitting system windows, which allows us to handle insets, - // including IME animations, and go edge-to-edge - // This also sets up the initial system bar style based on the platform theme - trace("niaEdgeToEdge") { enableEdgeToEdge() } + splashScreen.setKeepOnScreenCondition { viewModel.uiState.value.shouldKeepSplashScreen() } setContent { - val darkTheme = shouldUseDarkTheme(uiState) - - // Update the edge to edge configuration to match the theme - // This is the same parameters as the default enableEdgeToEdge call, but we manually - // resolve whether or not to show dark theme using uiState, since it can be different - // than the configuration's dark theme value based on the user preference. - DisposableEffect(darkTheme) { - trace("niaEdgeToEdge") { - enableEdgeToEdge( - statusBarStyle = SystemBarStyle.auto( - android.graphics.Color.TRANSPARENT, - android.graphics.Color.TRANSPARENT, - ) { darkTheme }, - navigationBarStyle = SystemBarStyle.auto( - lightScrim, - darkScrim, - ) { darkTheme }, - ) - onDispose {} - } - } - val appState = rememberNiaAppState( windowSizeClass = calculateWindowSizeClass(this), networkMonitor = networkMonitor, @@ -150,9 +146,9 @@ class MainActivity : ComponentActivity() { LocalTimeZone provides currentTimeZone, ) { NiaTheme( - darkTheme = darkTheme, - androidTheme = shouldUseAndroidTheme(uiState), - disableDynamicTheming = shouldDisableDynamicTheming(uiState), + darkTheme = themeSettings.darkTheme, + androidTheme = themeSettings.androidTheme, + disableDynamicTheming = themeSettings.disableDynamicTheming, ) { NiaApp(appState) } @@ -171,47 +167,6 @@ class MainActivity : ComponentActivity() { } } -/** - * Returns `true` if the Android theme should be used, as a function of the [uiState]. - */ -@Composable -private fun shouldUseAndroidTheme( - uiState: MainActivityUiState, -): Boolean = when (uiState) { - Loading -> false - is Success -> when (uiState.userData.themeBrand) { - ThemeBrand.DEFAULT -> false - ThemeBrand.ANDROID -> true - } -} - -/** - * Returns `true` if the dynamic color is disabled, as a function of the [uiState]. - */ -@Composable -private fun shouldDisableDynamicTheming( - uiState: MainActivityUiState, -): Boolean = when (uiState) { - Loading -> false - is Success -> !uiState.userData.useDynamicColor -} - -/** - * Returns `true` if dark theme should be used, as a function of the [uiState] and the - * current system context. - */ -@Composable -private fun shouldUseDarkTheme( - uiState: MainActivityUiState, -): Boolean = when (uiState) { - Loading -> isSystemInDarkTheme() - is Success -> when (uiState.userData.darkThemeConfig) { - DarkThemeConfig.FOLLOW_SYSTEM -> isSystemInDarkTheme() - DarkThemeConfig.LIGHT -> false - DarkThemeConfig.DARK -> true - } -} - /** * The default light scrim, as defined by androidx and the platform: * https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:activity/activity/src/main/java/androidx/activity/EdgeToEdge.kt;l=35-38;drc=27e7d52e8604a080133e8b842db10c89b4482598 @@ -223,3 +178,13 @@ private val lightScrim = android.graphics.Color.argb(0xe6, 0xFF, 0xFF, 0xFF) * https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:activity/activity/src/main/java/androidx/activity/EdgeToEdge.kt;l=40-44;drc=27e7d52e8604a080133e8b842db10c89b4482598 */ private val darkScrim = android.graphics.Color.argb(0x80, 0x1b, 0x1b, 0x1b) + +/** + * Class for the system theme settings. + * This wrapping class allows us to combine all the changes and prevent unnecessary recompositions. + */ +data class ThemeSettings( + val darkTheme: Boolean, + val androidTheme: Boolean, + val disableDynamicTheming: Boolean, +) diff --git a/app/src/main/kotlin/com/google/samples/apps/nowinandroid/MainActivityViewModel.kt b/app/src/main/kotlin/com/google/samples/apps/nowinandroid/MainActivityViewModel.kt index 09f4597a7..3715bb8b4 100644 --- a/app/src/main/kotlin/com/google/samples/apps/nowinandroid/MainActivityViewModel.kt +++ b/app/src/main/kotlin/com/google/samples/apps/nowinandroid/MainActivityViewModel.kt @@ -21,6 +21,8 @@ import androidx.lifecycle.viewModelScope import com.google.samples.apps.nowinandroid.MainActivityUiState.Loading import com.google.samples.apps.nowinandroid.MainActivityUiState.Success import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository +import com.google.samples.apps.nowinandroid.core.model.data.DarkThemeConfig +import com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand import com.google.samples.apps.nowinandroid.core.model.data.UserData import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.SharingStarted @@ -44,5 +46,40 @@ class MainActivityViewModel @Inject constructor( sealed interface MainActivityUiState { data object Loading : MainActivityUiState - data class Success(val userData: UserData) : MainActivityUiState + + data class Success(val userData: UserData) : MainActivityUiState { + override val shouldDisableDynamicTheming = !userData.useDynamicColor + + override val shouldUseAndroidTheme: Boolean = when (userData.themeBrand) { + ThemeBrand.DEFAULT -> false + ThemeBrand.ANDROID -> true + } + + override fun shouldUseDarkTheme(isSystemDarkTheme: Boolean) = + when (userData.darkThemeConfig) { + DarkThemeConfig.FOLLOW_SYSTEM -> isSystemDarkTheme + DarkThemeConfig.LIGHT -> false + DarkThemeConfig.DARK -> true + } + } + + /** + * Returns `true` if the state wasn't loaded yet and it should keep showing the splash screen. + */ + fun shouldKeepSplashScreen() = this is Loading + + /** + * Returns `true` if the dynamic color is disabled. + */ + val shouldDisableDynamicTheming: Boolean get() = false + + /** + * Returns `true` if the Android theme should be used. + */ + val shouldUseAndroidTheme: Boolean get() = false + + /** + * Returns `true` if dark theme should be used. + */ + fun shouldUseDarkTheme(isSystemDarkTheme: Boolean) = isSystemDarkTheme } diff --git a/app/src/main/kotlin/com/google/samples/apps/nowinandroid/util/UiExtensions.kt b/app/src/main/kotlin/com/google/samples/apps/nowinandroid/util/UiExtensions.kt new file mode 100644 index 000000000..20d55ab4c --- /dev/null +++ b/app/src/main/kotlin/com/google/samples/apps/nowinandroid/util/UiExtensions.kt @@ -0,0 +1,49 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.util + +import android.content.res.Configuration +import androidx.activity.ComponentActivity +import androidx.core.util.Consumer +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.conflate +import kotlinx.coroutines.flow.distinctUntilChanged + +/** + * Convenience wrapper for dark mode checking + */ +val Configuration.isSystemInDarkTheme + get() = (uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES + +/** + * Registers listener for configuration changes to retrieve whether system is in dark theme or not. + * Immediately upon subscribing, it sends the current value and then registers listener for changes. + */ +fun ComponentActivity.isSystemInDarkTheme() = callbackFlow { + channel.trySend(resources.configuration.isSystemInDarkTheme) + + val listener = Consumer { + channel.trySend(it.isSystemInDarkTheme) + } + + addOnConfigurationChangedListener(listener) + + awaitClose { removeOnConfigurationChangedListener(listener) } +} + .distinctUntilChanged() + .conflate()