Master Detail with Square’s Flow


Update

I got some amazing feedback from Ray Rayan and Logan Johnson. I’ll likely be creating a supplemental post to cover their notes. One thing that should definitely go in this post is that Flow is in Alpha and the API isn’t stable. The api can change, so if you decide to use it and I hope you do 😉 you’ve been warned.

There is a lot of debate about if Fragments are good or bad. I think it’s good to explore both sides of the argument. In this post I will be covering my initial attempt at creating a master detail sample app that works both on the smaller phone size screen and tablet using Square’s Flow library.

Understanding of Flow

Name UI states, navigate between them, remember where you’ve been.


mmm_waffles

I was confused when I first started using flow. The wonderful thing about Flow is that it is intended to be extensible. It’s up to us to decide how to use it to fit our needs. This is the part where flow for me sings. In the past I’ve worked on apps that had wizards that needed to change the back stack after progressing to certain stages. Fragments did not lend well for this use case. This arguably was bad design, but it was driven by an actual physical use case that necessitated changing the back stack. Now that I’ve been using it for a while I like to think of it as a library for having a custom back stack. A back stack of arbitrary entries(waffles, screens etc), and it’s up to us to decide what to do with the items in the back stack(though usually tied into a view). In flow’s terminology the back stack entries are called “Keys.”

The first thing that we have to do to get Flow up and running is override attachBaseContext in our activity:

@Override 
protected void attachBaseContext(Context baseContext) {

    baseContext = Flow.configure(baseContext, this) //
            
                  .defaultKey(new DefaultKey())

                  .addServicesFactory(new FlowServices())
                  .dispatcher(new BasicDispatcher(this)) //

                  .install();
    super.attachBaseContext(baseContext);

}

I was confused about all the different parts for the initialization of flow. Here is my take on what each one does:

Flow.configure creates a Installer object using the builder design pattern that configure to before installing.

Installer.defaultKey here we set the key that the back stack will start out with. If we leave this blank it will be initialized with a string that says hello world. This wasn’t expected on my part and caused the app to crash when I was in early stages of developing this example.

Installer.addServicesFactory this method is for allowing us to add in custom services on a Context with a given key. We can then get access to the Service with the key. Since the implementation of the Factory is up to us to decide we can create shared services for allowing different keys to use a shared service.

Installer.dispatcher this method is where we set our Dispatcher. Dispatcher is an interface that has one callback:

/**
* Called when the history is about to change.  Note that Flow does not consider the
 
* Traversal to be finished, and will not actually update the history, until the callback is
 
* triggered. Traversals cannot be canceled.
 
*
 * @param callback Must be called to indicate completion of the traversal.
 */
void dispatch(@NonNull Traversal traversal, @NonNull TraversalCallback callback);

I’d point out here the comments are particularly useful in that dispatch isn’t about one new state but about the previous states and the new states. As dispatch will be called not just when adding a new key but also going back to a previous key. Additionally in the Traversal object gives access to the whole back stack.

Here is what I came up with for BasicDispatcher(I’ll go into more details in a bit about what each part does):

public static class BasicDispatcher implements Dispatcher {


       private final Activity activity;



       BasicDispatcher(Activity activity) {
        
              this.activity = activity;
       }



       @Override

       public void dispatch(@NonNull Traversal traversal, @NonNull TraversalCallback callback) {

        
              
              Object dest = traversal.destination.top();


              @LayoutRes final int layout;
              ViewGroup target = null;

              if (dest instanceof DefaultKey) {

                      DefaultKey key = (DefaultKey) dest;
                      showLayout(activity, R.layout.activity_main);
                      callback.onTraversalCompleted();
                      Flow.get(activity).set(new ContactsUiKey());

                      return;

              } else if (dest instanceof ContactsUiKey) {

                     layout = R.layout.list_contacts_screen;

                     target = getLeftPane();

              } else if (dest instanceof EditNameScreen) {

                     layout = R.layout.edit_name_screen;
                     target = getRightPane();

              } else {

                     throw new AssertionError("Unrecognized screen " + dest);
              }

        

              if (target.getChildCount() > 0) {

                      target.removeAllViews();
        
              }



              View incomingView = LayoutInflater.from(traversal.createContext(dest, activity)) //
                
                      .inflate(layout, target, false);

        
              target.addView(incomingView);
        
              traversal.getState(traversal.destination.top()).restore(incomingView);


              callback.onTraversalCompleted();


       }

       private void showLayout(Context context, @LayoutRes int layout) {
              LayoutInflater inflater = LayoutInflater.from(context);
        
              activity.setContentView(inflater.inflate(layout, null));
    
       }



       private ViewGroup getLeftPane() {

              return getViewGroup(R.id.left_pane, R.id.single_pane);
       }



       private ViewGroup getRightPane() {

              return getViewGroup(R.id.right_pane, R.id.single_pane);

       }



       private ViewGroup getViewGroup(@IdRes int preferred, @IdRes int backup) {
        
              ViewGroup backupViewGroup = (ViewGroup) activity.findViewById(preferred);
              ViewGroup preferredViewGroup = (ViewGroup) activity.findViewById(backup);


              return preferredViewGroup != null ? preferredViewGroup : backupViewGroup;

       }

}

In this implementation the dispatch method does a few things. First it determines which view needs to be added based off of the type of key presented. Second it determines which view group to add the View. This second part is important for differentiating between placing the contents for a tablet device or a phone. Lastly it removes old content that was previously in the view.

We get the top most item on the back stack to determine how to handle the key.

 Object dest = traversal.destination.top();



Then we determine which action to perform:

if (dest instanceof DefaultKey) {
        ...
} else if (dest instanceof ContactsUiKey) {
        ...
} else if (dest instanceof EditNameScreen) {
        ...
} else {

        throw new AssertionError("Unrecognized screen " + dest);
}



The logic I was going with for this was to have two different layouts with the same layout name and leave it up to Android to pull the correct one via screen size qualifiers. We then look for the ViewGroup that corresponds to the side of the tablet we’d like to place the view into. If the pane isn’t available then we default to grabbing the ViewGroup for the phone and add the view there.



For example say that we want to display a list of contacts. We’d first try to place it in the left pane, but if we couldn’t find that id then we’d know that we were on a phone and should instead default to locating the viewgroup with the id single pane and put it there instead.

       ...
       } else if (dest instanceof ContactsUiKey) {

              layout = R.layout.list_contacts_screen;

              target = getLeftPane();

       }

       ...
       private ViewGroup getLeftPane() {

              return getViewGroup(R.id.left_pane, R.id.single_pane);
       }

       private ViewGroup getViewGroup(@IdRes int preferred, @IdRes int backup) {
        
              ViewGroup backupViewGroup = (ViewGroup) activity.findViewById(preferred);
              ViewGroup preferredViewGroup = (ViewGroup) activity.findViewById(backup);


              return preferredViewGroup != null ? preferredViewGroup : backupViewGroup;

       }


This works
workworkwork

Improvement

hierarchy_viewer_remove_views

This particular method of handling the difference between a single and dual pane layout works. However it does have ViewGroups that aren’t needed. In the case of the dual pane example we have two ViewGroups that hold the left and the right pane. Ideally these ViewGroups wouldn’t exist at all. But we’d have to add additional logic for determining if a View is already in place and should be removed etc. I’d also look at refactoring DefaultKey logic to possibly always include the logic for ContactsUiKey, or possibly make it only add on the first run(depending on how I want to handle the back button logic).

Leave a Reply

Your email address will not be published. Required fields are marked *