Jetpack Compose에서 Activity 재시작 없이 앱 테마 변경하기
Last updated: Jul 1, 2024
일반적으로 Android 앱에서 테마를 변경하게되면 Activity를 재시작해야 한다. 하지만 일부 앱들을 이용하던 중 테마를 변경해도 Activity를 재시작하지 않는 앱을 몇개 발견하고, 흥미가 생겨 분석한 후 취향에 맞게 바꾸어 구현해보았다.
Android 개발을 하다 보면 Activity 라이프사이클에서 onCreate
는 무조건 한번만 호출된다는 사실을 알고 있을 것이다. 그렇게 앱을 개발하다가 onCreate
가 두번 호출되는 현상을 겪게 되고, 원인을 찾아 보면 Activity에서 Configuration 변경이 일어나는 무언가를 했다는 사실을 깨닫게 된다. (참고)
테마 변경도 Configuration 변경에 해당되고, 테마가 변경될때 Activity도 재시작 된다. 이전 팀 프로젝트에서 팀원이 테마를 즉시 변경하는 기능을 구현하다가 설정하고 있던 Dialog가 테마가 변경될 때마다 recreate
때문에 계속 꺼졌다 켜지는 문제를 겪었고 결국 Dialog의 확인 버튼을 눌렀을 때 테마가 바뀌게끔 수정했던 기억이 있다. 그때 팀원이 Now In Android 앱에서 테마를 변경해도 Activity가 재시작되지 않는 것을 발견하고, 우리 프로젝트와 비교하는 글을 간단히 적었었고, 흥미로워서 읽어봤었다. 사용하고 있던 앱 중에서도 Now In Android와 동일하게 Activity를 재시작하지 않고 테마를 변경하는 앱들이 있어서, 이번 기회에 이를 완전 분석하고, 개인 프로젝트에 적용해보기로 했다.

Now In Android앱은 구글에서 공식적으로 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형식으로 가져오고 있고, 이 값을 lifecycle에 맞게끔 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은 유명한 유튜브 다운로드 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
를 구독하던CompositionLocalProvider
에서 테마 관련CompositionLocal
값 변경 ->MainActivity
에서SealTheme
을 감싸는CompositionLocal
의 테마 값 변경으로SealTheme
테마 색상 변경 -> Recomposition으로 UI에 반영
후기: 구현 방법이 꽤 간단하고 처음 보는 라이브러리를 써서 신기했다. (심지어 KV를 읽고 기록하는걸 UI thread block 및 ANR 없이 가능) 하지만 중국의 써드파티 라이브러리에 의존한다는 점이 조금 아쉽다.

마지막은 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이 remember를 통해 DataStore의 값을 가져와서 CompositionLocal
에 제공하고 있다. 역시나 MainActivity
에서 CompositionLocalProvider
로 LocalDarkTheme
을 제공하고 있다. 특이한점은 테마 scope가 HomeEntry를 감싸지 않고 HomeEntry안에 테마가 들어가있다. 그리고 해당 테마 역시 CompositionLocal
의 테마 값에 따라 Color Scheme 값을 바꾸어 MaterialTheme
을 반환하고, 그 안에 앱 화면들이 들어간다.
최종 정리:
RYSwitch
토글 클릭 ->CompositionLocal
에 있는LocalDarkTheme
의 값을 읽어옴 ->DarkThemePreference
의put()
함수를 통해 DataStore에 테마 값 저장 ->SettingsProvider
에서 DataStore의 값을 구독하여 변경 떄 마다CompositionLocal
에 제공 ->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 또는 이메일을 통해 피드백을 주시면 감사하겠습니다.)