0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Jetpack ComposeでActivityを再起動せずにアプリのテーマを変更する方法

Last updated at Posted at 2024-10-01

この記事は韓国語から翻訳したものです。不十分な部分があれば、いつでもフィードバックをいただければありがたいです! (オリジナル記事, 同じく私が作成しました。)

一般的に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を押したらどのようなことが起こるのかから見てみましょう。

nowinandroid/feature/settings/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/settings/SettingsDialog.kt

@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を見てみましょう。

nowinandroid/feature/settings/src/main/kotlin/google/samples/apps/nowinandroid/feature/settings/SettingsViewModel.kt

@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 も含まれています。MainActivityUiStateSuccess data classを見ると、おなじみのデータ型が一つあります。それが UserData です。先ほど、UserDataにテーマ値があることを覚えているはずです。この値は MainActivityViewModel が生成される時 uiStateUserDataRepositoryUserData をFlow形式で取得して、この値をライフサイクルに合わせて MainActivity でサブスクライブして反映しています。NiaTheme では最終的にテーマの値に基づいてColor Schemeを変更し、それを適用した MaterialTheme を返します。

最終的なまとめ: SettingsDialog Radioをクリック -> SettingsViewModel でテーマ変更関数を呼び出す -> UserDataRepository でDataSourceを使ってDataStoreに保存されたテーマの値を変更 -> UserDataRepository から値を吐き出す userData に変更された値を反映 -> これをサブスクライブしていた MainActivityMainActivityUiState.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
                    )
                }
            }
        }
        // ...
    }
}

コード量が多くないし、同時に見た時、理解しやすそうなのでまとめて持ってきました。まず、テーマの値をSettingsProviderCompositionLocalを利用してLocalDarkTheme及びLocalDynamicColorSwitchで提供しています。

Jetpack ComposeでComposableを作る時、再利用性とテストしやすさを考慮してStatelessなComposableを作ります。このようなComposableが多くなると、一般的には各ComposableのパラメータでUI Treeに沿って下へ下へと値が渡されます。しかし、よく使う値や最下位Treeで使われる場合は、TreeにDepthが深くなるとTreeに沿ってパラメータを一つ一つ設定する必要があり、面倒なことが発生します。これを簡単に処理できるようにするのが CompositionLocal です。CompositionLocalLocalDarkThemeのように特定の値を提供し、CompositionLocalが提供されたTreeの範囲内では別にパラメータを設定することなくその値を使うことができるようにしてくれます。 (詳細は公式ドキュメント)

また、Sealのコードを見てみましょう。まず、先ほどテーマ関連の値を持ってた AppSettingsStateFlow をサブスクライブして、その値が変わるたびに CompositionLocalProviderLocalDarkThemeLocalDynamicColorSwitch の値も変更しています。このように CompositionLocal に提供された Local... で始まる値は MainActivitySealTheme を包み込み、SealTheme でテーマの値を設定します。SealTheme はやはりテーマの値に応じて Color Scheme を設定し、 MaterialTheme を返します。

最終的なまとめ: PreferenceSwitchWithDivider トグルクリック -> PreferenceUtil でテーマ変更関数を呼び出す -> MMKV に保存された KV テーマの値を即座に変更 -> AppSettingsStateFlow の値を変更 - > AppSettingsStateFlow の値を変更する -> AppSettingsStateFlow を購読していた CompositionLocalProvider でテーマ関連の CompositionLocal の値を変更 -> MainActivitySealTheme を包む 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")

不思議なことに DarkThemePreferencePreference 型を継承しています。そして、CoroutineScopeとContextを使ってPreferences DataStoreに値を保存しています。ユニークな構造ですが、とりあえず整理してみます、

まとめ: RYSwitch トグルクリック -> CompositionLocal にある LocalDarkTheme の値を 読み込む -> DarkThemePreferenceput() 関数を使って 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)
                    }
                }
            }
        }
    }
}

SettingsProviderCompositionLocal を供給しています。ここで、Provider Composableがmemoryを使ってDataStoreの値を取得してCompositionLocalに提供しています。やはり MainActivity から CompositionLocalProviderLocalDarkTheme を提供しています。変わった点はテーマのscopeがHomeEntryを囲むことなく、HomeEntryの中にテーマが入っていることです。そして、このテーマもCompositionLocalのテーマの値によってColor Schemeの値を変えてMaterialThemeを返し、その中にアプリの画面が入ります。

最終的なまとめ: RYSwitch トグルクリック -> CompositionLocal にある LocalDarkTheme の値を 読み込む -> DarkThemePreferenceput() 関数を使って DataStore にテーマの値を保存 -> SettingsProvider で DataStore の値をサブスクライブして、変更するたびに CompositionLocal に提供 - > MainActivity をクリックします-> MainActivityLocalDarkTheme を受け取る -> HomeEntryCompositionLocal のテーマ値を変更して 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またはメールでフィードバックをいただければ幸いです).

0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?