この記事は韓国語から翻訳したものです。不十分な部分があれば、いつでもフィードバックをいただければありがたいです! (オリジナル記事, 同じく私が作成しました。)
一般的にAndroidアプリでテーマを変更すると、Activityを再起動する必要があります。しかし、いくつかのアプリを利用していた時、テーマを変更してもActivityを再起動しないアプリをいくつか発見し、興味を持って分析した後、好みに合わせて変更して実装してみました。
新しい発見
Android開発をしていると、ActivityのライフサイクルでonCreate
は無条件に1回だけ呼び出されるという事実を知っているはずです。そうやってアプリを開発してる時、onCreate
が二回呼び出される現象を経験して、原因を調べてみると、ActivityでConfigurationの変更が起こる何かをしたことに気づきます。(参考)
テーマの変更もConfigurationの変更に該当し、テーマが変更されるとActivityも再起動されます。以前のチームプロジェクトでチームメンバーがテーマを即座に変更する機能を実装していた時、テーマが変更されるたびに recreate
のためにDialogが消えたり消えたりする問題を経験し、最終的にDialogの確認ボタンを押したらテーマが変更されるように修正した記憶があります。その時、チームメンバーがNow In Androidアプリでテーマを変更してもActivityが再起動しないことを発見して、私たちのプロジェクトと比較する記事(韓国語です)を簡単に書いていて、面白くて読んでみました。使ってたアプリの中でもNow In Androidと同じようにActivityを再起動せずにテーマを変更するアプリがあったので、この機会にこれを完全に分析して、個人プロジェクトに適用してみることにしました。
コード分析
Now In Android
Now In AndroidアプリはGoogleが公式にKotlinとComposeで作ったサンプルアプリです。Googleが推奨してるAndroidアプリアーキテクチャーやBest Practicesを紹介するため作られたそうです。上の画像を見ると、Radioボタンをクリックするとすぐにテーマが変更されることが確認できます。もし、Activityが再起動されたらDialogが再生成されるモーションが見えるはずです。Dialogを押したらどのようなことが起こるのかから見てみましょう。
@Composable
fun SettingsDialog(
onDismiss: () -> Unit,
viewModel: SettingsViewModel = hiltViewModel(),
) {
val settingsUiState by viewModel.settingsUiState.collectAsStateWithLifecycle()
SettingsDialog(
onDismiss = onDismiss,
settingsUiState = settingsUiState,
onChangeThemeBrand = viewModel::updateThemeBrand,
onChangeDynamicColorPreference = viewModel::updateDynamicColorPreference,
onChangeDarkThemeConfig = viewModel::updateDarkThemeConfig,
)
}
// ...
@Composable
fun SettingsDialog(
settingsUiState: SettingsUiState,
supportDynamicColor: Boolean = supportsDynamicTheming(),
onDismiss: () -> Unit,
onChangeThemeBrand: (themeBrand: ThemeBrand) -> Unit,
onChangeDynamicColorPreference: (useDynamicColor: Boolean) -> Unit,
onChangeDarkThemeConfig: (darkThemeConfig: DarkThemeConfig) -> Unit,
) {
// ...
SettingsPanel(
settings = settingsUiState.settings,
supportDynamicColor = supportDynamicColor,
onChangeThemeBrand = onChangeThemeBrand,
onChangeDynamicColorPreference = onChangeDynamicColorPreference,
onChangeDarkThemeConfig = onChangeDarkThemeConfig,
)
}
// ...
@Composable
private fun ColumnScope.SettingsPanel(
settings: UserEditableSettings,
supportDynamicColor: Boolean,
onChangeThemeBrand: (themeBrand: ThemeBrand) -> Unit,
onChangeDynamicColorPreference: (useDynamicColor: Boolean) -> Unit,
onChangeDarkThemeConfig: (darkThemeConfig: DarkThemeConfig) -> Unit,
) {
// ...
Column(Modifier.selectableGroup()) {
SettingsDialogThemeChooserRow(
text = stringResource(string.feature_settings_dark_mode_config_system_default),
selected = settings.darkThemeConfig == FOLLOW_SYSTEM,
onClick = { onChangeDarkThemeConfig(FOLLOW_SYSTEM) },
)
SettingsDialogThemeChooserRow(
text = stringResource(string.feature_settings_dark_mode_config_light),
selected = settings.darkThemeConfig == LIGHT,
onClick = { onChangeDarkThemeConfig(LIGHT) },
)
SettingsDialogThemeChooserRow(
text = stringResource(string.feature_settings_dark_mode_config_dark),
selected = settings.darkThemeConfig == DARK,
onClick = { onChangeDarkThemeConfig(DARK) },
)
}
}
SettingsDialogThemeChooserRow()
で設定値を変更する場合、onChange...
関数を呼び出す。これは乗り越え、一番上にオーバーロードされたSettingsDialog関数からSettingsViewModelの関数を呼び出します。SettingsViewModelを見てみましょう。
@HiltViewModel
class SettingsViewModel @Inject constructor(
private val userDataRepository: UserDataRepository,
) : ViewModel() {
val settingsUiState: StateFlow<SettingsUiState> =
userDataRepository.userData
.map { userData ->
Success(
settings = UserEditableSettings(
brand = userData.themeBrand,
useDynamicColor = userData.useDynamicColor,
darkThemeConfig = userData.darkThemeConfig,
),
)
}
.stateIn(
scope = viewModelScope,
started = WhileSubscribed(5.seconds.inWholeMilliseconds),
initialValue = Loading,
)
fun updateThemeBrand(themeBrand: ThemeBrand) {
viewModelScope.launch {
userDataRepository.setThemeBrand(themeBrand)
}
}
fun updateDarkThemeConfig(darkThemeConfig: DarkThemeConfig) {
viewModelScope.launch {
userDataRepository.setDarkThemeConfig(darkThemeConfig)
}
}
fun updateDynamicColorPreference(useDynamicColor: Boolean) {
viewModelScope.launch {
userDataRepository.setDynamicColorPreference(useDynamicColor)
}
}
}
Radioの値が変更された時呼び出される関数を見ると、UserDataRepository
でテーマの値を変更する関数です。そして UserDataRepository
にある userData
の値をサブスクリプションが可能な SettingUiState
というStateFlowに変換することが確認できます。これは SettingsDialog
関数を見ると collectAsStateWithLifecycle()
関数で購読しています。
UserDataRepository
にある userData
とテーマ値設定関数は OfflineFirstUserRepository.kt
ファイルを確認すると、DataStoreの値をすぐに修正して反映することも確認できます。
まとめ:
SettingsDialog
Radioクリック ->SettingsViewModel
でテーマ変更関数呼び出し ->UserDataRepository
でDataSourceを使ってDataStoreに保存されたテーマの値を変更 ->UserDataRepository
から値を吐き出すuserData
で変更された値を反映 -> UIに反映
それでは、本格的にこのテーマの値が変更されたら、アプリのテーマがどう変わるか確認してみましょう。アプリのテーマを初期設定するコードはMainActivity.kt
にあります。
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
// ...
var uiState: MainActivityUiState by mutableStateOf(Loading)
// Update the uiState
lifecycleScope.launch {
lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.uiState
.onEach { uiState = it }
.collect()
}
}
// ...
setContent {
// ...
CompositionLocalProvider(
LocalAnalyticsHelper provides analyticsHelper,
LocalTimeZone provides currentTimeZone,
) {
NiaTheme(
darkTheme = darkTheme,
androidTheme = shouldUseAndroidTheme(uiState),
disableDynamicTheming = shouldDisableDynamicTheming(uiState),
) {
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
NiaApp(appState)
}
}
// ...
}
}
}
MainActivity
でテーマを設定する際に uiState
をベースに NiaTheme
を設定します。この部分が一番上なので、uiStateの値が変わると、アプリが順番にrecompositionが行われることになります。MainActivityUiState
を見てみましょう。(MainActivityViewModel.kt
)
@HiltViewModel
class MainActivityViewModel @Inject constructor(
userDataRepository: UserDataRepository,
) : ViewModel() {
val uiState: StateFlow<MainActivityUiState> = userDataRepository.userData.map {
Success(it)
}.stateIn(
scope = viewModelScope,
initialValue = Loading,
started = SharingStarted.WhileSubscribed(5_000),
)
}
sealed interface MainActivityUiState {
data object Loading : MainActivityUiState
data class Success(val userData: UserData) : MainActivityUiState
}
MainActivityViewModel
も含まれています。MainActivityUiState
の Success
data classを見ると、おなじみのデータ型が一つあります。それが UserData
です。先ほど、UserData
にテーマ値があることを覚えているはずです。この値は MainActivityViewModel
が生成される時 uiState
で UserDataRepository
の UserData
をFlow形式で取得して、この値をライフサイクルに合わせて MainActivity
でサブスクライブして反映しています。NiaTheme
では最終的にテーマの値に基づいてColor Schemeを変更し、それを適用した MaterialTheme
を返します。
最終的なまとめ:
SettingsDialog
Radioをクリック ->SettingsViewModel
でテーマ変更関数を呼び出す ->UserDataRepository
でDataSourceを使ってDataStoreに保存されたテーマの値を変更 ->UserDataRepository
から値を吐き出すuserData
に変更された値を反映 -> これをサブスクライブしていたMainActivity
のMainActivityUiState.uiState
の値を変更 -> その値でNiaTheme
で各Color Schemeの色を変更 -> UIに反映
レビュー: 各ファイルが分離されていて読みやすかったです。しかし、Now In Androidの特性上、モジュールが分離されすぎて、フォルダとファイル構造がアプリの規模に比べて複雑だと思いました。 そのため、必要なコードとファイルの位置を探すのが少し難しかったです。Android Studioでレポをクローンした後、インデックスが作成された状態で探したら少し楽になりました!
Seal
Sealは有名なYouTubeのダウンロードCLIツールであるyt-dlpがサポートする全ての動画をAndroidでダウンロードできるようにMaterial3 Designで作られたアプリです。このアプリもJetpack Composeで作成され、テーマが変更された時、Activityの再起動は見られません。分析してみましょう。
app/src/main/java/com/junkfood/seal/ui/page/settings/appearance/AppearancePreferences.kt
// ...
val isDarkTheme = LocalDarkTheme.current.isDarkTheme()
PreferenceSwitchWithDivider(title = stringResource(id = R.string.dark_theme),
icon = if (isDarkTheme) Icons.Outlined.DarkMode else Icons.Outlined.LightMode,
isChecked = isDarkTheme,
description = LocalDarkTheme.current.getDarkThemeDesc(),
onChecked = { PreferenceUtil.modifyDarkThemePreference(if (isDarkTheme) OFF else ON) },
onClick = { onNavigateTo(Route.DARK_THEME) })
// ...
Now In Androidのように値がどのように変わるか見てみましょう。PreferenceSwitchWithDivider
というコンポーネントが上のGIFで見たトグルスイッチ項目です。このアプリも PreferenceSwitch
が切り替わるたびにテーマがすぐに変わるのですが、 onChecked
時に呼び出される PreferenceUtil.modifyDarkThemePreference()
関数を見てみましょう。
app/src/main/java/com/junkfood/seal/util/PreferenceUtil.kt
private val kv: MMKV = MMKV.defaultMMKV()
object PreferenceUtil {
// ...
private val mutableAppSettingsStateFlow = MutableStateFlow(
AppSettings(
DarkThemePreference(
darkThemeValue = kv.decodeInt(
DARK_THEME_VALUE, DarkThemePreference.FOLLOW_SYSTEM
), isHighContrastModeEnabled = kv.decodeBool(HIGH_CONTRAST, false)
),
isDynamicColorEnabled = kv.decodeBool(
DYNAMIC_COLOR, DynamicColors.isDynamicColorAvailable()
),
seedColor = kv.decodeInt(THEME_COLOR, DEFAULT_SEED_COLOR),
paletteStyleIndex = kv.decodeInt(PALETTE_STYLE, 0)
)
)
val AppSettingsStateFlow = mutableAppSettingsStateFlow.asStateFlow()
// ...
fun modifyDarkThemePreference(
darkThemeValue: Int = AppSettingsStateFlow.value.darkTheme.darkThemeValue,
isHighContrastModeEnabled: Boolean = AppSettingsStateFlow.value.darkTheme.isHighContrastModeEnabled
) {
applicationScope.launch(Dispatchers.IO) {
mutableAppSettingsStateFlow.update {
it.copy(
darkTheme = AppSettingsStateFlow.value.darkTheme.copy(
darkThemeValue = darkThemeValue,
isHighContrastModeEnabled = isHighContrastModeEnabled
)
)
}
kv.encode(DARK_THEME_VALUE, darkThemeValue)
kv.encode(HIGH_CONTRAST, isHighContrastModeEnabled)
}
}
// ...
}
シングルトンであるPreferenceUtil ObjectでAppSettingsStateFlow
にテーマ関連の値を保存し、これをStateFlow形式で出力しています。一つ変わった点は、値を読み込んで保存する時、MMKVというオブジェクトを使うことです。確認してみると、MMKVはTencentが作ったKey-Value保存フレームワークで、値が即座にすごく速く反映され、suspend呼び出しが必要ありません。(パフォーマンス指標を見ると、速度がすごく速いです) WeChatで使ってるフレームワークだそうです。Starの数は多いですが、あまり馴染みのないフレームワークなので不思議でした。(確認してみたらSealの開発者も中国人だそうです)
app/src/main/java/com/junkfood/seal/ui/common/CompositionLocals.kt
@Composable
fun SettingsProvider(windowWidthSizeClass: WindowWidthSizeClass, content: @Composable () -> Unit) {
PreferenceUtil.AppSettingsStateFlow.collectAsState().value.run {
CompositionLocalProvider(
LocalDarkTheme provides darkTheme,
LocalSeedColor provides seedColor,
LocalPaletteStyleIndex provides paletteStyleIndex,
LocalTonalPalettes provides if (isDynamicColorEnabled && Build.VERSION.SDK_INT >= 31) dynamicDarkColorScheme(
LocalContext.current
).toTonalPalettes()
else Color(seedColor).toTonalPalettes(
paletteStyles.getOrElse(paletteStyleIndex) { PaletteStyle.TonalSpot }
),
LocalWindowWidthState provides windowWidthSizeClass,
LocalDynamicColorSwitch provides isDynamicColorEnabled,
content = content
)
}
}
app/src/main/java/com/junkfood/seal/MainActivity.kt
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
// ...
setContent {
// ...
SettingsProvider(windowWidthSizeClass = windowSizeClass.widthSizeClass) {
SealTheme(
darkTheme = LocalDarkTheme.current.isDarkTheme(),
isHighContrastModeEnabled = LocalDarkTheme.current.isHighContrastModeEnabled,
isDynamicColorEnabled = LocalDynamicColorSwitch.current,
) {
HomeEntry(
downloadViewModel = downloadViewModel,
cookiesViewModel = cookiesViewModel,
isUrlShared = isUrlSharingTriggered
)
}
}
}
// ...
}
}
コード量が多くないし、同時に見た時、理解しやすそうなのでまとめて持ってきました。まず、テーマの値をSettingsProvider
でCompositionLocal
を利用してLocalDarkTheme
及びLocalDynamicColorSwitch
で提供しています。
Jetpack ComposeでComposableを作る時、再利用性とテストしやすさを考慮してStatelessなComposableを作ります。このようなComposableが多くなると、一般的には各ComposableのパラメータでUI Treeに沿って下へ下へと値が渡されます。しかし、よく使う値や最下位Treeで使われる場合は、TreeにDepthが深くなるとTreeに沿ってパラメータを一つ一つ設定する必要があり、面倒なことが発生します。これを簡単に処理できるようにするのが CompositionLocal
です。CompositionLocal
はLocalDarkTheme
のように特定の値を提供し、CompositionLocal
が提供されたTreeの範囲内では別にパラメータを設定することなくその値を使うことができるようにしてくれます。 (詳細は公式ドキュメント)
また、Sealのコードを見てみましょう。まず、先ほどテーマ関連の値を持ってた AppSettingsStateFlow
をサブスクライブして、その値が変わるたびに CompositionLocalProvider
で LocalDarkTheme
と LocalDynamicColorSwitch
の値も変更しています。このように CompositionLocal
に提供された Local...
で始まる値は MainActivity
で SealTheme
を包み込み、SealTheme でテーマの値を設定します。SealTheme
はやはりテーマの値に応じて Color Scheme を設定し、 MaterialTheme
を返します。
最終的なまとめ:
PreferenceSwitchWithDivider
トグルクリック ->PreferenceUtil
でテーマ変更関数を呼び出す ->MMKV
に保存された KV テーマの値を即座に変更 ->AppSettingsStateFlow
の値を変更 - >AppSettingsStateFlow
の値を変更する ->AppSettingsStateFlow
を購読していたCompositionLocalProvider
でテーマ関連のCompositionLocal
の値を変更 ->MainActivity
でSealTheme
を包むCompositionLocal
のテーマ値を変更してSealTheme
のテーマの色を変更 -> RecompositionでUIに反映する。
レビュー: 実装方法がかなり簡単で、初めて見るライブラリを使うので不思議でした。(KVを読んで記録することをUI thread blockやANRなしで可能)しかし、中国のサードパーティライブラリに依存してるのが少し残念です。
Read You
最後はRead Youというアプリです。Read YouはMaterial3デザインをベースに作られたRSSリーダーアプリです。同じくJetpack Composeで作成されました。コードを分析してみましょう。
app/src/main/java/me/ash/reader/ui/page/settings/color/ColorAndStylePage.kt
@Composable
fun ColorAndStylePage(
navController: NavHostController,
) {
val darkTheme = LocalDarkTheme.current
val darkThemeNot = !darkTheme
// ...
SettingItem(
title = stringResource(R.string.dark_theme),
desc = darkTheme.toDesc(context),
separatedActions = true,
onClick = {
navController.navigate(RouteName.DARK_THEME) {
launchSingleTop = true
}
},
) {
RYSwitch(
activated = darkTheme.isDarkTheme()
) {
darkThemeNot.put(context, scope)
}
}
}
すでに LocalDarkTheme
型の変数があることから CompositionLocal
を使っていることがわかります。darkTheme
変数のタイプを明示的に書いてないのでコードでは見えませんが、 darkTheme
変数は DarkThemePreference
タイプです。これを見てみましょう。
app/src/main/java/me/ash/reader/infrastructure/preference/DarkThemePreference.kt
sealed class DarkThemePreference(val value: Int) : Preference() {
object UseDeviceTheme : DarkThemePreference(0)
object ON : DarkThemePreference(1)
object OFF : DarkThemePreference(2)
override fun put(context: Context, scope: CoroutineScope) {
scope.launch {
context.dataStore.put(
DataStoreKeys.DarkTheme,
value
)
}
}
// ...
@Composable
@ReadOnlyComposable
fun isDarkTheme(): Boolean = when (this) {
UseDeviceTheme -> isSystemInDarkTheme()
ON -> true
OFF -> false
}
// ...
}
app/src/main/java/me/ash/reader/ui/ext/DataStoreExt.kt
val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "settings")
不思議なことに DarkThemePreference
は Preference
型を継承しています。そして、CoroutineScopeとContextを使ってPreferences DataStoreに値を保存しています。ユニークな構造ですが、とりあえず整理してみます、
まとめ:
RYSwitch
トグルクリック ->CompositionLocal
にあるLocalDarkTheme
の値を 読み込む ->DarkThemePreference
のput()
関数を使って DataStore にテーマの値を保存 -> 続行...
値を読み込む部分を強調しましたが、これはLocalDarkTheme
の値を新しい変数として宣言して使ったからです。ここで宣言した変数の値を変えても LocalDarkTheme
の値は変わりません。次に CompositionLocal
の方を確認してみましょう。
app/src/main/java/me/ash/reader/infrastructure/preference/Settings.kt
// ...
val LocalDarkTheme =
compositionLocalOf<DarkThemePreference> { DarkThemePreference.default }
// ...
@Composable
fun SettingsProvider(
content: @Composable () -> Unit,
) {
val context = LocalContext.current
val settings = remember {
context.dataStore.data.map {
Log.i("RLog", "AppTheme: ${it}")
it.toSettings()
}
}.collectAsStateValue(initial = Settings())
CompositionLocalProvider(
// ...
LocalDarkTheme provides settings.darkTheme,
// ...
) {
content()
}
}
app/src/main/java/me/ash/reader/infrastructure/android/MainActivity.kt
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
// ...
setContent {
CompositionLocalProvider(
LocalImageLoader provides imageLoader,
) {
AccountSettingsProvider(accountDao) {
SettingsProvider {
// ...
HomeEntry(subscribeViewModel = subscribeViewModel)
}
}
}
}
}
}
SettingsProvider
で CompositionLocal
を供給しています。ここで、Provider Composableがmemoryを使ってDataStoreの値を取得してCompositionLocal
に提供しています。やはり MainActivity
から CompositionLocalProvider
で LocalDarkTheme
を提供しています。変わった点はテーマのscopeがHomeEntryを囲むことなく、HomeEntryの中にテーマが入っていることです。そして、このテーマもCompositionLocal
のテーマの値によってColor Schemeの値を変えてMaterialTheme
を返し、その中にアプリの画面が入ります。
最終的なまとめ:
RYSwitch
トグルクリック ->CompositionLocal
にあるLocalDarkTheme
の値を 読み込む ->DarkThemePreference
のput()
関数を使って DataStore にテーマの値を保存 ->SettingsProvider
で DataStore の値をサブスクライブして、変更するたびにCompositionLocal
に提供 - >MainActivity
をクリックします->MainActivity
でLocalDarkTheme
を受け取る ->HomeEntry
でCompositionLocal
のテーマ値を変更してMaterialTheme
テーマの色を変更 -> RecompositionでUIに反映する。
レビュー: Sealとテーマ関連の実装方法は似ていますが、アプリ構造と値を読んで保存する部分のコードが難解でした。思いもしなかった実装方法(ContextとCoroutineScopeの使用位置)に驚いたし、個人的には値を保存して読み込む部分だけRepositoryパターンのように分離されたらもっと読みやすかったと思います。DataStoreの非同期作業が入るけど、コードが同期のように読めるように構成されてるのが不思議でした。
私が選んだ方法
結局、Activityを再起動せずにテーマを変更するためには、まず、UI Treeの下部で呼び出された関数を使ってどうにかして値を変更し、その変更された値がUI Treeの上部で持っているテーマの値を変えて順番にRecompositionが起こるように実装する必要があると判断しました。
私はここでThemeViewModel
というものを作って、テーマ関連値に対するロジックはここに入れて、CompositionLocal
を通してThemeViewModel
にあるテーマ設定値を提供する方法を選択しました。このようにすると、テーマ変更ロジックは ThemeViewModel
一箇所で処理してコードを探して管理することができるので便利だと思いました。ThemeViewModel
の中ではRepositoryパターンを使ってテーマ関連の値をDataStoreに保存して読み込むように実装しました。
@HiltViewModel
class ThemeViewModel @Inject constructor(private val settingRepository: SettingRepository) : ViewModel() {
private val _themeSetting = MutableStateFlow(ThemeSetting())
val themeSetting = _themeSetting.asStateFlow()
init {
fetchThemes()
}
private fun fetchThemes() {
viewModelScope.launch {
_themeSetting.update { settingRepository.fetchThemes() }
}
}
fun updateDynamicTheme(theme: DynamicTheme) {
_themeSetting.update { setting ->
setting.copy(dynamicTheme = theme)
}
viewModelScope.launch {
settingRepository.updateThemes(_themeSetting.value)
}
}
fun updateThemeMode(theme: ThemeMode) {
_themeSetting.update { setting ->
setting.copy(themeMode = theme)
}
viewModelScope.launch {
settingRepository.updateThemes(_themeSetting.value)
}
}
}
data class ThemeSetting(
val dynamicTheme: DynamicTheme = DynamicTheme.OFF,
val themeMode: ThemeMode = ThemeMode.SYSTEM
)
val LocalDynamicTheme = compositionLocalOf { DynamicTheme.OFF }
val LocalThemeMode = compositionLocalOf { ThemeMode.SYSTEM }
val LocalThemeViewModel = compositionLocalOf<ThemeViewModel> {
error("CompositionLocal LocalThemeViewModel is not present")
}
@Composable
fun ThemeSettingProvider(
themeViewModel: ThemeViewModel = hiltViewModel(),
content: @Composable () -> Unit
) {
themeViewModel.themeSetting.collectManagedState().value.run {
CompositionLocalProvider(
LocalThemeViewModel provides themeViewModel,
LocalDynamicTheme provides dynamicTheme,
LocalThemeMode provides themeMode,
content = content
)
}
}
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
// ...
override fun onCreate(savedInstanceState: Bundle?) {
// ...
setContent {
// ...
ThemeSettingProvider {
GPTMobileTheme(
dynamicTheme = LocalDynamicTheme.current,
themeMode = LocalThemeMode.current
) {
SetupNavGraph(navController)
}
}
}
}
}
テーマ関連のコードはこれで終わりです。上で比較したプロジェクトよりコード量も少なく簡単だと思います。 また、テーマを変更したい時は、サブUI Treeのどこからでも LocalThemeViewModel
を使ってテーマの値をすぐに変更することもできます。今後、様々な値を追加してアプリ関連の設定で拡張することも容易だと思います。
直接実装したアプリのソースコードはここで確認することができます。アーキテクチャーパターンに決まった正解はありませんが、この方法も正しくないと思うかもしれません。(レポIssueまたはメールでフィードバックをいただければ幸いです).