Hey guys, question about handling one-off events i...
# compose
r
Hey guys, question about handling one-off events in Compose. I normally would deal with this by using a
SingleLiveEvent
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.
1
Copy code
@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)) }
    )
}
so here, the
event
is backed by a
SingleLiveEvent
. technically this works, but the
event
gets triggered on each recomposition (i.e. when rotating the screen)
this is because we observe as state, but I can’t get a regular
LiveData.observe()
to work because we can’t use
@Composable
’s inside of closures
a
tl;dr: use effects, not recomposition to observe event streams in a composition-scoped way
Or otherwise scope the event observation elsewhere
Recomposition is idempotent and event streams aren't.
r
yes, this is helpful, thank you
👍 1
sorry to be a bother @Adam Powell, but i read through your response on that PR and thought more about how to solve for this issue and i’m still a little confused i keep thinking about it, and i’d like all the actions the user takes to funnel through the
ViewModel
. 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 appreciated
a
No bother but this may have to wait until the morning before I get back to it in detail 🙂
r
oh no problem, please take your time. my brain is drained too haha
a
You should do something like this:
Copy code
val 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.
🙏🏻 1
d
Also this may be obvious to you; but don't forget you can react to a
Flow<Unit>
in your
LaunchedEffect
, for when you really just need a pure trigger with no associated metadata.
👍🏻 1
b
I think your method of programmatically showing/hiding a snack bar is not aligned with Compose methodology. Rather than
showSnackbar
you want a state e.g.
snackbarVisible: Boolean
a
@Brian G As per the doc,
This 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
🤯 1
💯 1
a
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
Handling 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.
👍🏻 1
Composable functions read state and declare a reflection of that state, they do not consume events. Composable functions may declare an Effect in response to particular input state, and that Effect may represent an event handler as a sort of actor, but the composable function itself only declares facts in response to an existing state of the world.
👍🏻 1
c
@Ryan Simon I've been using https://github.com/Zhuinden/live-event and have been happy with it since I've been used to using LiveData before compose. EDIT: Also for clarity, I basically send events for dispatching navigation changes. i.e. The user has logged in in the view model, and the server gives me a 200 response, then I send an event from the ViewModel to my fragment(containing my PageComposable) and it navigates to the LoggedInFragment (which hosts a LoggedInPageComposable) in my app.
🙏🏻 1
r
thanks for all the help and suggestions. it seems this is not an uncommon problem. i’ll post up the solution i come up with
c
Yeah, personally I would love to move to something else much simpler for events, but adding that live-event library, and emitting events has been so easy and it makes sense to my team which uses live data.
💯 1
r
@Adam Powell reading over your response about event -> state reduction, i’m wondering if we’re not on the same page. i should clarify by saying that i mean one-off events. these would be things that should only happen one time like navigation or showing a snack bar message in MVI, maybe you’re referring to Intents/Actions that get reduced to State? if not, then i still don’t see how having a state like
Success(showSnackbar = true)
would be good for me if on rotation this gets reemitted and i show the snackbar again?
c
Fwiw, I read Adams comment and was similarly confused and didn't know if maybe he was talking about something else. I really just want to know how to instruct my fragment to navigate to the next fragment. 😁
a
I really just want to know how to instruct my fragment to navigate to the next fragment.
For this case of fragment navigation specifically:
Copy code
// 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)
    }
  }
}
Reasons why:
The FragmentManager/fragment navigator owns the source of truth for the current navigational state, the only way to communicate with it is via events.
navigateTo
is one of these events, which performs the reduce step to state in the navigation implementation.
Each event must be handled exactly once and it's only safe to process those events while the host lifecycle is at least STARTED, hence the use of a channel +
receiveAsFlow
plus the
repeatOnLifecycle
around the
collect
.
All of these requirements are imposed by fragment navigation.
🎉 1
@Ryan Simon
Success(showSnackbar = true)
is not a state, it's an event. It describes something that happened rather than something that is.
An example of a reduced state from that event, something that is, would be,
currentSnack = SnackInfo("The operation was successful")
Any compose code can do
Copy code
currentSnack?.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.
(not the real API shown above but close enough in spirit 🙂 )
I'm really proud of the design of
SnackbarHostState
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=SnackbarHostState
showSnackbar
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.
currentSnackbarData
is the reduced state, and
showSnackbar
plus `SnackbarData`'s methods are the control surface for sending events
(plus the ability to cancel a
showSnackbar
call is another event afforded by this API)
having
showSnackbar
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.
But everything about
SnackbarHostState
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.
Want to write a policy that says after a snackbar notification has been seen in a particular place for 5 seconds, dismiss it? Easy:
Copy code
snackbarHostState.currentSnackbarData?.let { snack ->
  LaunchedEffect(lifecycle, snack) {
    lifecycle.repeatOnLifecycle(RESUMED) {
      delay(5_000)
      snack.dismiss()
    }
  }
}
r
ah interesting, this is helpful. i'll read more into this. i think having a concrete example of how a state-driven architecture deals with these one-off events would be super helpful to see in a compose sample. thank you @Adam Powell
👍 1
based off everything you wrote, i definitely have some ideas, so i'll post again when i have something
c
Yeah. many thanks @Adam Powell. Snackbar is interesting because you would think that you could squish it into state in your ViewModel with something as simple as "snackbarVisible = true" and then in your ViewModel you could also control the timeout of it. i.e. wait 2 seconds, and then set snackbarVisible to false.
a
doing it in the viewmodel has the problem of being tied to wall time and not time consistently visible
to tie it to the actual presentation time of the snackbar UI element to the user you need a presence/visibility anchor, something that compose is very good at representing 🙂
but if you want to stick a
val snackbarHostState = SnackbarHostState()
in your ViewModel and pass it to your compose ui from there, go nuts
c
Yeah. I think I remember Chris Banes talking about this on reddit wayyy back when (before compose) and so it's interesting to see how these conversations play out.
a
this topic of state vs. events and the nature of each is applicable to any software, afaict
👍 1
c
One more Q Adam. You mentioned: All of these requirements are imposed by fragment navigation. How would this change if we use nav-compose? I guess it would not have to be Conflated from what I've heard Ian Lake say about how things are dispatched (you can't get a double click navigation crash that you do in the current system)
a
navigation-compose is pretty similar in terms of owning the state, I haven't had a chance to sit down with Jeremy and Ian for a while to discuss longer-term goals. I do know that strong support for deep linking into your app is a high priority design goal for navigation-compose which imposes some particular shapes of things
👍 1
that gist of my toy navigation router that floats around this channel deliberately splits the navigational state, i.e. the back stack and
rememberSaveable
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 code
👍 1
but the tradeoff there is that it's possible to express navigation to places that don't exist, since the composable+route definitions depend on that state object and not the other way around
👍 1
it's built on the same principles as
SnackbarHostState
in that way
r
Okay, so I found a solution that I like that uses a
SharedFlow
for the one-off events. Here it is
Copy code
@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))
            }
        )
    }
}
this allows for my homeState to be re-emitted on rotation and click events that create the snackbar work on each click before/after rotation, or other config changes