What is the prefered way to save/restore screen state with Flow + Mortar + Dagger2?

1k Views Asked by At

I'm trying to convert an Acticity + Fragments app to Flow + Mortar + Dagger2

I would like to save & restore screen state when jumping from screen to screen (at least backwards). What is the prefered/recommanded way to do that ?

I have spent quite a lot of time looking at flow and mortar readmes and samples but couldn't figure it out (the documentation and samples are minimal and only deal with simple/static/unique data).

Say, for example, you have a browser-like app that moves from Page to Page Where each Page use the same same PageView class, the same PagePresenter Class but have different dynamic content depending on a url-string typed by a user

It's quite complex/hard to use Dagger2 (compile type annotation) to save/restore the states by injection, right ? (This would require a complex parent/cache structure)

I googled a bit and stumbled upon : https://github.com/lukaspili/flow-navigation

Yet this is mostly an experiment... If possible, I would rather base my production money making app on a solution that is official/reliable/tested/supported/backed by square

I also looked at :

1) https://github.com/square/flow/issues/11 But the sample injects data with Dagger in 2 screens with different view classes (not a valid answer)

2) https://github.com/square/flow/issues/76 (no answer given)

3) Mortar / Flow save view and presenter in backstack

I saw this also : We're doing this now internally by simply adding a mutable field to our screen objects:

public void setViewState(SparseArray<Parcelable> viewState) {
this.viewState = viewState;
}

public void restoreHierarchyState(View view) {
view.restoreHierarchyState(viewState);
}

When a View is swapped out, we grab its instance state and set it on the screen object (which is already on the backstack). We're going to live with this pattern a little while before promoting it into the library.

But neither the flow sample nor the mortar sample use this solution (they use dagger2 to inject lists...not a valid solution again)

So, what is the UP TO DATE best/recommanded way to restore/save screen state in a mortar+flow (+dagger2) app ?

3

There are 3 best solutions below

0
On

After experimenting a bit, I added some mutable fields to my Path objects.

By design, a mortar/Flow app uses a StateParceler to serialize/unserialize those Path objects to/from Bundles and Parcels in order to save and restore view states

By making the StateParceler take care of those mutable fields, they are able to survive orientation change and back-navigation.

By making the StateParceler also able to serialize/unserialize those mutable fields to Persistant storage (say JSon and SharedPreferences), a complete history can survive power down/different app sessions

The sample from square uses a GsonParceler that is able to do that out of the box for most objects. You just have to write some code to make it able to take care of collections and complex objects with Generics/interface...

2
On

First some facts about Flow & Flow-path

  1. Flow saves the view state of the previous view, and try to restore the state of the new view if it was saved previously.
    And by view state, I mean the android view state, totally independent from Flow. It does not save the Mortar scope associated with the previous screen.
    The snippet of code you copy-pasted is already implemented in Flow, and does exactly what I said above.

  2. With Flow-path, the logic that defines how to go from screen A to screen B, how to animate the view transition from A to B, and how to setup/destroy PathContext wrappers of A and B, is defined in a PathContainer class.

  3. PathContext is a class from Flow-path that setups the context associated with a Screen and its View (it's a wrapper around the android.content.Context, in the same way that works the Mortar context wrapper). You would usually also have a custom PathContextFactory that is called by the PathContainer and that setups the Mortar scope associated to the screen and to the PathContext.

  4. Flow-path does not provide any "official" implementation of PathContainer. The only one is the SimplePathContainer in the sample project.
    If you look at the source code of SimplePathContainer, you will see that it destroys the path context associated to the previous screeen. By destroying its context, it also destroys its Mortar scope, and everything that's within, such as the Dagger2 component that holds the instance of the ViewPresenter.

  5. If you want to preserve the Mortar scope of the previous screen, the only way of doing this is to write your own implementation of PathContainer that does not destroy the previous scopes in history. This is what basically does Flow-navigation (https://github.com/lukaspili/flow-navigation).

  6. StateParceler is used to save/restore the flow history stack in/from Bundle. As you said, its purpose is to make the history survive the configuration changes and app process kill.
    However, with proper Mortar configuration, the Mortar scopes are not destroyed during configuration changes, and thus, you don't need to save/restore your ViewPresenter instances, because those instances are not destroyed (only the Views). You would still have to do it for process kill though.

And now my 2cents:

Flow-navigation was the first proof of concept of not destroying the mortar scope of the previous screens in history (backstack).
Since then, I wrote an alternative Flow library from scratch, that handles the navigation, manages a history of Mortar scopes and provides view transitions in a decoupled way that suits better my needs: https://github.com/lukaspili/Mortar-architect

Because you are looking for a solution supported and backed by Square, this won't work for you. However, I invite you to check out the source code, and that may give you an idea on how to write your own PathContainer that preserves the Mortar scopes in the Flow history.

0
On

Based on @lukas 's answer and his library flow-navigation, I've realized that the offending call that destroys the MortarScope is this line:

oldPath.destroyNotIn(context, contextFactory);

So I replaced that with

  public static PathContext create(PathContext previousContext, Path newPath, PathContextFactory factory) {
    if(newPath == Path.ROOT) {
      throw new IllegalArgumentException("Path is empty.");
    }
    List<Path> newPathElements = newPath.elements();
    Map<Path, Context> newContextChain = new LinkedHashMap<>();
    // We walk down the elements, reusing existing contexts for the elements we encounter.  As soon
    // as we encounter an element that doesn't already have a context, we stop.
    // Note: we will always have at least one shared element, the root.
    Context baseContext = null;
    Iterator<Path> pathIterator = newPathElements.iterator();
    Iterator<Path> basePathIterator = previousContext.path.elements().iterator();
    Log.d("PathContext", ":: Creating Context to [" + ((BasePath) newPath).getScopeName() + "]");
    while(pathIterator.hasNext() && basePathIterator.hasNext()) {
      Path element = pathIterator.next();
      Path basePathElement = basePathIterator.next();
      if(basePathElement.equals(element)) {
        if(!element.isRoot()) {
          Log.d("PathContext",
                  "Matched new Path to old Path [" + ((BasePath) element).getScopeName() + "], preserving context.");
        } else {
          Log.d("PathContext", "Matched new Path to old Path [ROOT], preserving context.");
        }

        baseContext = previousContext.contexts.get(element);
        newContextChain.put(element, baseContext);
      } else {
        if(!basePathElement.isRoot() && !element.isRoot()) {
          Log.d("PathContext",
                  "No match from [" + ((BasePath) basePathElement).getScopeName() + "] to [" + ((BasePath) element)
                          .getScopeName() + "] , creating new context.");
        } else {
          Log.d("PathContext",
                  "No match from ROOT [" + basePathElement + "] to ROOT [" + element + "] , creating new context.");
        }

        baseContext = factory.setUpContext(element, baseContext);
        newContextChain.put(element, baseContext);
        break;
      }
    }
    // Now we continue walking our new path, creating contexts as we go in case they don't exist.
    while(pathIterator.hasNext()) {
      Path element = pathIterator.next();
      if(!element.isRoot()) {
        Log.d("PathContext", "Creating new path [" + ((BasePath) element).getScopeName() + "].");
      } else {
        Log.d("PathContext", "Creating new path [ROOT].");
      }
      baseContext = factory.setUpContext(element, baseContext);
      newContextChain.put(element, baseContext);
    }
    // Finally, we can construct our new PathContext
    return new PathContext(baseContext, newPath, newContextChain);
  }

  /**
   * Finds the tail of this path which is not in the given path, and destroys it.
   */
  public void destroyNotIn(PathContext path, PathContextFactory factory) {
    Iterator<Path> aElements = this.path.elements().iterator();
    Iterator<Path> bElements = path.path.elements().iterator();
    while(aElements.hasNext() && bElements.hasNext()) {
      Path aElement = aElements.next();
      Path bElement = bElements.next();
      if(!aElement.equals(bElement)) {
        BasePath aBasePath = (BasePath) aElement;
        BasePath bBasePath = (BasePath) bElement;
        Log.d(toString(),
                "Destroying [" + aBasePath.getScopeName() + "] on matching with [" + bBasePath.getScopeName() + "]");
        factory.tearDownContext(contexts.get(aElement));
        break;
      }
    }
    while(aElements.hasNext()) {
      Path aElement = aElements.next();
      BasePath aBasePath = (BasePath) aElement;
      Log.d(toString(), "Destroying [" + aBasePath.getScopeName() + "] as it is not found in [" + path + "]");
      factory.tearDownContext(contexts.get(aElement));
    }
  } 

BasePath just returns getScopeName(). It's essentially like BluePrint which Square had removed.

And also, the SimplePathContainer destroyed all previous ones, so I modified that too.

/**
 * Provides basic right-to-left transitions. Saves and restores view state.
 * Uses {@link PathContext} to allow customized sub-containers.
 */
public class SimplePathContainer
        extends PathContainer {
    private static final String TAG = "SimplePathContainer";

    private final PathContextFactory contextFactory;

    public SimplePathContainer(int tagKey, PathContextFactory contextFactory) {
        super(tagKey);
        this.contextFactory = contextFactory;
    }

    @Override
    protected void performTraversal(final ViewGroup containerView, Flow.Traversal traversal, final PathContainer.TraversalState traversalState, final Flow.Direction direction, final Flow.TraversalCallback callback) {
        final PathContext oldPathContext;
        final PathContext newPathContext;
        if(containerView.getChildCount() > 0) {
            Log.d(TAG,
                    "Container View Child count was > 0, using view context of path [" + PathContext.get(containerView.getChildAt(
                            0).getContext()).path + "]");
            oldPathContext = PathContext.get(containerView.getChildAt(0).getContext());
        } else {
            Log.d(TAG, "Container View Child Count was == 0, using root context");
            oldPathContext = PathContext.root(containerView.getContext());
        }
        Log.d(TAG, "Previous Path [" + oldPathContext.path + "]");
        final Path to = traversalState.toPath();
        Log.d(TAG, "New Path [" + to + "]");

        View newView;
        newPathContext = PathContext.create(oldPathContext, to, contextFactory);

        int layout = ((BasePath) to).getLayout(); //removed annotation
        newView = LayoutInflater.from(newPathContext.getApplicationContext()) //fixed first path error
                .cloneInContext(newPathContext)
                .inflate(layout, containerView, false);
        View fromView = null;
        if(traversalState.fromPath() != null) {
            fromView = containerView.getChildAt(0);
            traversalState.saveViewState(fromView);
        }
        traversalState.restoreViewState(newView);

        if(fromView == null || direction == REPLACE) {
            containerView.removeAllViews();
            containerView.addView(newView);
            oldPathContext.destroyNotIn(newPathContext, contextFactory);
            callback.onTraversalCompleted();
        } else {
            final View finalFromView = fromView;
            if(direction == Flow.Direction.BACKWARD) {
                containerView.removeView(fromView);
                containerView.addView(newView);
                containerView.addView(finalFromView);
            } else {
                containerView.addView(newView);
            }
            ViewUtils.waitForMeasure(newView, new ViewUtils.OnMeasuredCallback() {
                @Override
                public void onMeasured(View view, int width, int height) {
                    runAnimation(containerView, finalFromView, view, direction, new Flow.TraversalCallback() {
                        @Override
                        public void onTraversalCompleted() {
                            containerView.removeView(finalFromView);
                            oldPathContext.destroyNotIn(newPathContext, contextFactory);
                            callback.onTraversalCompleted();
                        }
                    }, (BasePath) Path.get(oldPathContext), (BasePath) to);
                }
            });
        }
    }

    private void runAnimation(final ViewGroup container, final View from, final View to, Flow.Direction direction, final Flow.TraversalCallback callback, BasePath fromPath, BasePath toPath) {
        Animator animator = createSegue(from, to, direction);

        animator.addListener(new AnimatorListenerAdapter() {
            @Override
            public void onAnimationEnd(Animator animation) {
                container.removeView(from);
                callback.onTraversalCompleted();
            }
        });
        animator.start();
    }

    private Animator createSegue(View from, View to, Flow.Direction direction) {
        boolean backward = direction == Flow.Direction.BACKWARD;
        int fromTranslation = backward ? from.getWidth() : -from.getWidth();
        int toTranslation = backward ? -to.getWidth() : to.getWidth();

        AnimatorSet set = new AnimatorSet();

        set.play(ObjectAnimator.ofFloat(from, View.TRANSLATION_X, fromTranslation));
        set.play(ObjectAnimator.ofFloat(to, View.TRANSLATION_X, toTranslation, 0));

        return set;
    }
}