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: Boolean
Brian 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 aState
State
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