手动实现布局过渡效果(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中不同时存在的情况:
最后值得一提的是,两个布局中,input_done按钮都是存在的(这和我们最开始想做到的不受限制相违背),只是具有不同的可见状态。这是因为我们想要让它在消失的过程中随着父布局一起移动。如果在不可见的时候把它去掉,则没有和父布局一起移动的效果。所以没有去掉才实现了我们需要的效果。
这就是手动实现布局过渡效果。当然还有一些使用方法没有讲到(比如列表item的动画消失),但是基本的技术是一致的:跟踪view的初始状态,跟踪view的结束状态,计算两个状态之间的差值并运行动画。
本文的代码在这里。