Animation / Animation Set / Transition / ViewOverlay

Manual Layout Transitions – Part 4

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 transitioning nicely, but there was a hard requirement of our implementation that any Views in our starting layout must have corresponding Views in the destination layout and vice versa. In this article we’ll look at removing that restriction.

The big complication of having differing View sets in our two layouts is easily recognised when we consider the case of a particular View which appears in our starting layout but does not appear in the destination layout:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
  android:layout_width="match_parent"
  android:layout_height="match_parent"
  android:clipChildren="false"
  android:orientation="vertical">

  <RelativeLayout
    android:layout_width="match_parent"
    android:layout_height="0dp"
    android:layout_weight="1">

    <View
      android:id="@+id/toolbar"
      android:layout_width="match_parent"
      android:layout_height="?attr/actionBarSize"
      android:background="?attr/colorPrimary" />

    <View
      android:id="@+id/focus_holder"
      android:layout_width="0dp"
      android:layout_height="0dp"
      android:focusableInTouchMode="true">

      <requestFocus />
    </View>

    <android.support.v7.widget.CardView
      android:id="@+id/input_view"
      android:layout_width="match_parent"
      android:layout_height="match_parent"
      android:layout_alignParentBottom="true"
      android:layout_below="@id/toolbar">

      <EditText
        android:id="@+id/input"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:inputType="textMultiLine" />

      <ImageView
        android:id="@+id/input_done"
        android:layout_width="32dip"
        android:layout_height="32dip"
        android:layout_alignBottom="@id/input"
        android:layout_alignEnd="@id/input"
        android:layout_alignRight="@id/input"
        android:layout_gravity="bottom|end"
        android:layout_margin="8dp"
        android:background="@drawable/done_background"
        android:contentDescription="@string/done"
        android:padding="2dp"
        android:src="@drawable/ic_arrow_forward"
        android:visibility="gone" />
    </android.support.v7.widget.CardView>

  </RelativeLayout>

  <FrameLayout
    android:layout_width="match_parent"
    android:layout_height="0dp"
    android:layout_weight="1">

    <android.support.v7.widget.CardView
      android:id="@+id/translation"
      android:layout_width="match_parent"
      android:layout_height="wrap_content"
      android:layout_margin="8dp">

      <View
        android:layout_width="match_parent"
        android:layout_height="100dp"
        android:background="?attr/colorPrimary" />
    </android.support.v7.widget.CardView>
  </FrameLayout>

</LinearLayout>

In this layout we have the CardView with the ID translation, and in this layout it is missing:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
  android:layout_width="match_parent"
  android:layout_height="match_parent"
  android:clipChildren="false"
  android:orientation="vertical">

  <RelativeLayout
    android:layout_width="match_parent"
    android:layout_height="0dp"
    android:layout_weight="1">

    <View
      android:id="@+id/toolbar"
      android:layout_width="match_parent"
      android:layout_height="?attr/actionBarSize"
      android:background="?attr/colorPrimary" />

    <View
      android:id="@+id/focus_holder"
      android:layout_width="0dp"
      android:layout_height="0dp"
      android:focusableInTouchMode="true" />

    <android.support.v7.widget.CardView
      android:id="@+id/input_view"
      android:layout_width="match_parent"
      android:layout_height="match_parent"
      android:layout_alignParentBottom="true"
      android:layout_alignParentTop="true"
      android:layout_marginBottom="?attr/actionBarSize">

      <EditText
        android:id="@+id/input"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:inputType="textMultiLine">

        <requestFocus />
      </EditText>

      <ImageView
        android:id="@+id/input_done"
        android:layout_width="32dip"
        android:layout_height="32dip"
        android:layout_alignBottom="@id/input"
        android:layout_alignEnd="@id/input"
        android:layout_alignRight="@id/input"
        android:layout_gravity="bottom|end"
        android:layout_margin="8dp"
        android:background="@drawable/done_background"
        android:contentDescription="@string/done"
        android:padding="2dp"
        android:src="@drawable/ic_arrow_forward" />
    </android.support.v7.widget.CardView>

  </RelativeLayout>

  <Space
    android:layout_width="match_parent"
    android:layout_height="0dp"
    android:layout_weight="1" />

</LinearLayout>

The problem that we have is that when our OnPreDraw() method gets call just before the new layout is drawn we there simply isn’t a View in the new layout for us to animate. There was in the old layout, but that has now been destroyed, so we can’t do anything with it. There’s a way around this if we’re targeting API 18 and later – we can use ViewOverlay which provides a mechanism for doing precisely what we need, but we’ve set minSdkVersion="15" on this project so we can’t use that. However, if we understand what ViewOverlay actually does, we can roll our own quite easily.

ViewOverlay is essentially a lightweight ViewGroup which is rendered on top of the current layout. By lightweight I mean that it does not perform the normal measure and layout passes for its children which most ViewGroups do. Instead it create a Bitmap representation of any View that gets promoted to the ViewOverlay. We can then perform animations on this Bitmap representation within the ViewOverlay even if the original View, from which the Bitmap was created, has since been destroyed.

With that understanding we can construct our own fairly easy. In our onPreDraw() callback we create a FrameLayout which we add to the parent layout. This will act as our ViewOverlay:

private ViewGroup viewOverlay;

@Override
public boolean onPreDraw() {
    ViewTreeObserver viewTreeObserver = parent.getViewTreeObserver();
    viewTreeObserver.removeOnPreDrawListener(this);
    Context context = parent.getContext();
    viewOverlay = new FrameLayout(context);
    parent.addView(viewOverlay);
    ViewGroup.LayoutParams layoutParams = viewOverlay.getLayoutParams();
    layoutParams.height = ViewGroup.LayoutParams.MATCH_PARENT;
    layoutParams.width = ViewGroup.LayoutParams.MATCH_PARENT;
    viewOverlay.setLayoutParams(layoutParams);
    SparseArray<View> views = new SparseArray<>();
    for (int i = 0; i < startStates.size(); i++) {
        int resId = startStates.keyAt(i);
        View view = parent.findViewById(resId);
        if (view == null) {
            ViewState startState = startStates.get(resId);
            view = addOverlayView(startState);
        }
        views.put(resId, view);
    }
    Animator animator = buildAnimator(views);
    animator.start();
    return false;
}

As well as creating the overlay, the other important thing in here is the handling of a View which does not exist in the new layout – so we call addOverlayView() to add it to the overlay. More on this later.

Now that we have out ViewOverlay set up we need to think about how to get Bitmap representations of our Views. We need to do this before the Views in the old layout get destroyed, so we’ll add this to the ViewState:

public final class ViewState {
    private final int top;
    private final int absoluteTop;
    private final int absoluteLeft;
    private final int visibility;
    private final Bitmap viewBitmap;

    public static ViewState ofView(View view) {
        int top = 0;
        int absoluteTop = 0;
        int absoluteLeft = 0;
        int visibility = View.GONE;
        Bitmap viewBitmap = null;
        if (view != null) {
            top = view.getTop();
            int[] location = new int[2];
            view.getLocationOnScreen(location);
            absoluteLeft = location[0];
            absoluteTop = location[1];
            visibility = view.getVisibility();
            if (visibility == View.VISIBLE) {
                viewBitmap = getBitmap(view);
            }
        }
        return new ViewState(top, absoluteLeft, absoluteTop, visibility, viewBitmap);
    }

    private static Bitmap getBitmap(View view) {
        Bitmap bitmap = Bitmap.createBitmap(view.getWidth(), view.getHeight(), Bitmap.Config.ARGB_8888);
        Canvas canvas = new Canvas(bitmap);
        view.draw(canvas);
        return bitmap;
    }

    private ViewState(int top, int absoluteLeft, int absoluteTop, int visibility, Bitmap viewBitmap) {
        this.top = top;
        this.absoluteLeft = absoluteLeft;
        this.absoluteTop = absoluteTop;
        this.visibility = visibility;
        this.viewBitmap = viewBitmap;
    }

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

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

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

    public int getY() {
        return top;
    }

    public int getAbsoluteX() {
        return absoluteLeft;
    }

    public int getAbsoluteY() {
        return absoluteTop;
    }

    public Bitmap getViewBitmap() {
        return viewBitmap;
    }
}

This has got a little more complex because we need to deal also with null View objects being passed in – this will happen in cases where a View exists in the new layout but not the old one. We will have a View object that we can animate in new layout, but won’t have a starting ViewState. Therefore we’ll create an empty state which will mimic a View which was not visible in the old layout.

The other obvious thing is that we now store a Bitmap representation of the View which we’ll use later on. This is created within the getBitmap() method. We create a Bitmap matching the dimension of the View, create a Canvas which will allow us to draw to that Bitmap, and then draw the View on to the Canvas. Voilà we have a Bitmap representation of our View. This gets stored in the ViewState along with absolute left and top co-ordinates. The reason for this is that if we call getLeft() and getTop() on the View itself, it will return co-ordinates relative to the immediate parent of the View. That layout may be in a different location in the new layout (or may not exist at all) so a relative position is of little use. By getting an absolute position here we can ensure that we position things correctly in the overlay.

So back to the addOverlayView() method which we saw earlier:

private View addOverlayView(ViewState viewState) {
    Context context = viewOverlay.getContext();
    ImageView imageView = new ImageView(context);
    int[] overlayLocation = new int[2];
    viewOverlay.getLocationOnScreen(overlayLocation);
    imageView.setX(viewState.getAbsoluteX() - overlayLocation[0]);
    imageView.setY(viewState.getAbsoluteY() - overlayLocation[1]);
    Bitmap viewBitmap = viewState.getViewBitmap();
    imageView.setAdjustViewBounds(true);
    imageView.setImageBitmap(viewBitmap);
    imageView.setVisibility(View.INVISIBLE);
    viewOverlay.addView(imageView);
    ViewGroup.LayoutParams layoutParams = imageView.getLayoutParams();
    layoutParams.height = ViewGroup.LayoutParams.WRAP_CONTENT;
    layoutParams.width = ViewGroup.LayoutParams.WRAP_CONTENT;
    imageView.setLayoutParams(layoutParams);
    return imageView;
}

This creates an ImageView which gets positioned according to the absolute position of the original View relative to the overlay, and then adds it to the overlay. We return this View as the one which we’re going to animate. We call setAdjustViewBounds(true) to automatically determine the width and height of the ImageView when we set the Bitmap and, of course, we use the Bitmap representation of the old View which we created earlier.

We’re almost there, the only thing that remains is to look at a slight difference in how the TransitionAnimator is actually created, Rather than passing in View objects, we now pass in an array of ID resources and find the Views. This is because of the case where Views which exist in the destination layout but not in the current layout. By passing an ID we can cope with a missing View much better:

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

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

private static SparseArray<ViewState> buildViewStates(ViewGroup parent, @IdRes int... viewIds) {
    SparseArray<ViewState> viewStates = new SparseArray<>();
    for (int viewId : viewIds) {
        View view = parent.findViewById(viewId);
        viewStates.put(viewId, ViewState.ofView(view));
    }
    return viewStates;
}

There are a few null and state checks which have also been added, but I won’t bother covering them here as we’ve covered the important techniques – they are all in the source code.

So if we run that we can see that things work as before, but we now correctly handle Views which don’t appear in both layouts:

One final thing worth mentioning is that the input_done button appears in both layouts with different visibilites. The reason for this is that we want it to move within the parent layout which itself moves in its own transition. If we remove it from the layout where it is not visible, then it won’t be associated with the moving parent layout when it is fading out, so won’t move with it. By keeping it in both layouts we get the movement we require.

That concludes out look at manually performing transitions. Of course there are a number of use-cases which we haven’t covered here (such as dismissing list items) but the basic techniques are still the same: track the start state of the Views, apply the new layout, track the end state of the Views, calculate and run animators between the two states.

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.