The View

The MainActivity and its fragments implement the “view” layer of the MVI architecture. These classes have two primary jobs:

  1. Render the view state when it arrives
  2. Create actions and get them over to the controller

Of course, this is Android, and so there are other details to be worried about. Configuration changes are chief among those details.

Right now, we will focus on RosterListFragment, the fragment for displaying the list of to-do items. Later on, we will look briefly at the other two fragments. Note that RosterListFragment inherits from an AbstractRosterFragment, as some of its logic is shared with DisplayFragment.

Incorporating a ViewModel

The fragments use a ViewModel, named RosterViewModel, as the state to be retained across configuration changes. To that end, MainActivity is a FragmentActivity and the three fragments each extend from the support libraries’ edition of Fragment. The fragments hold onto the RosterViewModel in a viewModel field and initialize it in onCreate():

  @Override
  public void onCreate(@Nullable Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);

    viewModel=ViewModelProviders.of(getActivity()).get(RosterViewModel.class);
  }

Notice that we are passing the activity into of(), not the fragment. As a result, all three fragments share a common RosterViewModel. For relatively tightly-coupled fragments, this likely will be a common pattern.

RosterViewModel has two key roles:

  1. It holds onto the Controller to be used by the view and forwards actions from the view to that Controller
  2. It serves as the reducer, receiving results from the Controller and converting them into updated view states, delivering those to the view as results arrive

Receiving View States

We need to get ViewState instances from the RosterViewModel to the fragments, when results arrive from the Controller.

To that end, RosterViewModel has a LiveData object, representing a stream of view states. That is made available to the view layer via a simple stateStream() method:

  public LiveData<ViewState> stateStream() {
    return(states);
  }

Our fragments then use that LiveData to subscribe to the stream and route the ViewState objects to a render() method:

    viewModel.stateStream().observe(this, this::render);

Rendering View States

Each of our three fragments has its own render() method, responsible for taking the data in our ViewState and updating its own bit of the UI to match.

In the case of the RosterListFragment, render() is responsible for populating the RecyclerView:

  @Override
  void render(ViewState state) {
    if (adapter!=null) {
      if (state.cause()==null) {
        adapter.setState(state);

        if (state.isLoaded() && state.filteredItems().size()==0) {
          getEmptyView().setVisibility(View.VISIBLE);

          if (state.items().size()>0) {
            getEmptyView().setText(R.string.msg_empty_filter);
          }
          else {
            getEmptyView().setText(R.string.msg_empty);
          }
        }
        else {
          getEmptyView().setVisibility(View.GONE);
        }

        if (state.getSelectionCount()==0 && snackbar!=null &&
          snackbar.isShown()) {
          snackbar.dismiss();
        }
      }
      else {
        Snackbar
          .make(getView(), R.string.msg_crash, Snackbar.LENGTH_LONG)
          .show();
        Log.e(getClass().getSimpleName(), "Exception in obtaining view state",
          state.cause());
      }
    }
  }

If we have no adapter, then our UI has not been set up just yet, and so we need to skip this rendering event.

If the ViewState contains a cause(), we show a Snackbar to alert the user to the problem, plus log the Throwable to Logcat for debugging purposes.

In the more normal case, where everything worked and we have our UI, we:

The empty view is a bit complicated, because we have three conditions:

  1. There are items in the list, in which case we do not want to show the empty view, so we mark it GONE
  2. There are no items in the list, because there are simply no items at all
  3. There are items in the list, but the current filter mode that the user has chosen blocks all of them (e.g., the user chose outstanding items and all items are completed)

The ViewState has a helper method, filteredItems(), which returns only the subset of the items() list that apply for the currently-chosen filter:

  @Memoized
  public List<ToDoModel> filteredItems() {
    return(ToDoModel.filter(items(), filterMode()));
  }

Here, @Memoized means that the ViewState will cache the results of computing this list, to save time on subsequent calls — this is a feature of AutoValue.

ToDoModel.filter(), in turn, does the actual filtering:

  public static List<ToDoModel> filter(List<ToDoModel> models,
                                       FilterMode filterMode) {
    List<ToDoModel> result;

    if (filterMode==FilterMode.COMPLETED) {
      result=new ArrayList<>();

      for (ToDoModel model : models) {
        if (model.isCompleted()) {
          result.add(model);
        }
      }
    }
    else if (filterMode==FilterMode.OUTSTANDING) {
      result=new ArrayList<>();

      for (ToDoModel model : models) {
        if (!model.isCompleted()) {
          result.add(model);
        }
      }
    }
    else {
      result=new ArrayList<>(models);
    }

    return(result);
  }

The implementation of setState() on RosterListAdapter is responsible for updating the RecyclerView contents. That is fairly complicated and not particularly relevant for the discussion of MVI, so we will skip that here, other than to note that it uses DiffUtil to animate any relevant changes to the visible rows in the list, comparing the new view state with its predecessor.


Prev Table of Contents Next

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