Skip to content

Commit

Permalink
Optimized handling of dark/light mode
Browse files Browse the repository at this point in the history
- 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
  • Loading branch information
mlykotom committed Apr 10, 2024
1 parent d71383c commit bd508c4
Show file tree
Hide file tree
Showing 4 changed files with 146 additions and 94 deletions.
1 change: 1 addition & 0 deletions app/src/main/AndroidManifest.xml
Expand Up @@ -39,6 +39,7 @@

<activity
android:name=".MainActivity"
android:configChanges="uiMode"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
Expand Down
Expand Up @@ -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
Expand All @@ -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() {
Expand All @@ -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,
Expand All @@ -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)
}
Expand All @@ -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
Expand All @@ -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,
)
Expand Up @@ -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
Expand All @@ -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
}
@@ -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<Configuration> {
channel.trySend(it.isSystemInDarkTheme)
}

addOnConfigurationChangedListener(listener)

awaitClose { removeOnConfigurationChangedListener(listener) }
}
.distinctUntilChanged()
.conflate()

0 comments on commit bd508c4

Please sign in to comment.