-
Notifications
You must be signed in to change notification settings - Fork 500
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
How to handle one-shot events / side effects? Example: backwards navigation #400
Comments
I might model this as: data class NameState(
val name: String? = null,
val showError: Boolean
) : MvRxState have the fragment check whether the input text is not empty when it gets the click and then navigate and tell the viewmodel to save the name or show an error (or hide the error on text input). |
Thanks, that would work for this scenario but it moves some logic from the ViewModel to the Fragment (can't cover it in the VM unit test). Given the above, is there an alternative approach that leaves the fragment as is right now? I'm looking for an equivalent of SingleLiveEvent where it only gets triggered once per event (that part is covered thanks to the If you think it is something not available yet and it makes sense to have I can look into it and put a PR up. |
@sanogueralorenzo If you make your event class not a data class then every instantiation of it will be considered unique |
Good point, I'm too used to data classes That made me think something like the following // Generic so in more complex scenarios a sealed class can be sent through
class SingleEvent<out T>(val data: T) State: data class NameState(
val name: String? = null,
val error: Int? = null,
val navigation: SingleEvent<Unit>? = null
) : MvRxState Fragment: viewModel.selectSubscribe(NameState::navigation, uniqueOnly(), {
if (it == null) return@selectSubscribe
// do something
}) How do you see the above? The only thing is that you must remember that selectSubscribe will emit the initial null value, so something like a selectNonNullSubscribe or selectSingleEvent that ignores the initial value/nulls/Unitnitialized value would be nice Update, tested the above and gives the following
|
@sanogueralorenzo Another option is just to have |
True, thanks for the suggestion. Another similar approach we tried out was having a SingleLiveData but it combined different concepts on the ViewModel and Fragment level which looked ugly. One of the main thoughts behind having it in the state is to keep that simplicity but also easily test navigation/other one-shot events with MvRxLauncher (which I imagine you are not supposed to do that 😄 ). The main drawbacks are that it completely goes against several concepts of MvRx state like:
So as I see it right now the alternatives are:
|
@sanogueralorenzo A |
Maybe we could expose our flow operator to flow when started or wrap it with a Channel to make it feel and behave even more similarly. |
@sanogueralorenzo I'm also dealing with this scenario and introducing a data class Trigger internal constructor(private val value: Int) {
internal var metadata: Any? = null
private inline fun map(f: (Int) -> Int): Trigger = Trigger(f(extract()))
private fun extract(): Int = value
fun activate() = activate { true }
fun activate(predicate: () -> Boolean) =
if (predicate()) this.map { it + 1 } else this
fun deactivate() = this.map { 0 }
fun <R> fold(ifInactive: () -> R, ifActive: () -> R): R =
if (this.extract() <= 0) ifInactive() else ifActive()
companion object {
fun create(): Trigger = Trigger(0)
/**
* Helper to set metadata on a Trigger instance.
*/
fun Trigger.setMetadata(metadata: Any) {
this.metadata = metadata
}
/**
* Helper to get metadata on a Trigger instance.
*/
@Suppress("UNCHECKED_CAST")
fun <T> Trigger.getMetadata(): T? = this.metadata as T?
}
} State: data class NameState(
val name: String? = null,
val error: Int? = null,
val navigateTrigger: Trigger = Trigger.create()
) : MvRxState VM: fun onNextClick() {
withState {
if (it.name.isNullOrBlank().not()) {
userManager.username = it.name
setState { copy(navigateTrigger = navigateTrigger.activate()) }
} else {
setState { copy(error = R.string.onboarding_name_error) }
}
}
} Fragment: viewModel.selectSubscribe(NameState::navigateTrigger, uniqueOnly()) {
it.fold({
// handle inactive state
}, {
// Navigate forward to next fragment (with backstack)
})
}) |
@gpeal a question about the
(The examples in parentheses are just... examples) The point is that the order must be testable and therefore guaranteed. However, because Mavericks processes states mostly on background threads, if my VM has a |
@erikhuizinga Each ViewModel has a single reducer thread and its stateFlow will always emit items in the same order. You can also use the MavericksTestRule to make it synchronous for tests. |
@gpeal thank you for your reply. I see that the state store uses its own thread pool to concurrently invoke state updates. Is it possible for a state update to be something like: // In my VM
private val eventChannel = Channel(BUFFERED)
val events = eventChannel.receiveAsFlow()
// In some function
setState {
eventChannel.trySend { myEvent }
myState
} Is the order of states and events guaranteed to be (I'm asking because I'm investigating whether or not to use Mavericks. I have no experience with it yet.) |
@erikhuizinga I think it's hard to understand the exact question without a more complete example of what you're trying to do. |
@gpeal
I understand that the original question is about putting one-shot event behaviour in the view state and that might not be a good place in the Mavericks paradigm. However, it is reality: a view is not only a pure function of state, but it behaves more dynamically: it goes through lifecycle changes and it has the ability to consume message and navigation events. That's why some developers want to put that consumable behaviour inside the view state too. It makes it easy to test and reproduce, and it makes views simpler as they almost trivially consume the states and events. That also means that view state and view behaviour should stay in sync. If not in sync, then the view state might not yet be up to date while an event is consumed, possibly degrading UX. |
data class MyState(
val prop1: Async<Foo> = Uninitialized,
val prop2: Async<Foo> = Uninitialized,
val prop3: Async<Foo> = Uninitialized,
) : MavericksState {
val isLoading = prop1 is Loading || prop2 is Loading || prop3 is Loading
val showError = !isLoading && (prop1 is Fail || prop2 is Fail || prop3 is Fail)
}
|
Thank you very much. |
Looking back at this my example was pretty poor since it should have been an Async property. Even then, I think the main problem / confusion / request comes from the fact that we probably come from MVP or MVVM with something like SingleLiveEvent where we are used to move this logic to the view model instead of keeping it simple. From my experience moving it to the ViewModel to have it unit tested made redundant tests (when a navigation derived property is easier to unit test for ex.) and the other drawback is that it created a bit of a bounce effect between ViewModel and Fragment which we saw with MVP or SingleLiveEvents Maybe it would be a good idea to collect here examples of simple requirements that would cause a navigation / side effect and then we can create a How-to documentation so people are aware of different options before trying to do something like they would do with MVVM (which was my personal experience on my previous company) Requirements could be (navigation side effects examples):
|
Hello, creating this issue to gather some feedback or better solutions on handling one-shot events / side effects.
TLDR: Under some circumstances where you need to allow backwards navigation I run into a scenario where you have to combine
uniqueOnly()
+ anAsync<Unit>
to allow user backwards & forward navigation (sending the same event). The Async seems like a bad workaround.uniqueOnly()
is required so when the user presses back and goes to the previous screen we don't trigger the navigation event again, makes sense 👍Async<Unit>
+.execute()
is used to force a change in the state (since it will trigger Loading & Success every time). Replacing Async with a normal variable will become a problem if the user decides to navigate back and try to navigate forward again since clicking the button will set the same value as before, not triggering anything on the UI side.To make it easier imagine the following scenario:
User sees a screen that only requires name input
Attempting to continue without a name provides an error (simple empty validation)
After name input & clicking next, navigate to the next screen
User is able to press back to show this fragment again and update the name or just check it was right (clicking next again)
Looking at the code side:
State:
ViewModel:
Fragment:
How would you approach this scenario to allow the backward & then forward navigation?
Thank you
The text was updated successfully, but these errors were encountered: