手动实现布局过渡效果(Layout transition)- 第四部分(完)

英文原文:Manual Layout Transitions – Part 4 

前面我们实现了在两个布局之间动画切换,并且效果还不错,但是有一个限制:开始布局中的每个view都要在结束布局中有相应的view与之对应。在本篇文章中,我们将去除这一限制。

识别两个布局是否具有不同view状态的方法很简单,如果在第一个布局中存在的view没有在第二个view中找到,则两个布局的状态就是不同的:

<?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>

在这个布局中我们有id为translation的CardView,但是在下面的布局中却不见了:

<?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>

现在的问题是,新布局绘制前,当OnPreDraw() 被调用的时候,在新的布局中没有可以用来动画的view。在旧布局中有,但现在被销毁了,所以我们什么也做不了。如果targeting API 是18 或者之后,还有一个办法- 我们可以使用ViewOverlay,ViewOverlay提供的机制完全符合我们的需要。但是我们项目设置的是minSdkVersion="15",因此我们无法使用ViewOverlay。但是,如果我们理解了ViewOverlay实际所作的事情,自己实现也是很容易的事情。

ViewOverlay本质上是一个渲染在当前布局之上的轻量级的ViewGroup。之所以说轻量级是因为它的子view并没有执行正常的测量与布局,相反,它是创建了一个代表ViewOverlay中所有view的Bitmap。即使创建该bimap所依据的源view已经不在了,我们也可以让这个处在ViewOverlay上的代理Bitmap播放动画。

理解这个之后,创建我们自己的模拟“ViewOverlay”也就很容易了。在onPreDraw()回调方法中创建一个FrameLayout,添加到父布局中,这个FrameLayout将做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;
}

和创建我们自己的overlay一样重要的是处理新布局中不再存在的view - 因此我们调用addOverlayView() 来把他添加到overlay。这个我们后面会有更多的讲解。

现在我们已经设置好了ViewOverlay,我们需要考虑如何获得代表view的Bitmap。我们需要在旧布局销毁之前做这件事情。因此我们在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;
    }
}

但是当需要处理传递进来的空view对象的时候,事情就变的有点复杂了 - 这种情况发生在当新布局中有的view在旧布局中不存在的时候。我们有一个可以在新布局中动画的view对象,但是没有初始状态。我们将创建一个空的state来模拟旧布局中不存在的view。

另一个显而易见的事情就是,我们现在保存了一个Bitmap,代表稍后要使用的view。它是在getBitmap()方法中创建的。在getBitmap()方法中,我们创建了与view尺寸一致的Bitmap,新建了一个可以把内容绘制到Bitmap上的Canvas,最后把view绘制到Canvas上。在ViewState这个类中,我们在保存Bitmap的同时也保存了横纵坐标的绝对值,这是因为如果我们调用view自身的getLeft() 和 getTop(),返回的是相对于直接父类的坐标。而在新的布局中,那个布局可能已经处在不同的位置了(或者根本不存在了),因此相对坐标几乎无用。使用绝对坐标可以确保在overlay中元素总是处于正确的位置。

回到前面代码中见到的addOverlayView() 方法:

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;
}

这里创建了一个基于源view的绝对位置而布置在overlay中的ImageView,并且把他添加到了overlay中。我们把这个view作为将要播放动画的view返回。在设置Bitmap的时候,调用setAdjustViewBounds(true) 来自动计算ImageView的宽和高。这里使用了Bitmap代表前面创建的旧view。

就快完成任务了,最后剩下的事情是看看TransitionAnimator的创建,不同于之前传入view对象,这次我们传入的是资源id的数组,然后find这些view。原因还是目标布局中存在的view可能不存在于当前布局中。传入id可以更好应对空view的情况:

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;
}

除此之外还有一些null或者状态检查的代码,但是我们就不再在本文一一讲解了,因为最重要的是东西已经讲了- 所有的东西都在源代码里,可以自己去看。

运行代码可以看到效果和之前一样,但是现在可以正确处理好view在前后两个view中不同时存在的情况:

Manual Layout Transitions - Part4.mp4_1435549908.gif

最后值得一提的是,两个布局中,input_done按钮都是存在的(这和我们最开始想做到的不受限制相违背),只是具有不同的可见状态。这是因为我们想要让它在消失的过程中随着父布局一起移动。如果在不可见的时候把它去掉,则没有和父布局一起移动的效果。所以没有去掉才实现了我们需要的效果。

这就是手动实现布局过渡效果。当然还有一些使用方法没有讲到(比如列表item的动画消失),但是基本的技术是一致的:跟踪view的初始状态,跟踪view的结束状态,计算两个状态之间的差值并运行动画。

本文的代码在这里