Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save Lucodivo/439534537c8ae2a22da581b60b8277a9 to your computer and use it in GitHub Desktop.
Save Lucodivo/439534537c8ae2a22da581b60b8277a9 to your computer and use it in GitHub Desktop.
ViewModel to UI Communication: Part 4 - Compose state management
// 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()
}
}
}
@Lucodivo
Copy link
Author

Lucodivo commented Sep 6, 2024

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment