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:

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”:

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:


Prev Table of Contents Next

This book is licensed under the Creative Commons Attribution-ShareAlike 4.0 International license.