Nacho Ruiz Martin
07/27/2021, 5:13 PMSharedFlow
instead of StateFlow
because this should be a hot observable.
I'm trying to collect the Flow in the Composable with val state = flow.collectAsState(defaultValue)
even if this feels weird because it's not a state.
• The Composable is not being recomposed when a new event is send, how's that achieved?
• Is there any better way to consume/collect/observe a SharedFlow in a Composable function?Zach Klippenstein (he/him) [MOD]
07/27/2021, 5:17 PMcollectAsState
is, as you’ve called out, is wrong. You can use a LaunchedEffect
and collect inside thatNacho Ruiz Martin
07/27/2021, 5:18 PMColton Idle
07/27/2021, 5:22 PMColton Idle
07/27/2021, 5:23 PMNacho Ruiz Martin
07/27/2021, 5:25 PMZach Klippenstein (he/him) [MOD]
07/27/2021, 5:43 PMcollectAsState
function, but it’s a little more boilerplate to collect events.Nacho Ruiz Martin
07/27/2021, 5:48 PMZach Klippenstein (he/him) [MOD]
07/27/2021, 5:50 PMNacho Ruiz Martin
07/27/2021, 5:54 PMColton Idle
07/27/2021, 5:55 PMLandry Norris
07/27/2021, 7:38 PMHalil Ozercan
07/27/2021, 7:41 PMfeedback messages that are undoubtedly eventsyou are partially right that feedback messages are kind of events but let's take a closer look at that idea from UI perspective. For example a backend request fails and you want to show an error message in UI. The verb "show" is hinting that this might be an event that you send to your UI from your ViewModel. However, Snackbar is a UI element. It has its own state and what you are actually doing is manipulating that state from outside. Navigation also works in a very similar fashion. There are tons of different ways and places you update a state. These "event"s are just one of those. When snackbar state receives a request to show a message, it simply updates the visibility and the text. After, it starts a timeout for reverting the visibility. Animatables, snackbar, navigation these are all stateful but not a great fit for ViewModel. So, the actual question is where do you draw the line? Which states should be completely owned by Compose and which should be outsourced to a ViewModel so that side effects can work. I don't have a clear answer for that. Thanks for coming to my non-sense TED talk...
Chuck Stein
07/27/2021, 10:24 PMEventHandler
from my top-level screen composables. It's worked well for me so far. Hopefully this helps some people out who are in similar situations.
/**
* Collects a flow of instantaneous UI events coming from a ViewModel, so that the UI can react accordingly,
* for example by navigating to a new screen or showing a snackbar.
*
* @param uiEvents The [Flow] of events to collect (each should represent a one-time event, not a persistent UI state).
* @param eventCollector The function that will process each event.
*/
@Composable
fun <T> EventHandler(uiEvents: Flow<T>, eventCollector: suspend (T) -> Unit) {
val lifecycleOwner = LocalLifecycleOwner.current
val uiEventsLifecycleAware = remember(uiEvents, lifecycleOwner) {
uiEvents.flowWithLifecycle(lifecycleOwner.lifecycle, Lifecycle.State.STARTED)
}
LaunchedEffect(uiEventsLifecycleAware, eventCollector) {
uiEventsLifecycleAware.collect(eventCollector)
}
}
For the type parameter representing events, T
, I use an enum containing the possible events for that screen, or if some events contain data, a sealed class instead.Chuck Stein
07/27/2021, 10:51 PMColton Idle
07/28/2021, 12:17 AMAdam Powell
07/28/2021, 1:39 AM["Hello, world!"]
)Colton Idle
07/28/2021, 1:43 AMAdam Powell
07/28/2021, 1:44 AMsignIn
method setting it to true is exactly what I mean hereAlex Vanyo
07/28/2021, 1:49 AMDialog
in the view system has a fairly straightforward show()
method at first glance. However, if you want to hide the dialog manually, you have to keep track of it. If you are not careful, you might show two dialogs. What happens if your app is rotated, or goes into the background and goes through process death. Do you still want your dialog to be shown?
If you can avoid trying to pass a ("Show a dialog: "Hello, world"") event down into the UI, and instead treat it as a ("Currently showing dialog: "Hello, world"") state, you now have a lot more control over what the user will see.Colton Idle
07/28/2021, 1:50 AMColton Idle
07/28/2021, 1:53 AMChuck Stein
07/28/2021, 2:17 AMSingleLiveEvent
or a Kotlin Channel
) is so that the response to the event only happens once, e.g. don't pop up a snackbar a second time after rotating or on recomposition. Also, what is it about the compose paradigm that makes this event stream pattern obsolete?Alex Vanyo
07/28/2021, 2:36 AMDialog
, rather than the non-compose AlertDialog
or variants. That composable Dialog
is much more conducive to being controlled by state, rather than needing an explicit `show()`/`hide()` call. That's the bit that allows you to avoid needing an event stream to the UI:
val openDialog = remember { mutableStateOf(true) }
if (openDialog.value) {
Dialog(onDismissRequest = { openDialog.value = false }) {
// Draw a rectangle shape with rounded corners inside the dialog
Box(Modifier.size(200.dp, 50.dp).background(Color.White))
}
}
For showing the dialog twice, I was imagining a case something like this:
Suppose you want to show some sort of error dialog upon a network request failing.
If a rotation causes the request to kick off again, (or maybe the user backgrounds the app, and the process is killed, and upon returning to the app the request is made again), or there's any other way for another dialog to be shown, you might get two dialogs visible at the same time if the user hadn't dismissed the first one yet. Each dialog might only have been shown once, but it gets a lot more complicated to choreograph the behavior of both/all of them.Chuck Stein
07/28/2021, 3:17 AMopenDialog
. But my point is that rather than having the ViewModel
expose a State
with a showDialog
boolean set to true
, it could expose something like Flow<Event>
where Event
is an enum for all the possible events on that screen, including SHOW_DIALOG
. openDialog
would be initialized to false
, but then if the SHOW_DIALOG
event is emitted, we set openDialog
to true
. That's the only difference, and it's not possible for the UI to ever show more than one dialog because the composable can only ever either emit that one Dialog
if openDialog
is true
, or not if it's false
. And with this event stream approach rather than exposing it as State
from the ViewModel
, we wouldn't need to add a function like onDismissDialog
to the ViewModel
to set the showDialog
State
back to false
, and call it in the `Dialog`'s onDismissRequest
callback.Colton Idle
07/28/2021, 3:31 AMChuck Stein
07/28/2021, 3:40 AMViewModel
exposing the same State
as when you left the screen, which includes something like navigateToNextScreen = true
, then that LaunchedEffect
block will run again and immediately navigate to the next screen once again, then the user is stuck in that loop I mentioned before. I do use LaunchedEffect
for collecting that Flow<Event>
from the ViewModel
, but using State
for something that should only happen once just causes complications.Colton Idle
07/28/2021, 3:48 AMColton Idle
07/28/2021, 3:55 AMColton Idle
07/28/2021, 3:58 AMLaunchedEffect{
navController.navigate("ScreenB")
vm.allDataValidated = false
}
Besides that... without more official guidance... I'm kinda lost. lol @Chuck Stein you were right.Chuck Stein
07/28/2021, 4:21 AMColton Idle
07/28/2021, 4:27 AMChuck Stein
07/28/2021, 4:29 AMNacho Ruiz Martin
07/28/2021, 9:35 AMSharedFlow
, isn’t it? You just want more than one observer to react to a single time event.
If that event is present in the state, the first observer would be the one resetting the state, and what about the others?
I mean, it’s probably something you can make work, but I don’t really see the downfall of just sending single time events.
This is React/Redux, but video has an interesting point of view about removing time from state. Web dev is some years ahead in declarative UIs, so we can learn some things from them, maybe.Adam Powell
07/28/2021, 2:03 PMAdam Powell
07/28/2021, 2:06 PMAdam Powell
07/28/2021, 2:09 PMAdam Powell
07/28/2021, 2:12 PMAdam Powell
07/28/2021, 2:12 PMAdam Powell
07/28/2021, 2:13 PMAdam Powell
07/28/2021, 2:19 PMLandry Norris
07/28/2021, 2:20 PMAdam Powell
07/28/2021, 2:20 PMAdam Powell
07/28/2021, 2:21 PMAdam Powell
07/28/2021, 2:22 PMAdam Powell
07/28/2021, 2:23 PMAdam Powell
07/28/2021, 2:25 PMAdam Powell
07/28/2021, 2:27 PMAdam Powell
07/28/2021, 2:29 PMAdam Powell
07/28/2021, 2:31 PMAdam Powell
07/28/2021, 2:33 PMAdam Powell
07/28/2021, 2:34 PMAdam Powell
07/28/2021, 2:37 PMAdam Powell
07/28/2021, 2:38 PMAdam Powell
07/28/2021, 2:41 PMColton Idle
07/28/2021, 2:47 PMAdam Powell
07/28/2021, 3:01 PMColton Idle
07/28/2021, 3:11 PMChuck Stein
07/28/2021, 4:28 PMAdam Powell
07/28/2021, 4:42 PMChuck Stein
07/28/2021, 4:48 PMAdam Powell
07/28/2021, 4:59 PMLandry Norris
07/28/2021, 5:04 PMNacho Ruiz Martin
07/28/2021, 5:41 PMColton Idle
07/28/2021, 5:45 PM@Composable
fun ScreenA(goToScreenB: () -> Unit, vm = hiltViewModel()) {
if (vm.membershipValidated){
LaunchedEffect{
someLambda() //ends up calling navController.navigate("ScreenB")
vm.membershipValidated = false //acknowledge the state and reset it
}
}
Column() { //actual screen content}
}
Landry Norris
07/28/2021, 5:51 PMZach Klippenstein (he/him) [MOD]
07/28/2021, 5:59 PMLaunchedEffect
can also be used for view-only stuff that the view model shouldn’t know about, like running individual animations.Landry Norris
07/28/2021, 6:02 PMAdam Powell
07/28/2021, 6:03 PMAdam Powell
07/28/2021, 6:04 PMAdam Powell
07/28/2021, 6:04 PMAdam Powell
07/28/2021, 6:07 PMToast
as a special case and not something to derive best practices from. 🙂 Its problems as a fire and forget event sent to a Context
run very deep and the android WindowManager team has been working to mitigate the side effects from that for years. Some of these are also reasons why Snackbars are the preferred way to display that sort of information.Colton Idle
07/28/2021, 6:07 PMGoal is to go from @Composable ScreenA to @Composable ScreenB after some logic is done on a button press.
Real life example: So on button press, if your membershipType is a certain level, then you pass onto the next screen. If your membershipType is lower than that, then you see a Toast "not allowed to do this"
How would that example work?
I am literally just making stuff up because I don't know how I would do the example above which (to me) seems like a simple example. But I'm dead in the water. (I understand toast is a special case... but still there must be some way to trigger it "just" once, no?)Adam Powell
07/28/2021, 6:11 PMonClick = {
if (membershipType >= requiredLevel) {
navigateToNextScreen()
} else {
reportError()
}
}
Colton Idle
07/28/2021, 6:12 PMLandry Norris
07/28/2021, 6:14 PMAdam Powell
07/28/2021, 6:15 PMif (vm.hasValidMembership) { // ...
Adam Powell
07/28/2021, 6:16 PMAdam Powell
07/28/2021, 6:16 PMColton Idle
07/28/2021, 6:16 PMAdam Powell
07/28/2021, 6:18 PMvm.validateMembership(
onSuccess = navigateToNextScreen,
onFailure = reportError
)
Adam Powell
07/28/2021, 6:19 PMColton Idle
07/28/2021, 6:19 PMnavigateToNextScreen
and reportError
would be lambdas passed into the composable right?
i.e.
vm.validateMembership(
onSuccess = navigateToNextScreen(),
onFailure = reportError()
)
Landry Norris
07/28/2021, 6:19 PMAdam Powell
07/28/2021, 6:23 PMAdam Powell
07/28/2021, 6:25 PMAdam Powell
07/28/2021, 6:26 PMColton Idle
07/28/2021, 6:27 PMvm.validateMembership(
onSuccess = navigateToNextScreen,
onFailure = reportError
)
the next time I encounter this problem.Alex Vanyo
07/28/2021, 6:30 PMnavigateToNextScreen
is a lambda that contains a reference to a navController
or something like an Activity
for startActivity
, is there a concern now that a ViewModel
might have a reference to a shorter lived component?Colton Idle
07/28/2021, 6:51 PMaction = {
vm.validateMembership(
onSuccess = navigateToNextScreen,
onFailure = reportError
)
}
So simple. idk why it didn't click before.
It works 😍Adam Powell
07/28/2021, 7:23 PMval scope = rememberCoroutineScope()
// from compose-foundation, cancels old request in flight and ensures mutual exclusion
val mutatorMutex = MutatorMutex()
Button(
onClick = {
scope.launch {
mutatorMutex.mutate {
vm.validateMembership(...
at which point the lifetimes of any lambda captures are controlledAdam Powell
07/28/2021, 7:26 PMvalidateMembership
returns or throws)Alex Vanyo
07/28/2021, 7:47 PM@Composable
layer.
If there’s an event that originates from the ViewModel
layer or application layer, that needs to perform a “one-shot” update (navigate, call startActivity()
), then we’re back in the situation where it feels like we need an event to be passed from the ViewModel
“down” to the UINacho Ruiz Martin
07/28/2021, 8:16 PMAdam Powell
07/28/2021, 8:30 PMAdam Powell
07/28/2021, 8:31 PMvm.loginState is LoginState.LoggedIn
Colton Idle
07/28/2021, 8:38 PMif (vm.loginState is LoginState.LoggedIn) {
LaunchedEffect(Unit) {
someNavigationLambdaThatWasPassedIn()
}
} else {
Column(){ // my actual layout}
}
or
Column(){ // my actual layout, with a buttonClickable
buttonClick = {
vm.loginUser(success = someNavigationLambdaThatWasPassedIn)
}
}
Alex Vanyo
07/28/2021, 9:10 PMColton Idle
07/28/2021, 9:11 PMAlex Vanyo
07/28/2021, 9:23 PMToast
exactly once with the error message from the request that caused the user to sign out.
If we yield that failed request into a state on the ViewModel
side to only pass down state, then on the UI side it has to act upon that state to produce the Toast
, and then “reset” the state to keep the “only once” requirement.
(For the reasons above, Toast
is not the example to follow for general best practices, but it is a simple case where it’s a fire and forget API)Alex Vanyo
07/28/2021, 9:25 PMJohn O'Reilly
07/29/2021, 12:08 PMNacho Ruiz Martin
07/29/2021, 12:25 PMShakil Karim
07/30/2021, 8:13 PMShakil Karim
07/30/2021, 8:50 PMAdam Powell
07/30/2021, 10:31 PMAdam Powell
07/30/2021, 10:34 PMShakil Karim
07/30/2021, 11:29 PMisComplete
here is just an example, it basically represents some action that is true/false ( in my case there is a sound played for correct or incorrect answer and after that ViewModel's method markcomplete
get called, which scroll the pager to the next Quiz page and reset isComplete
) I only want to scroll to the next page when the sound is finished playing.Colton Idle
12/22/2021, 7:37 PMShakil Karim
12/23/2021, 7:48 AMNacho Ruiz Martin
12/23/2021, 9:47 AMChuck Stein
12/23/2021, 5:49 PMAdam Powell
12/23/2021, 5:51 PMColton Idle
12/27/2021, 3:14 PMChuck Stein
12/27/2021, 10:01 PM