Created
September 17, 2024 16:51
-
-
Save Lucodivo/5a11ff678d2852335148785227f23f20 to your computer and use it in GitHub Desktop.
ViewModel to UI Communication: Part 5 - Compose UI State Manager
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// UI state enums | |
enum class SettingsDropdownMenuState { | |
None, | |
DarkMode, | |
ColorPalette, | |
HighContrast, | |
Typography, | |
ImageQuality, | |
} | |
enum class SettingsAlertDialogState { | |
None, | |
DeleteAllData, | |
ImageQuality, | |
} | |
// UI state | |
data class SettingsUIState ( | |
val clearCacheEnabled: Boolean, | |
val highContrastEnabled: Boolean, | |
val dropdownMenuState: SettingsDropdownMenuState, | |
val alertDialogState: SettingsAlertDialogState, | |
val darkMode: DarkMode, | |
val colorPalette: ColorPalette, | |
val highContrast: HighContrast, | |
val imageQuality: ImageQuality, | |
val typography: Typography, | |
) | |
// UI effects | |
sealed interface SettingsUIEffect { | |
sealed interface NavigationDestination { | |
data object TipsAndInfo: NavigationDestination | |
data object Statistics: NavigationDestination | |
data class Web(val url: String): NavigationDestination | |
} | |
data object CachePurged: SettingsUIEffect | |
data object AllDataDeleted: SettingsUIEffect | |
data object RateAndReviewRequest: SettingsUIEffect | |
data class Navigation(val dest: NavigationDestination) | |
: SettingsUIEffect | |
} | |
// UI events | |
sealed interface SettingsUIEvent { | |
data object ClickClearCache: SettingsUIEvent | |
data class SelectDarkMode(val more: DarkMode): SettingsUIEvent | |
// ...25 more UI events | |
} | |
// enough time to prevent flows from timing out on basic configuration changes | |
const val STATEIN_TIMEOUT_MILLIS = 5_000L | |
interface ComposeUIStateManager<UIEvent, UIState, UIEffect> { | |
@Composable | |
fun uiState( | |
uiEvents: Flow<UIEvent>, | |
launchUiEffect: (UIEffect) -> Unit, | |
): UIState | |
val cachedState: UIState | |
} | |
abstract class MoleculeViewModel<UIEvent, UIState, UIEffect>( | |
val uiStateManager: ComposeUIStateManager<UIEvent, UIState, UIEffect> | |
): ViewModel() { | |
private val uiScope = | |
CoroutineScope( | |
viewModelScope.coroutineContext + | |
AndroidUiDispatcher.Main | |
) | |
private val _uiEvents = Channel<UIEvent>(capacity = 20) | |
private val _uiEffects = | |
MutableSharedFlow<UIEffect>(extraBufferCapacity = 20) | |
val uiEffect: SharedFlow<UIEffect> = _uiEffects | |
val uiState: StateFlow<UIState> = moleculeFlow(mode = ContextClock) { | |
uiStateManager.uiState( | |
uiEvents = _uiEvents.receiveAsFlow(), | |
launchUiEffect = ::launchUiEffect, | |
) | |
}.stateIn( | |
scope = uiScope, | |
started = WhileSubscribed(STATEIN_TIMEOUT_MILLIS), | |
initialValue = uiStateManager.cachedState, | |
) | |
fun onUiEvent(uiEvent: UIEvent) = | |
viewModelScope.launch { _uiEvents.send(uiEvent) } | |
private fun launchUiEffect(uiEffect: UIEffect){ | |
if(!_uiEffects.tryEmit(uiEffect)) { | |
error("SettingsViewModel: UI effect buffer overflow.") | |
} | |
} | |
} | |
@HiltViewModel | |
class SettingsViewModel @Inject constructor( | |
settingsUIStateManager: SettingsUIStateManager | |
): MoleculeViewModel<SettingsUIEvent, SettingsUIState, SettingsUIEffect>( | |
uiStateManager = settingsUIStateManager | |
) | |
class SettingsUIStateManager @Inject constructor( | |
val purgeRepository: PurgeRepository, | |
val userPreferencesRepository: UserPreferencesRepository, | |
): ComposeUIStateManager<SettingsUIEvent, SettingsUIState, SettingsUIEffect> { | |
override var cachedState = with(UserPreferences()) { | |
SettingsUIState( | |
clearCacheEnabled = true, | |
highContrastEnabled = true, | |
dropdownMenuState = SettingsDropdownMenuState.None, | |
alertDialogState = SettingsAlertDialogState.None, | |
darkMode = darkMode, | |
colorPalette = colorPalette, | |
highContrast = highContrast, | |
imageQuality = imageQuality, | |
typography = typography, | |
) | |
} | |
@Composable | |
override fun uiState( | |
uiEvents: Flow<SettingsUIEvent>, | |
launchUiEffect: (SettingsUIEffect) -> Unit | |
): SettingsUIState { | |
var clearCacheEnabled by remember { | |
mutableStateOf(cachedState.clearCacheEnabled) | |
} | |
var dropdownMenuState by remember { | |
mutableStateOf(cachedState.dropdownMenuState) | |
} | |
var alertDialogState by remember { | |
mutableStateOf(cachedState.alertDialogState) | |
} | |
val userPreferences by remember { | |
userPreferencesRepository.userPreferences | |
}.collectAsState( | |
UserPreferences( | |
darkMode = cachedState.darkMode, | |
colorPalette = cachedState.colorPalette, | |
highContrast = cachedState.highContrast, | |
imageQuality = cachedState.imageQuality, | |
typography = cachedState.typography, | |
) | |
) | |
LaunchedEffect(Unit) { | |
uiEvents.collect { uiEvent -> | |
when(uiEvent) { | |
ClickClearCache -> { | |
// Disable clearing cache until state is recreated | |
clearCacheEnabled = false | |
launch(Dispatchers.IO) { | |
purgeRepository.purgeCache() | |
launchUiEffect(CachePurged) | |
} | |
} | |
is SelectDarkMode -> { /* .. */ } | |
// ...25 more UI events | |
} | |
} | |
} | |
val highContrastEnabled = | |
userPreferences.colorPalette != SYSTEM_DYNAMIC | |
cachedState = SettingsUIState( | |
clearCacheEnabled = clearCacheEnabled, | |
dropdownMenuState = dropdownMenuState, | |
alertDialogState = alertDialogState, | |
highContrastEnabled = highContrastEnabled, | |
darkMode = userPreferences.darkMode, | |
colorPalette = userPreferences.colorPalette, | |
typography = userPreferences.typography, | |
imageQuality = userPreferences.imageQuality, | |
highContrast = | |
if(highContrastEnabled) userPreferences.highContrast | |
else HighContrast.OFF, | |
) | |
return cachedState | |
} | |
} | |
// Compose UI | |
@Composable | |
fun SettingsRoute( | |
navigateToTipsAndInfo: () -> Unit, | |
navigateToStatistics: () -> Unit, | |
settingsViewModel: SettingsViewModel = hiltViewModel(), | |
) { | |
val lifecycle = LocalLifecycleOwner.current.lifecycle | |
val uriHandler = LocalUriHandler.current | |
LaunchedEffect(Unit) { | |
lifecycle.repeatOnLifecycle(state = Lifecycle.State.STARTED) { | |
settingsViewModel.uiEffect.collect{ uiEvent -> | |
when(uiEvent){ | |
AllDataDeleted -> { /* toast */ } | |
CachePurged -> { /* toast */ } | |
RateAndReviewRequest -> { | |
inAppReviewRequest() | |
} | |
is Navigation -> when(uiEvent.dest) { | |
Statistics -> navigateToStatistics() | |
TipsAndInfo -> navigateToTipsAndInfo() | |
is Web -> uriHandler.openUri(uiEvent.dest.url) | |
} | |
} | |
} | |
} | |
} | |
val uiState by settingsViewModel | |
.uiState | |
.collectAsStateWithLifecycle() | |
SettingsScreen( | |
uiState = uiState, | |
onUiEvent = settingsViewModel::onUiEvent | |
) | |
} | |
@Composable | |
fun SettingsScreen( | |
uiState: SettingsUIState, | |
onUiEvent: (SettingsUIEvent) -> Unit, | |
) { | |
// Convert SettingsUIState to Compose UI | |
// and report UI events via onUiEvent() | |
} | |
// Compose UI Preview | |
@Preview | |
@Composable | |
fun PreviewSettingsScreen() = NoopTheme { | |
Surface { | |
SettingsScreen( | |
uiState = SettingsUIState( | |
alertDialogState = SettingsAlertDialogState.None, | |
dropdownMenuState = SettingsDropdownMenuState.None, | |
highContrastEnabled = true, | |
clearCacheEnabled = true, | |
darkMode = DARK, | |
colorPalette = ROAD_WARRIOR, | |
highContrast = OFF, | |
imageQuality = STANDARD, | |
typography = DEFAULT, | |
), | |
onUiEvent = {}, | |
) | |
} | |
} | |
// SettingsUIStateMangerTest | |
class SettingsUIStateManagerTest { | |
@get:Rule val mockkRule = MockKRule(this) | |
@get:Rule val dispatcherRule = MainDispatcherRule() | |
@MockK lateinit var purgeRepository: PurgeRepository | |
@MockK lateinit var userPreferencesRepository: UserPreferencesRepository | |
val testInitialUserPreferences = | |
UserPreferences(imageQuality = VERY_HIGH) | |
lateinit var settingsUIStateManager: SettingsUIStateManager | |
@Before | |
fun beforeEach() = runTest { | |
every { userPreferencesRepository.userPreferences } | |
returns flowOf(testInitialUserPreferences) | |
justRun { purgeRepository.purgeCache() } | |
settingsUIStateManager = SettingsUIStateManager( | |
purgeRepository = purgeRepository, | |
userPreferencesRepository = userPreferencesRepository, | |
) | |
} | |
@Test | |
fun `Clear cache disables button and triggers effect`() = runTest { | |
val uiEvents = Channel<SettingsUIEvent>() | |
val uiEffects = ArrayList<SettingsUIEffect>() | |
moleculeFlow(mode = Immediate){ | |
settingsUIStateManager.uiState( | |
uiEvents = uiEvents.receiveAsFlow(), | |
launchUiEffect = { uiEffects.add(it) }, | |
) | |
}.test { | |
skipItems(1) // skip initial emission | |
assertTrue(awaitItem().clearCacheEnabled) | |
uiEvents.send(ClickClearCache) | |
assertFalse(awaitItem().clearCacheEnabled) | |
assertEquals(1, uiEffects.size) | |
assertEquals(CachePurged, uiEffects[0]) | |
uiEffects.clear() | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment