The View
The MainActivity
and its fragments implement the “view” layer of the MVI architecture. These classes have two primary jobs:
- Render the view state when it arrives
- 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:
- It holds onto the
Controller
to be used by the view and forwards actions from the view to thatController
- 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:
- Pass the
ViewState
along to theRosterListAdapter
, to update the contents of theRecyclerView
- Hide, show, and update the prose for the empty view, as appropriate
- If we happen to have some other
Snackbar
showing (e.g., from a delete request), dismiss it
The empty view is a bit complicated, because we have three conditions:
- There are items in the list, in which case we do not want to show the empty view, so we mark it
GONE
- There are no items in the list, because there are simply no items at all
- 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.