Last active
September 17, 2024 16:48
-
-
Save Lucodivo/439534537c8ae2a22da581b60b8277a9 to your computer and use it in GitHub Desktop.
ViewModel to UI Communication: Part 4 - Compose state management
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 mode: DarkMode): SettingsUIEvent | |
// ...25 more UI events | |
} | |
// ViewModel | |
abstract class MoleculeViewModel<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) | |
// UI effect & state providers | |
val uiEffect: SharedFlow<UIEffect> = _uiEffects | |
val uiState: StateFlow<UIState> = | |
uiScope.launchMolecule(mode = ContextClock) { | |
uiState(_uiEvents.receiveAsFlow(), ::launchUiEffect) | |
} | |
@Composable | |
protected abstract fun uiState( | |
uiEvents: Flow<UIEvent>, | |
launchUiEffect: (UIEffect) -> Unit, | |
): UIState | |
// UI event entry point | |
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( | |
private val purgeRepository: PurgeRepository, | |
private val userPreferencesRepository: UserPreferencesRepository, | |
): MoleculeViewModel<SettingsUIEvent, SettingsUIState, SettingsUIEffect>() { | |
@Composable | |
override fun uiState( | |
uiEvents: Flow<SettingsUIEvent>, | |
launchUiEffect: (SettingsUIEffect) -> Unit, | |
): SettingsUIState = | |
settingsUIState( | |
uiEvents = uiEvents, | |
launchUiEffect = launchUiEffect, | |
purgeRepository = purgeRepository, | |
userPreferencesRepository = userPreferencesRepository | |
) | |
} | |
// UI state management composable function | |
@Composable | |
fun settingsUIState( | |
uiEvents: Flow<SettingsUIEvent>, | |
launchUiEffect: (SettingsUIEffect) -> Unit, | |
purgeRepository: PurgeRepository, | |
userPreferencesRepository: UserPreferencesRepository, | |
): SettingsUIState { | |
var clearCacheEnabled by remember { mutableStateOf(true) } | |
var dropdownMenuState by remember { | |
mutableStateOf(SettingsDropdownMenuState.None) | |
} | |
var alertDialogState by remember { | |
mutableStateOf(SettingsAlertDialogState.None) | |
} | |
val userPreferences by remember { | |
userPreferencesRepository.userPreferences | |
}.collectAsState(UserPreferences()) | |
LaunchedEffect(Unit) { | |
uiEvents.collect { uiEvent -> | |
// UI event handler | |
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 | |
return 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, | |
) | |
} | |
// 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 = {}, | |
) | |
} | |
} | |
// Compose UI state management tests | |
class SettingsUIStateTest { | |
@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) | |
@Before | |
fun beforeEach() = runTest { | |
every { userPreferencesRepository.userPreferences } | |
returns flowOf(testInitialUserPreferences) | |
justRun { purgeRepository.purgeCache() } | |
} | |
@Test | |
fun `Clear cache disables button and triggers effect`() = runTest { | |
val events = Channel<SettingsUIEvent>() | |
val uiEffects = ArrayList<SettingsUIEffect>() | |
moleculeFlow(mode = Immediate){ | |
settingsUIState( | |
events.receiveAsFlow(), | |
{ uiEffects.add(it) }, | |
purgeRepository = purgeRepository, | |
userPreferencesRepository = userPreferencesRepository, | |
) | |
}.test { | |
skipItems(1) // skip initial state | |
assertTrue(awaitItem().clearCacheEnabled) | |
events.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
In relation to issue 273 in the Molecule github , this is not a perfect solution using the ViewModel Android Architecture Component with Molecule. Typically, the sharing state from an AAC ViewModel to Compose involves exposing Flows using
stateIn()
.stateIn()
allows a hot flow to turn cold after all subscribers have stopped listening. If you see above, that is not the situation here. If your user has 10 screens on their backstack, their associated ViewModels will all contain hot Flows that may all continue to do unnecessary work. A solution to such a problem can be seen in Stylianos Gakis PR and can be seen here. As well as a separate gist of mine here.