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

泡在网上的日子 / 文 发表于2015-06-29 11:47 次阅读 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的结束状态,计算两个状态之间的差值并运行动画。

本文的代码在这里

收藏 赞 (3) 踩 (0)
上一篇:手动实现布局过渡效果(Layout transition)- 第三部分
前面的文章中我们做到了创建两个不同的布局状态并能够在两者之间切换,本文,我们将让它们“动”起来。 我们已经有了所要创建动画的基本元素(AnimatorBuilder),我们也有了可以提供开始和结束状态的两个静态的布局,现在我们要做的只是让两个状态切换的时
下一篇:手动实现布局过渡效果
可以帮助你理解新的Transition api,同时也是教你封装属性动画操作的好资料。