はじめに
アプリ内でダーク/ライト/システムを選べるテーマ設定を実装したところ、切り替えるたびに設定シートの下でホーム画面が一瞬ちらつきました。
原因は Activity.recreate() です。recreate でシートがいったん閉じ、新しい Activity が描画されるまでの間に背後の画面が見えていました。
この記事では、Activity を再起動せず、Compose の中でテーマを切り替える方法を残します。
テーマの定義
enum class AppColorTheme { Dark, Light, System }
fun AppColorTheme.isDark(base: Configuration): Boolean = when (this) {
AppColorTheme.Dark -> true
AppColorTheme.Light -> false
AppColorTheme.System ->
(base.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
}
解決策:AppColorThemeHost
setContent {
val theme by colorThemeRepository.get().collectAsStateWithLifecycle(initialTheme)
AppColorThemeHost(activity = this, theme = theme) {
AppTheme { AppContent() }
}
}
@Composable
fun AppColorThemeHost(
activity: ComponentActivity,
theme: AppColorTheme,
content: @Composable () -> Unit,
) {
val base = LocalConfiguration.current
val baseContext = LocalContext.current
val isDark = theme.isDark(base)
val configuration = remember(base, isDark) {
Configuration(base).apply {
val night = if (isDark) Configuration.UI_MODE_NIGHT_YES else Configuration.UI_MODE_NIGHT_NO
uiMode = (uiMode and Configuration.UI_MODE_NIGHT_MASK.inv()) or night
}
}
// createConfigurationContext() だと Activity をたどれなくなるため ContextThemeWrapper を使う
val context = remember(baseContext, configuration) {
ContextThemeWrapper(baseContext, R.style.Theme_YourApp).apply {
applyOverrideConfiguration(configuration)
}
}
LaunchedEffect(isDark) {
val style = SystemBarStyle.auto(Color.TRANSPARENT, Color.TRANSPARENT) { isDark }
activity.enableEdgeToEdge(statusBarStyle = style, navigationBarStyle = style)
}
CompositionLocalProvider(
LocalConfiguration provides configuration,
LocalContext provides context,
content = content,
)
}
LocalConfiguration と LocalContext を上書きすると、isSystemInDarkTheme() も -night リソースもユーザーが選んだテーマに従うようになり、Activity を再起動する必要がなくなります。
ハマりポイント1:configChanges="uiMode"
これだけでは、テーマを切り替えるたびにシステムが Activity を再起動してしまいます。uiMode の変更を自分で処理すると宣言する必要があります。
<activity
android:name=".MainActivity"
android:configChanges="uiMode" />
ハマりポイント2:ModalBottomSheet
ModalBottomSheet は別の Window で描画されるので、シートの中ではテーマがシステムの値に戻ってしまいます。シートの内側で、ホスト側の値を入れ直します。
@Composable
fun ThemedModalBottomSheet(onDismissRequest: () -> Unit, content: @Composable () -> Unit) {
val hostConfiguration = LocalConfiguration.current
val hostContext = LocalContext.current
ModalBottomSheet(onDismissRequest = onDismissRequest) {
CompositionLocalProvider(
LocalConfiguration provides hostConfiguration,
LocalContext provides hostContext,
) {
// シートのシステムバーのアイコン色もテーマに合わせる
val isDark = isSystemInDarkTheme()
val view = LocalView.current
SideEffect {
val window = (view.parent as? DialogWindowProvider)?.window ?: return@SideEffect
WindowCompat.getInsetsController(window, view).apply {
isAppearanceLightStatusBars = !isDark
isAppearanceLightNavigationBars = !isDark
}
}
content()
}
}
}
おわりに
CompositionLocalProvider で Configuration を上書きすれば、ナビゲーションの状態や画面の状態を保ったままテーマを切り替えられます。configChanges と ModalBottomSheet の対応は見落としやすいので、同じ実装をする方の参考になれば幸いです。