Animation / Animation Set / AnimationListener / Transition

Manual Layout Transitions – Part 3

Layout transitions are an important aspect of Material design as they help to indicate the user flow through an app, and help to tie visual components together as the user navigates. Two important tools for achieving this are Activity Transitions (which we’ll cover in the future) and Layout Transitions which we have covered on Styling Android before. However Layout Transitions are only supported in API 19 and later. Previously we created two distinct layout states and were able to toggle between them, in this article we’ll get them animating.

We already have the basis for the animations that we’re going to create. We have two static layout states giving us the start and end states so we just need to animate between these states. Simple!

The two layouts we defined have identical Views with identical ID, only the visibility and position changes between the two states, so we just need to detect the nature of the change and apply the appropriate translation or alpha animation to each View. It is worth remembering that, because we’re are inflating a new layout, while the type and IDs of Views may be identical in both layouts, they will be represented by different View objects. Also, by the time we have transitioned to the new layout, the old one will no longer be in scope so we cannot determine the state of the controls in the old layout at that time. Therefore we need a mechanism of storing the specific View state attributes from the Views in the old layout:

public final class ViewState {
    private final int top;
    private final int visibility;

    public static ViewState ofView(View view) {
        int top = view.getTop();
        int visibility = view.getVisibility();
        return new ViewState(top, visibility);
    }

    private ViewState(int top, int visibility) {
        this.top = top;
        this.visibility = visibility;
    }

    public boolean hasMovedVertically(View view) {
        return view.getTop() != top;
    }

    public boolean hasAppeared(View view) {
        int newVisibility = view.getVisibility();
        return visibility != newVisibility && newVisibility == View.VISIBLE;
    }

    public boolean hasDisappeared(View view) {
        int newVisibility = view.getVisibility();
        return visibility != newVisibility && newVisibility != View.VISIBLE;
    }

    public int getY() {
        return top;
    }
}

This is pretty simple because we’re only interested in the vertical offset and the visibility of each View. We also have a couple of helper methods so that we can determine the delta between a new View object.

So now we have a mechanism for storing the state of the outgoing View objects let’s take a look at how we go about doing that. We already have the mechanism in place for switching layouts which is done in the TransitionController when it calls setContentView() on our Activity. So what we need to do is, just before we call that, capture the state of the Views in the layout that is just about to be replaced. We’ll do this using a class named TransitionAnimator which will be responsible for calculating and executing the animations. So Part3TransitionController looks like this:

public class Part3TransitionController extends TransitionController {

    Part3TransitionController(WeakReference<Activity> activityWeakReference, AnimatorBuilder animatorBuilder) {
        super(activityWeakReference, animatorBuilder);
    }

    public static TransitionController newInstance(Activity activity) {
        WeakReference<Activity> activityWeakReference = new WeakReference<>(activity);
        AnimatorBuilder animatorBuilder = AnimatorBuilder.newInstance(activity);
        return new Part3TransitionController(activityWeakReference, animatorBuilder);
    }

    @Override
    protected void enterInputMode(Activity activity) {
        createTransitionAnimator(activity);
        activity.setContentView(R.layout.activity_part2_input);
    }

    @Override
    protected void exitInputMode(Activity activity) {
        createTransitionAnimator(activity);
        activity.setContentView(R.layout.activity_part2);
    }

    private void createTransitionAnimator(Activity activity) {
        ViewGroup parent = (ViewGroup) activity.findViewById(android.R.id.content);
        View inputView = parent.findViewById(R.id.input_view);
        View inputDone = parent.findViewById(R.id.input_done);
        View translation = parent.findViewById(R.id.translation);

        TransitionAnimator.begin(parent, inputView, inputDone, translation);
    }
}

The addition here is the createTransitionAnimator() method which finds the appropriate View objects in the current layout and begins a TransitionAnimator instance. This method gets called before activity.setContentView() in both enterInputMode() and exitInputMode() methods. The important thing to remember here is that TransitionAnimator will just transition between two layout states, so requires no prior knowledge of those layouts other that details of the Views that we’re interested in animating.

So let’s take a look at TransitionAnimator:

public final class TransitionAnimator implements ViewTreeObserver.OnPreDrawListener {
    private final ViewGroup parent;
    private final SparseArray<ViewState> startStates;
    private final AnimatorBuilder animatorBuilder;

    public static void begin(ViewGroup parent, View... views) {
        SparseArray<ViewState> startStates = buildViewStates(views);
        AnimatorBuilder animatorBuilder = AnimatorBuilder.newInstance(parent.getContext());
        final TransitionAnimator transitionAnimator = new TransitionAnimator(animatorBuilder, parent, startStates);
        ViewTreeObserver viewTreeObserver = parent.getViewTreeObserver();
        viewTreeObserver.addOnPreDrawListener(transitionAnimator);
    }

    private TransitionAnimator(AnimatorBuilder animatorBuilder, ViewGroup parent, SparseArray<ViewState> startStates) {
        this.animatorBuilder = animatorBuilder;
        this.parent = parent;
        this.startStates = startStates;
    }

    private static SparseArray<ViewState> buildViewStates(View... views) {
        SparseArray<ViewState> viewStates = new SparseArray<>();
        for (View view : views) {
            viewStates.put(view.getId(), ViewState.ofView(view));
        }
        return viewStates;
    }
    .
    .
    .
}

The static begin() method gets called by the TransitionController to kick things off.

Firstly begin() calls buildViewStates() which iterates through the Views passed in an constructs ViewState objects for each which it sores in a SparseArray indexed by the relevant View IDs. It then instantiates an AnimatorBuilder object (which we’ve discussed previously), and uses this the ViewStates and the parent layout container to construct a TransitionAnimator instance.

Now comes the clever bit. We have captured the states of the views from the outgoing layout which hasn’t gone anywhere yet, but we now need to do something once the new layout has been created. We can’t simply inflate the layout and introspect it because the child Views will not have correct positioning until we have gone through the measurement and layout passes which happen once it gets attached to the parent container. But what we can do is register on OnPreDrawListener with the parent container. This will enable us to get a callback just before it is about to draw next time. As the TransitionController is just about to call setContentView() on the Activity, this callback will be made once the new layout has been inflated and the measurement and layout passes have completed, but before anything is drawn.

TransitionAnimator implements ViewTreeObserver.OnPreDrawListener and gets registered as the OnPreDrawLister, and its onPreDraw() method will get called just before the new layout is drawn:

@Override
public boolean onPreDraw() {
    ViewTreeObserver viewTreeObserver = parent.getViewTreeObserver();
    viewTreeObserver.removeOnPreDrawListener(this);
    SparseArray<View> views = new SparseArray<>();
    for (int i = 0; i < startStates.size(); i++) {
        int resId = startStates.keyAt(i);
        View view = parent.findViewById(resId);
        views.put(view.getId(), view);
    }
    Animator animator = buildAnimator(views);
    animator.start();
    return false;
}

The first thing it does is unregister itself – we don’t want this operation to occur before every draw operation – it’s relatively heavyweight and it will kill or animation speeds if we forget to unregister. We only want it to be invoked when we replace the layout and need to begin the layout state transition animations.

The next thing it does is iterate through the SparseArray of starting ViewStates and looks up the View matching each saved state in the current layout. It then passes this array of Views in the current to buildAnimator():

private Animator buildAnimator(SparseArray<View> views) {
    AnimatorSet animatorSet = new AnimatorSet();
    List<Animator> animators = new ArrayList<>();
    for (int i = 0; i < views.size(); i++) {
        int resId = views.keyAt(i);
        ViewState startState = startStates.get(resId);
        View view = views.get(resId);
        animators.add(buildViewAnimator(view, startState));
    }
    animatorSet.playTogether(animators);
    return animatorSet;
}

This builds an AnimatorSet containing all of the individual View Animators which will be run in parallel. For each View buildViewAnimator() is called which constructs the appropriate Animator:

private Animator buildViewAnimator(final View view, ViewState startState) {
    Animator animator = null;
    if (startState.hasAppeared(view)) {
        animator = animatorBuilder.buildShowAnimator(view);
    } else if (startState.hasDisappeared(view)) {
        final int visibility = view.getVisibility();
        view.setVisibility(View.VISIBLE);
        animator = animatorBuilder.buildHideAnimator(view);
        animator.addListener(
                new AnimatorListenerAdapter() {
                    @Override
                    public void onAnimationEnd(@NonNull Animator animation) {
                        super.onAnimationEnd(animation);
                        view.setVisibility(visibility);
                    }
                });
    } else if (startState.hasMovedVertically(view)) {
        int startY = startState.getY();
        int endY = view.getTop();
        animator = animatorBuilder.buildTranslationYAnimator(view, startY - endY, 0);
    }

    return animator;
}

This determines the transition type of each View by calling the helper methods on the ViewState object which we saw earlier. There are three possible transitions: An invisible View becomes visible, a visible View becomes invisible, and a View moves vertically. For each of these we construct the appropriate Animator.

That’s it. If we run this we can see our transitions are working quite nicely:

While this works quite nicely there is one fairly major issue with it: It is fine if all of the Views in our starting layout have corresponding Views in the end layout and vice versa. But that won’t always be the case. In the next article we’ll look at how we can adapt things further to allow for this particular case.

The source code for this article is available here.

© 2015, Mark Allison. All rights reserved.

Copyright © 2015 Styling Android. All Rights Reserved.
Information about how to reuse or republish this work may be available at http://blog.stylingandroid.com/license-information.

Leave a Reply

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

This site uses Akismet to reduce spam. Learn how your comment data is processed.