Ryan Simon
05/25/2021, 5:50 AMSingleLiveEvent and observing changes in my Fragment. Now that I’m in Compose world, it seems that using a SingleLiveEvent isn’t something that’s supported.
What are your recommendations for handling one-off events coming from the ViewModel like showing a Snackbar, navigation, etc?
Sample code in the thread of what I’m doing now.Ryan Simon
05/25/2021, 5:50 AM@Composable
fun HomeScreen(viewModel: HomeViewModel, scaffoldState: ScaffoldState = rememberScaffoldState()) {
val homeState by viewModel.state.observeAsState()
val event by viewModel.event.observeAsState()
LaunchedEffect(event) {
when (event) {
is ShowSizzle -> scaffoldState.snackbarHostState.showSnackbar("Show Sizzle")
}
}
HomeScreen(
state = homeState,
scaffoldState = scaffoldState,
onDefault = { viewModel.processAction(action = HomeAction.Load) },
onSizzleClicked = { viewModel.processAction(action = HomeAction.SizzleClicked(sizzleId = it)) }
)
}Ryan Simon
05/25/2021, 5:51 AMevent is backed by a SingleLiveEvent. technically this works, but the event gets triggered on each recomposition (i.e. when rotating the screen)Ryan Simon
05/25/2021, 5:52 AMLiveData.observe() to work because we can’t use @Composable ’s inside of closuresAdam Powell
05/25/2021, 5:53 AMAdam Powell
05/25/2021, 5:53 AMAdam Powell
05/25/2021, 5:54 AMAdam Powell
05/25/2021, 5:54 AMRyan Simon
05/25/2021, 5:56 AMRyan Simon
05/25/2021, 6:23 AMViewModel. so that rules out any solution where i just directly handle events in Composable ’s. it’s mostly so i can handle analytics or anything else that would make sense during clicks, etc using a single function call
so then i thought about making these events part of my State, though it doesn’t feel right. i don’t want events to be reconsumed during something like a configuration change which would happen if the events get reduced to a State
i’m trying to follow an MVI-like pattern, and handling these one-off events is the thing that has me a bit stumped. any guidance on this would be very appreciatedAdam Powell
05/25/2021, 6:24 AMRyan Simon
05/25/2021, 6:24 AMAlbert Chang
05/25/2021, 6:57 AMval lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(viewModel, lifecycleOwner) {
val observer = Observer<Event> { event ->
// Handle event
}
viewModel.event.observe(lifecycleOwner, observer)
onDispose { viewModel.event.removeObserver(observer) }
}
And consider migrating to conflated Channel or SharedFlow so that you can use LaunchedEffect and flow collecting API which is much simpler.darkmoon_uk
05/25/2021, 12:11 PMFlow<Unit> in your LaunchedEffect, for when you really just need a pure trigger with no associated metadata.Brian G
05/25/2021, 1:37 PMshowSnackbar you want a state e.g. snackbarVisible: BooleanBrian G
05/25/2021, 1:37 PMAlbert Chang
05/25/2021, 1:46 PMThis components provides only the visuals of the Snackbar. If you need to show a Snackbar with defaults on the screen, use ScaffoldState.snackbarHostState and SnackbarHostState.showSnackbar
Adam Powell
05/25/2021, 2:26 PMso then i thought about making these events part of myHandling events is the process of reducing events to state. If events have been reduced to state then the resulting state is always safe to consume zero or a million times, because consuming state is always idempotent., though it doesn’t feel right. i don’t want events to be reconsumed during something like a configuration change which would happen if the events get reduced to aStateState
Adam Powell
05/25/2021, 2:30 PMColton Idle
05/25/2021, 3:08 PMRyan Simon
05/25/2021, 3:44 PMColton Idle
05/25/2021, 3:46 PMRyan Simon
05/25/2021, 4:01 PMSuccess(showSnackbar = true) would be good for me if on rotation this gets reemitted and i show the snackbar again?Colton Idle
05/25/2021, 4:37 PMAdam Powell
05/25/2021, 4:54 PMI really just want to know how to instruct my fragment to navigate to the next fragment.For this case of fragment navigation specifically:
// Conflated because a new request should replace an old request
// that has not been acted upon yet
private val _navigationRequests = Channel<DestinationInfo>(Channel.CONFLATED)
val navigationRequests = _navigationRequests.receiveAsFlow()
// Consumer(s):
LaunchedEffect(lifecycle, eventSource, navController) {
lifecycle.repeatOnLifecycle(STARTED) {
eventSource.navigationRequests.collect {
navController.navigateTo(it)
}
}
}Adam Powell
05/25/2021, 4:55 PMAdam Powell
05/25/2021, 4:56 PMnavigateTo is one of these events, which performs the reduce step to state in the navigation implementation.Adam Powell
05/25/2021, 4:57 PMreceiveAsFlow plus the repeatOnLifecycle around the collect.Adam Powell
05/25/2021, 4:58 PMAdam Powell
05/25/2021, 5:00 PMSuccess(showSnackbar = true) is not a state, it's an event. It describes something that happened rather than something that is.Adam Powell
05/25/2021, 5:01 PMcurrentSnack = SnackInfo("The operation was successful")Adam Powell
05/25/2021, 5:02 PMcurrentSnack?.let { snack ->
Snackbar(snack, onDismiss = { currentSnack = null })
}
as many times as it wants, or not at all, and it would still be just as correct.Adam Powell
05/25/2021, 5:03 PMAdam Powell
05/25/2021, 5:04 PMSnackbarHostState because it represents this event to state reduction quite elegantly: https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/[…]idx/compose/material/SnackbarHost.kt;l=60?q=SnackbarHostStateAdam Powell
05/25/2021, 5:05 PMshowSnackbar uses a suspending Mutex to implement a queue of notifications; cancelling a caller of showSnackbar is capable of double duty: it can clear the current notification or remove a pending one from the queue that is no longer relevant.Adam Powell
05/25/2021, 5:07 PMcurrentSnackbarData is the reduced state, and showSnackbar plus `SnackbarData`'s methods are the control surface for sending eventsAdam Powell
05/25/2021, 5:07 PMshowSnackbar call is another event afforded by this API)Adam Powell
05/25/2021, 5:11 PMshowSnackbar be a suspend fun makes all manner of useful things possible since it lets code await a response from the user for as long as it takes the user to respond. For example, I've used a suspend function that eventually calls snackbarHostState.showSnackbar as part of a Flow.retryWhen predicate, which lets the user directly respond to errors and decide whether to restart the operation represented by that flow or not, like reconnect to a remote data source.Adam Powell
05/25/2021, 5:13 PMSnackbarHostState exists outside of `@Composable fun`s. It has no dependencies that prevent keeping a very long-lived reference to it, whether that's across activities or anything else. It can be trivially unit tested.Adam Powell
05/25/2021, 5:17 PMsnackbarHostState.currentSnackbarData?.let { snack ->
LaunchedEffect(lifecycle, snack) {
lifecycle.repeatOnLifecycle(RESUMED) {
delay(5_000)
snack.dismiss()
}
}
}Ryan Simon
05/25/2021, 5:38 PMRyan Simon
05/25/2021, 5:38 PMColton Idle
05/25/2021, 5:41 PMAdam Powell
05/25/2021, 5:55 PMAdam Powell
05/25/2021, 5:56 PMAdam Powell
05/25/2021, 5:59 PMval snackbarHostState = SnackbarHostState() in your ViewModel and pass it to your compose ui from there, go nutsColton Idle
05/25/2021, 6:01 PMAdam Powell
05/25/2021, 6:01 PMColton Idle
05/25/2021, 6:01 PMAdam Powell
05/25/2021, 6:03 PMAdam Powell
05/25/2021, 6:05 PMrememberSaveable data, away from the definition of the routes themselves as a way to experiment with making that state more hoistable/more easily owned and controlled by app codeAdam Powell
05/25/2021, 6:06 PMAdam Powell
05/25/2021, 6:07 PMSnackbarHostState in that wayRyan Simon
05/26/2021, 5:06 AMSharedFlow for the one-off events. Here it is
@Composable
fun HomeScreen(viewModel: HomeViewModel, scaffoldState: ScaffoldState = rememberScaffoldState()) {
val homeState by viewModel.state.observeAsState()
LaunchedEffect(viewModel.event) {
viewModel.event.collect {
when (it) {
is ShowSizzle -> scaffoldState.snackbarHostState.showSnackbar("Show Sizzle")
}
}
}
Scaffold(scaffoldState = scaffoldState) {
HomeScreen(
state = homeState,
onDefault = { viewModel.processAction(action = HomeAction.Load) },
onSizzleClicked = {
viewModel.processAction(action = HomeAction.SizzleClicked(sizzleId = it))
}
)
}
}Ryan Simon
05/26/2021, 5:07 AM