States and Events
Back in the chapter on dialogs, we had a scenario that sounds similar to the view-states that we have seen in this chapter. We had a ConfirmationDialogFragment
displaying an AlertDialog
, and we wanted to get the button-click events from the ConfirmationDialogFragment
back to the MainFragment
that triggered the dialog to be shown.
It may seem like we can do the same thing there as we did here: use LiveData
and have the ConfirmationDialogFragment
emit a view-state that the MainFragment
observes. In truth, that is close to what we need but not quite the same, because “states” and “events” are subtly different, particularly in Android.
Definitions
In MainFragment
, if the user clicks the positive button in the dialog, we want to show a Toast
. A Toast
has its own lifecycle: it appears and vanishes on its own.
What we do not want to do is display a Toast
again after a configuration change. The Toast
should appear exactly once per positive button click. If the user clicks the positive button, sees the Toast
, then rotates the screen, we should not re-display the Toast
.
As a result, the “did the user click the positive button” output from ConfirmationDialogFragment
cannot readily be part of a view-state. A view-state represents data that we want to survive a configuration change. If MainActivity
wanted to update its button caption based on whether the user had accepted the dialog in its most recent invocation, then we would want the “did the user click the positive button” to be part of the view-state, so we could show the right caption after a configuration change. That does not work well in our Toast
scenario: MainFragment
would get the most-recent view-state after a configuration change, see that the positive button had been clicked, and show the Toast
again.
In many respects, the problem itself is not the configuration change. In theory, what could happen is:
- The user clicks the really big button to display the dialog
- The user clicks the positive button on that dialog, then immediately rotates the screen, so fast that
MainFragment
has not had time to react to the positive button click
In that case, we still do want MainFragment
to show the Toast
… but only once.
In the terminology used in this book, we have “states” and “events”:
- A state is something that we want to survive a configuration change and use
- An event is something that we want to use exactly once, regardless of any configuration changes that may or may not occur
Impacts on Delivery
LiveData
is a value holder with a way to tell a set of observers when that value changes. It is designed for states, which is why we used it for the view-state in this chapter.
On its own, though, it is not well-suited for events. There is no built-in concept of “consuming” an event from LiveData
that would prevent that event from being consumed again after a configuration change.
The Jetpack does not have a great solution for this problem. The current “state of the art” for handling this varies based on your programming language.
Java: Single Live Event
Google’s original solution was what is called the “single live event” pattern. And, while Google has been backtracking away from that solution, it is still the best option for Java projects that have not adopted RxJava as a reactive API, where RxJava projects would use PublishSubject
or similar options.
The single live event pattern still has us deliver via LiveData
. However, we wrap the data that represents the event (e.g., a boolean of whether the user dismissed the dialog via the positive button or not) in a wrapper that tracks whether or not we have consumed the event.
In the Java edition of the NukeFromOrbit
sample module, that wrapper is called Event
:
package com.commonsware.jetpack.samplerj.dialog;
import androidx.lifecycle.Observer;
public final class Event<T> {
public interface Handler<T> {
void handle(T content);
}
public static class EventObserver<T> implements Observer<Event<T>> {
private final Event.Handler<T> handler;
public EventObserver(Handler<T> handler) {
this.handler = handler;
}
@Override
public void onChanged(Event<T> event) {
if (event != null) {
event.handle(handler);
}
}
}
private boolean hasBeenHandled = false;
private final T content;
public Event(T content) {
this.content = content;
}
private void handle(Event.Handler<T> handler) {
if (!hasBeenHandled) {
hasBeenHandled = true;
handler.handle(content);
}
}
}
At its core, Event
wraps some object (using generic type T
), referred to as content
. It has a handle()
method that takes a callback (Event.Handler
) and calls that callback if the Event
has not already been handled. Event
also provides an Observer
implementation called EventObserver
that handles an event received from a LiveData
, if that event has not already been handled.
When the user clicks one of the dialog buttons, or uses the system BACK button to dismiss the dialog, ConfirmationDialogFragment
calls either onAccept()
or onDecline()
on the GraphViewModel
:
package com.commonsware.jetpack.samplerj.dialog;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
import androidx.lifecycle.ViewModel;
public class GraphViewModel extends ViewModel {
private MutableLiveData<Event<Boolean>> results = new MutableLiveData<>();
LiveData<Event<Boolean>> getResultStream() {
return results;
}
void onAccept() {
results.postValue(new Event<>(true));
}
void onDecline() {
results.postValue(new Event<>(false));
}
}
GraphViewModel
in turn posts a boolean value to a MutableLiveData
called results
, where the boolean is wrapped in an Event
.
MainFragment
then sets up an EventObserver
to receive those boolean values:
vm.getResultStream().observe(getViewLifecycleOwner(),
new Event.EventObserver<>(wasAccepted -> {
if (wasAccepted) {
Toast.makeText(requireContext(), "BOOOOOOOM!",
Toast.LENGTH_LONG).show();
}
}));
So, when the user clicks a button, the EventObserver
handles the Event
and passes the underlying value to our code. If the user then rotates the screen or otherwise triggers a configuration change, while EventObserver
itself will receive the Event
from the LiveData
, since the Event
will have been marked as having been handled, EventObserver
does not wind up calling our code again. So, we get the boolean value exactly once per button click.
This works. It is a bit of a hack, but it works.
Kotlin: SharedFlow
The “bit of a hack” aspect is why Google would prefer that you use something else. In Kotlin, that “something else” right now is SharedFlow
and MutableSharedFlow
.
GraphViewModel
in Kotlin still has onAccept()
and onDecline()
functions that ConfirmationDialogFragment
calls. This time, though, they offer()
a boolean value to a MutableSharedFlow
named results
:
package com.commonsware.jetpack.sampler.dialog
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.launch
class GraphViewModel : ViewModel() {
private val results = MutableSharedFlow<Boolean>()
val resultStream = results.asSharedFlow()
fun onAccept() {
viewModelScope.launch { results.emit(true) }
}
fun onDecline() {
viewModelScope.launch { results.emit(false) }
}
}
A SharedFlow
works a bit like LiveData
and StateFlow
, in that it can have one or more observers and passes any offered object to each of them. Unlike LiveData
and StateFlow
, a SharedFlow
does not hold onto the last-offered object, so observers get each object exactly once. The relationship between SharedFlow
and MutableSharedFlow
is very much like the relationship between LiveData
and MutableLiveData
: MutableSharedFlow
has a read/write API, whereas SharedFlow
has a read-only API.
The MutableSharedFlow
is part of the GraphViewModel
implementation. Its API is in the form of a SharedFlow
created from that MutableSharedFlow
. The So, MainFragment
consumes those click results via that SharedFlow
:
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
vm.resultStream.collect { wasAccepted ->
if (wasAccepted) {
Toast.makeText(requireContext(), "BOOOOOOOM!", Toast.LENGTH_LONG)
.show()
}
}
}
}
This works much like the EventObserver
from the Java example. The lambda expression that we pass to collect()
will get called for each boolean offered by ConfirmationDialogFragment
and GraphViewModel
. And, as with the StateFlow
example earlier in this chapter:
-
collect()
gets wrapped inrepeatOnLifecycle()
, called on theLifecycle
object representing this fragment’s view lifecycle - That in turn is wrapped in
launch()
on theLifecycleScope
associated with this fragment’s view lifecycle, so the entire coroutine can be canceled cleanly when the fragment’s views are destroyed
Prev Table of Contents Next
This book is licensed under the Creative Commons Attribution-ShareAlike 4.0 International license.