Android-转场动画深度解析

Android5.0之后新增了很多好看的转场动画,相比于以前的overridePendingTransition()丰富了很多,特别新增了共享元素跳转的方式。本篇文章介绍转场动画框架的基本概念,并着手自己实现转场动画。

Scene(场景)

Scene保存了一个布局文件。我们可以通过以下方式生成一个Scene:

public static Scene getSceneForLayout(ViewGroup sceneRoot, int layoutId, Context context)

这个方法时静态的,传入一个根布局ViewGroup(作为显示场景的容器),一个layoutId(场景的显示内容),最后传入当前上下文。源码很短,我们一起来看一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public static Scene getSceneForLayout(ViewGroup sceneRoot, int layoutId, Context context) {
SparseArray<Scene> scenes = (SparseArray<Scene>) sceneRoot.getTag(
com.android.internal.R.id.scene_layoutid_cache);
if (scenes == null) {
scenes = new SparseArray<Scene>();
sceneRoot.setTagInternal(com.android.internal.R.id.scene_layoutid_cache, scenes);
}
Scene scene = scenes.get(layoutId);
if (scene != null) {
return scene;
} else {
scene = new Scene(sceneRoot, layoutId, context);
scenes.put(layoutId, scene);
return scene;
}
}
  • 根据一个固定的Tag取得保存依附于这个ViewGroup的scene集合SparseArray scenes,如果是空就先new一个。
  • 以要显示场景的layoutId为Key,先尝试获取这个场景,如果已经有这个layoutId对应的场景就直接返回,没有就先调用构造方法生成一个再放入进去,然后返回。
  • 一个scene只能对应一个布局,scene只是简单保存了sceneRoot, layoutId, context的值,并没有通过layoutId来分析处理里面的View信息(也没有必要)
  • 可以通过setEnterAction(Runnable action),setExitAction(Runnable action),在场景被加载和移除时回调,做相应的操作。

Transition(变换)

上面的介绍scene将一个或多个布局和一个加载这些布局的根布局建立起关系。真正的动画是由Transition实现的。所以大致的流程是:

1
2
3
4
5
6
7
8
9
//为Scene创建scene root
mSceneRoot = (ViewGroup) findViewById(R.id.scene_root);
//创建 scenes
Scene mAScene = Scene.getSceneForLayout(mSceneRoot, R.layout.a_scene, this);
Scene mAnotherScene = Scene.getSceneForLayout(mSceneRoot, R.layout.another_scene, this);
//代码中创建Transition
Transition mFadeTransition = new Fade();
//用TransitionManager负责场景变换
TransitionManager.go(mEndingScene, mFadeTransition);

自定义Transition

Transition是个抽象类,必须要实现以下方法:

  • public abstract void captureStartValues(TransitionValues transitionValues);捕获当前场景的视图,这里会对视图树中所有的View调用,有几个View就会调用几次。
  • public abstract void captureEndValues(TransitionValues transitionValues);捕获目标场景的视图,这里会对视图树中所有的View调用,有几个View就会调用几次。
  • public Animator createAnimator(ViewGroup sceneRoot, TransitionValues startValues,TransitionValues endValues)(不实现方法这个就没动画效果)从命名就可以看到captureStartValues和captureStartValues分别用来捕获当前场景和目标场景。

TransitionValues有三个重要属性,对理解Transition框架的机制有很大帮助。

  • View view:就是一个场景的一个View,在里面拿到View,我们可以从里面得到这个View我们所需要的属性。
  • Map<String, Object> values:默认为空,我们拿到属性后需要放到里面,如果这个Transition需要改变多个属性,就可以放多次进去。
  • ArrayList<Transition> targetedTransitions:默认为空,用来记录这个View执行了哪些Transition,我们可以在对这个View执行Transition的时候,把这个Transition存进去。

createAnimator方法就是Transition真正的实现方法了,返回一个属性动画。
好了实战开始,我们就实现一个Transition来实现直角移动:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
public class ChangeRect extends Transition {
private static final String PROPNAME_BER =
"changeposition:Rect";
// 开始的状态,这里会对视图树中所有的View调用,这里我们可以记录一下View的我们感兴趣的状态,比如这里:position
@Override
public void captureStartValues(TransitionValues transitionValues) {
captureValues(transitionValues);
}
// 结束也会对所有的View进行调用
@Override
public void captureEndValues(TransitionValues transitionValues) {
captureValues(transitionValues);
}
private void captureValues(TransitionValues transitionValues) {
float[] location = new float[2];
location[0] = transitionValues.view.getX();
location[1] = transitionValues.view.getY();
transitionValues.values.put(PROPNAME_BER, location);
}
//新建动画
@Override
public Animator createAnimator(ViewGroup sceneRoot, TransitionValues startValues, TransitionValues endValues) {
if (null == startValues || null == endValues) {
return null;
}
final View view = endValues.view;
float[] startPosition = (float[]) startValues.values.get(PROPNAME_BER);
float[] endPosition = (float[]) endValues.values.get(PROPNAME_BER);
if (startPosition[0] != endPosition[0] || startPosition[1] != endPosition[1]) {
Path path=new Path();
path.moveTo(startPosition[0],startPosition[1]);
path.lineTo(endPosition[0],startPosition[1]);
path.lineTo(endPosition[0],endPosition[1]);
ObjectAnimator animator = ObjectAnimator.ofFloat(view, View.X, View.Y, path);
animator.setDuration(getDuration());
animator.start();
return animator;
}
return null;
}
}

总结

简述下Transition框架的执行机制,我们定义了两个Scene,,当我们通过 TransitionManager.go( scene , transition),从Scene跳转到目标Scene的时候,会去取得scene对应布局,遍历布局中的每一个View(包括根布局和容器View),获取我们需要的属性。通过View的Id我们建立起两个布局中View的对应关系,所以最终只会在目标场景执行原场景有相同Id的View的动画(满足startValues != null && endValues!= null)。这篇文章只是简单解析了转场动画的原理,详细的两个页面的跳转将会的下一篇展开。

Content Transition

Content Transition就是最常见的转场动画了。为了方便大家理解,我们先来上个图。

部分代码:

源Activity:

1
2
3
4
5
6
7
8
9
Slide slide=new Slide();
slide.setDuration(3000);
slide.setSlideEdge(Gravity.BOTTOM);
getWindow().setExitTransition(slide);
Explode explode = new Explode();
explode.setDuration(3000);
explode.setMode(Visibility.MODE_IN);
getWindow().setReenterTransition(explode);

目标Activity:

1
2
3
4
5
6
7
8
9
Slide slideEnter=new Slide();
slideEnter.setDuration(1500);
slideEnter.setSlideEdge(Gravity.RIGHT);
getWindow().setEnterTransition(slideEnter);
Slide slide=new Slide();
slide.setDuration(1500);
slide.setSlideEdge(Gravity.RIGHT);
getWindow().setReturnTransition(slide);

然后在A页面调用方法跳到B页面:

1
2
3
Intent intent = new Intent(this, BActivity.class);
ActivityOptionsCompat activityOptionsCompat = ActivityOptionsCompat.makeSceneTransitionAnimation(this);
startActivity(intent, activityOptionsCompat.toBundle());

可以看到一共可以设置四个Transition:

  • (1)setExitTransition() - 当A 跳转到 B时,A中的View退出场景的效果(默认Null)
  • (2)setEnterTransition() - 当A 跳转到 B时,B中的View进入场景的效果(默认Fade)
  • (3)setReturnTransition() - 当B 返回 A时,B中的View退出场景的效果(默认同EnterTransition)
  • (4)setReenterTransition() - 当B 返回 A时,A中的View进入场景的效果(默认同ExitTransition)

以上这个过程同样可以看做是Transition作用在Scene上的一系列效果,只不过这里的Scene从上一篇中的单一布局换成了Window。不过细心的同学可能发现了,明明我为四个过程都设置动画效果,可为什么ExitTransition没有生效呢?接下来我们为每个Transition加入监听,看看动画的执行流程。下面是其中一个的代码,其他三个都一样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
Explode explode = new Explode();
explode.setDuration(3000);
explode.setMode(Visibility.MODE_IN);
explode.addListener(new Transition.TransitionListener(){
@Override
public void onTransitionStart(Transition transition) {
Log.d("Transitions--","ReenterTransitionStart");
}
@Override
public void onTransitionEnd(Transition transition) {
Log.d("Transitions--","ReenterTransitionEnd");
}
@Override
public void onTransitionCancel(Transition transition) {
}
@Override
public void onTransitionPause(Transition transition) {
}
@Override
public void onTransitionResume(Transition transition) {
}
});
getWindow().setReenterTransition(explode);

再次执行程序,日志信息如下:

20171111151041141118718.png

原来A页面的退出动画和B页面的进入动画、B页面的返回动画和A页面的重现动画是并行执行的。也就是说A页面的ExitTransition不是没有执行,而是在它执行的时候,B页面已经覆盖上来,并且EnterTransition已经同时在执行了,这时A页面已经不可见了。这也是Android默认的转场动画执行流程。
那问题来了,如果想要并行执行该怎么办呢?
有两种方法:
在设置Transition的时候同时设置不允许Transition重叠,也就是并行执行:

1
2
getWindow().setAllowEnterTransitionOverlap(false);
getWindow().setWindowAllowReturnTransitionOverlap(false);

或者在主题文件全局设置这个属性,这样无疑更好,即减少了代码又保证了应用视觉效果的统一:

1
2
<item name="android:windowAllowEnterTransitionOverlap">false</item>
<item name="android:windowAllowReturnTransitionOverlap">false</item>

修改后效果如下:
日志也显示现在是串行执行了:

20171111151041148016665.png

生命周期分析

保持上面的打印信息不变,我们增加两个Activity的生命周期日志信息,串行结果如下:

20171111151041150877583.png

并行如下:

20171111151041153077770.png

可以得到如下信息:

  • 在A页面的onPause执行前,ExitTransition就已经开始执行了
  • Transition不会阻塞BActivity的生命周期,尽管是串行执行的,即使ExitTransition没结束,BActivity已经执行完OnResume了。
  • ReturnExitTransition需要等到AActivity OnStart执行完才开始执行,而且ReturnExitTransition会阻塞AActivity的生命周期,AActivity的OnResume会等到ReturnExitTransition执行完再执行。关于这点可以简要说明下:直接调用Finish不会有动画直接结束掉,需要执行onBackPressed()才会有ReturnExitTransition。看源码就很明显了:
1
2
3
4
5
6
7
8
9
public void onBackPressed() {
if (mActionBar != null && mActionBar.collapseActionView()) {
return;
}
if (!mFragments.getFragmentManager().popBackStackImmediate()) {
finishAfterTransition();
}
}

总结

最后简单分析下转场动画的大致流程(以slide为例),看过上一篇文章的同学应该很好理解:

1
2
3
4
5
6
7
8
9
1.从DecoerView开始,依次遍历获得当前Window上的视图树里的所有View
2.执行captureStartValues(TransitionValues transitionValues),捕获View开始状态的一些属性(visibility,Parent,LocationOnScree)
3.设置所有的VIew为INVISIBLE。
4.执行captureEndValues(TransitionValues transitionValues),捕获View结束状态的一些属性(visibility,Parent,LocationOnScree)
5.比较属性的不同,创建属性动画。下一个过程就是返回属性动画并执行了。

这是ExitTransiton的流程,其他三个也差不多。下一篇将会讲带共享元素的转场动画,也是material design中很有特色的动画了。

终于到了material design转场动画中最好玩,最有特色的一部分了。我们沿用上一篇的图,不过将跳转Activity的代码作如下更改:

1
2
3
4
5
Intent intent = new Intent(this,BBBActivity.class);
ActivityOptionsCompat activityOptionsCompat =ActivityOptionsCompat.makeSceneTransitionAnimation(this
, new Pair<View, String>(shared_image, "shared_image_")
, new Pair<View, String>(shared_text, "shared_text_"));
startActivity(intent, activityOptionsCompat.toBundle());

然后在BBBActivity的布局文件想要设置共享元素的部分设置android:transitionName,值和上个页面中设置的值要一一对应,比如:

1
2
3
4
5
6
<TextView
android:id="@+id/tv_show"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="我是一行文字"
android:transitionName="shared_text_"/>

当然也可以在代码中设置(注意要在调用时机,不能晚于OnResume):shared_image.setTransitionName("shared_image_");
这样简单两步,咱们的带共享元素的转场动画就改造完成了:

20171111151041179398841.gif

自定义共享元素动画

自带的共享元素动画很简单,可以通过如下代码定义进入和返回动画:

1
2
3
4
5
6
7
8
9
10
getWindow().setSharedElementEnterTransition(Transition transition)
getWindow().setSharedElementReturnTransition(Transition transition)
```
仔细一看这两个方法都只需要一个transition作为动画,所以意味着自定义共享元素动画就是自定义Transition了。套用第一篇自定义的那个直角移动ChangeRect,效果如下:
![20171111151041183822484.gif](http://ohtrrgyyd.bkt.clouddn.com/20171111151041183822484.gif)
# 其他方法
上面我们只用两个方法就完成了一次完整的共享元素进入到返回动画。但其实和普通的转场动画一样,设置共享元素的转场动画有四个,除了上面介绍的还有两个就是

setSharedElementExitTransition
setSharedElementReenterTransition

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
看命名方式和普通的转场动画非常相似,也就是共享元素离开和重现动画的方法。但是共享元素转场是为了表现两个页面相似内容连贯性而设计的,一组动画就足以完成了。但如果我们都加上后会怎么样呢?为了动画更明显,我们把普通动画设为串行:
![2017111115104118664268.gif](http://ohtrrgyyd.bkt.clouddn.com/2017111115104118664268.gif)
可以看到新增的两个并没有生效,通过日志打印也可以卡出这一点:
![2017111115104118919019.png](http://ohtrrgyyd.bkt.clouddn.com/2017111115104118919019.png)
SharedElementExitTransition和SharedElementReenterTransition开始后立即就结束了。关于这一点,参看stackoverflow上的回答,简单来说这两个动画的设计只是为了作一些初始化而存在的。当我们点击跳转按钮的时候,马上就已经跳到了B(参看上一篇生命周期的分析),而共享元素动画没有所谓的串行机制,会马上执行SharedElementEnterTransition,所以转场动画内部会立即结束掉ExitTransition。而ReenterTransition我们也可以从gif图看到,SharedElementReturnTransition已经完成了动画,将目标View变为目标状态,所以不再进行SharedElementReturnTransition(也因为没必要),所以只进行了普通转场动画的ReenterTransition。
# 共享元素执行空间
Window中有个关于共享元素的设置setSharedElementsUseOverlay(boolean sharedElementsUseOverlay),我们将其设为false,重启App:
![20171111151041191716571.gif](http://ohtrrgyyd.bkt.clouddn.com/20171111151041191716571.gif)
可以看到动画执行流程没有变但是共享元素在移动过程中被遮住了,我们来看源码

protected void moveSharedElementsToOverlay() {
if (mWindow == null || !mWindow.getSharedElementsUseOverlay()) {
return;
}
setSharedElementMatrices();
int numSharedElements = mSharedElements.size();
ViewGroup decor = getDecor();
if (decor != null) {
boolean moveWithParent = moveSharedElementWithParent();
Matrix tempMatrix = new Matrix();
for (int i = 0; i < numSharedElements; i++) {
View view = mSharedElements.get(i);
tempMatrix.reset();
mSharedElementParentMatrices.get(i).invert(tempMatrix);
GhostView.addGhost(view, decor, tempMatrix);
ViewGroup parent = (ViewGroup) view.getParent();
if (moveWithParent && !isInTransitionGroup(parent, decor)) {
GhostViewListeners listener = new GhostViewListeners(view, parent, decor);
parent.getViewTreeObserver().addOnPreDrawListener(listener);
mGhostViewListeners.add(listener);
}
}
}
}
```

可以看到,如果getSharedElementsUseOverlay==true(也就是默认状态),系统会得到这个View,然后GhostView.addGhost(view, decor, tempMatrix),放置在decorView的Overlay上,因为是decorView,所以也就是在整个view树结构的最上层。Overlay它是view的最上面的一个透明的层,添加到上面的和view不会被其他View遮挡住。

拓展:共享元素形变动画

前面的动画效果都是操作View原有的一些属性,View的内容没有(或者内容没有改变),所以如果与Svg矢量动画配合一番,会产生怎么样的效果呢:

20171111151041200523866.gif

其实很简单,监听SharedElementEnterTransition和SharedElementReturnTransition动画,在其执行的时候执行矢量动画就行了。在更多关于这个矢量动画实现的东西可以参考这篇文章。

写在最后

好了关于Android转场动画的内容完结了,代码已上传gitHub,欢迎指正!