深入理解 Android Transition 场景动画

Transition 概念

Transition 是指不同 UI 状态转换时的动画。其中有两个关键概念:场景(Scene)和转换(Transition)。场景定义了一个确定的 UI 布局状态,而转换定义了两个场景切换时过渡的动画。

当两个场景进行切换时,Transition 主要有下面两个行为:

1、 确定开始场景和结束场景中每个 view 的状态。
2、 根据状态差异创建 Animator,用于场景切换时每个 view 的动画。

例如最简单的对View的隐藏增加渐变动画:

1
2
TransitionManager.beginDelayedTransition(viewGroup, new Fade());
view.setVisibility(View.GONE);

动画执行的基本流程:
1、TransitionManager.beginDelayedTransition(viewGroup, new Fade()); 确定子view初始状态。
2、调用view.setVisibility(View.GONE);之后,framework会调用Transition类的captureEndValues()方法,记录每个view最新的可见状态。
3、 framework调用Transition的createAnimator()方法。transition会分析每个view的开始和结束时的数据发现view在开始时是可见的,结束时是不可见的。Fade(Transition的子类)会利用这些信息创建一个用于把view的alpha属性变为0的 Animator,并返回 Animator 对象。
4、framework会执行返回的 Animator 动画

beginDelayedTransition 方法分析

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
public static void beginDelayedTransition(final ViewGroup sceneRoot, Transition transition) {
... ...
// 确定 sceneRoot 的子 view 初始状态
sceneChangeSetup(sceneRoot, transitionClone);
Scene.setCurrentScene(sceneRoot, null);

// 在下一次 sceneRoot 绘制之前,确定 view 结束状态
sceneChangeRunTransition(sceneRoot, transitionClone);
}

private static void sceneChangeRunTransition(final ViewGroup sceneRoot, final Transition transition) {
MultiListener listener = new MultiListener(transition, sceneRoot);
sceneRoot.addOnAttachStateChangeListener(listener);
// PreDraw 监听,确定结束状态
sceneRoot.getViewTreeObserver().addOnPreDrawListener(listener);
}

private static class MultiListener implements ViewTreeObserver.OnPreDrawListener, View.OnAttachStateChangeListener {

... ...

@Override
public boolean onPreDraw() {
... ...
mTransition.captureValues(mSceneRoot, false);
if (previousRunningTransitions != null) {
for (Transition runningTransition : previousRunningTransitions) {
runningTransition.resume(mSceneRoot);
}
}

// 执行动画 调用 Transition.createAnimators() & Transition.runAnimators()
mTransition.playTransition(mSceneRoot);

return true;
}
};

页面过渡动画

过渡动画使得 Activity 跳转或者 Fragment 切换等显得不那么生硬,通过共享元素(Share Element)过渡决定了两个 Activity/Fragment 共享的视图如何在这些跳转的时候执行过渡动画。例如,如果两个 Activity 使用相同的图片(但位置和大小不同),通过 changeImageTransform 共享元素过渡就会在这些 Activity/Fragment 之间流畅地平移和缩放该图片。如图所示共享元素过渡效果:

支持的Android最低版本:Android 5.0 (API 21)

Content Transition

内容变换(Content Transition)决定了非共享view元素在 activity 和 fragment 切换期间是如何进入或者退出场景的。Content Transition 的触发是通过改变 view 的 visibility 来实现的。

setExitTransition() - 当A start B时,使A中的View退出场景的transition

setEnterTransition() - 当A start B时,使B中的View进入场景的transition

setReturnTransition() - 当B 返回 A时,使B中的View退出场景的transition

setReenterTransition() - 当B 返回 A时,使A中的View进入场景的transition

Shared Element Transition

共享元素变换(Shared Element Transition)决定了共享view元素从一个 Activity/Fragment 到另一个 Activity/Fragment t 的切换中是如何动画变化的。共享元素变换并不是真正实现了两个activity或者Fragment之间元素的共享,Framework采用了不同的方法来达到相同的视觉效果。共享元素默认其实是绘制在整个view树结构的最上层,在一个叫ViewOverlay的东西上面。

setSharedElementEnterTransition() - 设置在A进入B的时候播放的动画,共享元素以A中的位置作为起始,B中的位置为结束来播放动画。

setSharedElementReturnTransition() - 设置在B返回A的时候播放的动画,共享元素以B中的位置作为起始,A中的位置为结束来播放动画。

Activity 共享元素过渡动画

设置允许过渡动画

按官方文档的说法,使用过渡动画之前需要在 Activity 设置启用,须在 setContentView() 之前调用,最好是放在 super.onCreate() 之前,避免 6.0 及以下机型报错:requestFeature() must be called before adding content。

1
getWindow().requestFeature(Window.FEATURE_CONTENT_TRANSITIONS);

或者在Theme的定义里面加上

1
<item name="android:windowActivityTransitions">true</item>

开启过渡动画

例如上面示例图里面,由搜索结果页跳商详页,主图缩放效果

1、两个页面执行过渡的共享元素分别设置相同的 transitionName

1
2
3
<ImageView
...
android:transitionName="imgTransition" />

2、执行过渡动画跳转

1
2
3
4
5
6
7
8
9
10
11
12
13
// 纯页面过渡,无共享元素,可以替代 Activity 过渡动画
startActivity(new Intent(this, DetailActivity.class), ActivityOptions.makeSceneTransitionAnimation(this).toBundle());

// 单个共享元素
startActivity(new Intent(this, DetailActivity.class),
ActivityOptionsCompat.makeSceneTransitionAnimation(this, imageView, "imgTransition").toBundle());

// 多个共享元素
Pair<View, String> pair1 = Pair.create((View) image, "imgTransition");
Pair<View, String> pair2 = Pair.create((View) title, "titleTransition");
...
ActivityOptionsCompat transitionActivityOptions = ActivityOptionsCompat.makeSceneTransitionAnimation(ActTransitionActivity.this, pair1, pair2, ...);
startActivity(new Intent(this, DetailActivity.class), transitionActivityOptions.toBundle());

3、由于一个页面布局里面不允许有重名的 transitionName 的元素,对于列表,例如 RecyclerView,可以在 onBindViewHolder 的时候,手动设置 transitionName。

1
2
3
4
public void onBindViewHolder(final ViewHolder viewHolder, final int position) {
...
ViewCompat.setTransitionName(viewHolder.image, position + "_imgTransition");
}

那么点击跳转到商详页的时候,把对应位置的 transitionName 通过参数带过去,并设置给商详页主图 ImageView 的 transitionName。

Tips
1、页面退出时,应该调用 **Activity.finishAfterTransition()**,不能直接调用 finish(), 否则过渡动画将不执行。

2、过渡动画过程包括 Shared Element TransitionContent Transition, 指共享元素 和 其他非共享元素 的过渡动画。

Fragment 共享元素过渡动画

Fragment 的过渡动画是天然支持的,在为 Fragment 添加 Transition 的时候并不需要像 Activity 一样设置 Window.FEATURE_ACTIVITY_TRANSITIONS 和 Window.FEATURE_CONTENT_TRANSITIONS。

例如,FragmentA 切换到 FragmentB,需要分别设置共享元素的 transitionName。Fragment 同样也支持设置过渡动画类型

1
2
3
4
5
6
Fragment.setSharedElementEnterTransition()
Fragment.setSharedElementExitTransition()
Fragment.setExitTransition()
Fragment.setEnterTransition()
Fragment.setReturnTransition()
Fragment.setReenterTransition()

1. 对于fragment通过 replace() 的情况:

1
2
3
4
5
6
getSupportFragmentManager()
.beginTransaction()
.addSharedElement(holder.image, "imgTransition")
.replace(R.id.container, FragmentB)
.addToBackStack(null)
.commit();

2. 对于fragment通过 add()/show()/hide() 的情况

1
2
3
4
5
6
7
8
getSupportFragmentManager()
.beginTransaction()
.addSharedElement(holder.image, "imgTransition")
.add(R.id.container, FragmentB)
.show(FragmentB)
.hide(FragmentA)
.setReorderingAllowed(true) // 必须设置,否则无效果
.commit();

过渡动画分析

页面进入和退出时,过渡动画执行顺序如图所示:

动画类型

过渡时,可以设置一些动画效果(android.transition.Transition),一般默认是 Fade(淡入淡出)或 AutoTransition。系统还提供了其它一些动画,基本适用于大部分场景了。

  • Slide(滑动式,可以选择滑动方向)
  • Explode(分解,类似Slide,但是会根据共享元素中心做方向计算,可以理解为周围发散)
  • ChangeBounds(布局边界的变化 动画效果)
  • ChangeClipBounds(裁剪边界的变化 动画效果)
  • ChangeTransform(缩放和旋转方面的变化 动画效果)
  • ChangeImageTransform (尺寸和缩放方面的变化 动画效果)
  • AutoTransition(实际上包含了Fade-out,ChangeBounds和Fade-in的集合,对共享元素设置时包含的Fade无效果)
  • TransitionSet(实现动画集合,AutoTransition 基于此类实现)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 页面跳转时,前一个页面非共享元素退出的动画
getWindow().setExitTransition(new Slide());
// 跳转返回时,前一个页面非共享元素进入的动画
getWindow().setReenterTransition(null);

// 页面跳转时,后一个页面非共享元素进入的动画
getWindow().setEnterTransition(new Explode());
// 跳转返回时,后一个页面非共享元素退出的动画
getWindow().setReturnTransition(new Fade());

// 前一个页面共享元素退出动画,一般不用设置,默认为move效果
getWindow().setSharedElementExitTransition(new Fade().setDuration(1000));
// 后一个页面共享元素进入动画
getWindow().setSharedElementEnterTransition(new AutoTransition());

也可以分别在两个 Activity 的 Theme 里面配置转场动画效果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<style name="BaseAppTheme" parent="android:Theme.Material">
<!-- enable window content transitions -->
<item name="android:windowActivityTransitions">true</item>

<!-- specify enter and exit transitions -->
<item name="android:windowEnterTransition">@transition/explode</item>
<item name="android:windowExitTransition">@transition/explode</item>

<!-- specify shared element transitions -->
<item name="android:windowSharedElementEnterTransition">
@transition/change_image_transform</item>
<item name="android:windowSharedElementExitTransition">
@transition/change_image_transform</item>
</style>
1
2
3
4
<!-- res/transition/change_image_transform.xml -->
<transitionSet xmlns:android="http://schemas.android.com/apk/res/android">
<changeImageTransform/>
</transitionSet>

对于上述的各种 Transition,我们也可以设置其动画时长、插值器等。

1
2
3
Transition.setDuration(300);
Transition.setInterpolator(new FastOutSlowInInterpolator());
Transition.setStartDelay(200);

自定义 Transition

若系统提供的动画无法满足需求,也可以扩展 Visibility/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
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
public class ChangeTextTransition extends Transition {

protected static final String PROPNAME_TEXTSIZE = "ChangeTextTransition::textSize";
protected static final String PROPNAME_TEXTCOLOR = "ChangeTextTransition::textColor";

public ChangeTextTransition() {
addTarget(TextView.class);
}

@Override
public void captureStartValues(TransitionValues transitionValues) {
ShareElementInfo info = ShareElementInfo.getFromView(transitionValues.view);
if (info == null || !(info.getViewStateSaver() instanceof TextViewStateSaver)) {
return;
}

// 保存 Start TextView 的字体大小及颜色值
captureValues(transitionValues, (TextViewStateSaver) info.getViewStateSaver(), info.isEnter() ? info.getFromViewBundle() : info.getToViewBundle());
}

@Override
public void captureEndValues(TransitionValues transitionValues) {
ShareElementInfo info = ShareElementInfo.getFromView(transitionValues.view);
if (info == null || !(info.getViewStateSaver() instanceof TextViewStateSaver)) {
return;
}

// 保存 End TextView 的字体大小及颜色值
captureValues(transitionValues, (TextViewStateSaver) info.getViewStateSaver(), info.isEnter() ? info.getToViewBundle() : info.getFromViewBundle());
}

protected void captureValues(TransitionValues value, TextViewStateSaver stateSaver, Bundle viewExtraInfo) {
value.values.put(PROPNAME_TEXTSIZE, stateSaver.getTextSize(viewExtraInfo));
value.values.put(PROPNAME_TEXTCOLOR, stateSaver.getTextColor(viewExtraInfo));
}

@Override
public Animator createAnimator(ViewGroup sceneRoot, TransitionValues startValues, TransitionValues endValues) {
ShareElementInfo info = endValues==null?null:ShareElementInfo.getFromView(endValues.view);
if (info == null || !(info.getViewStateSaver() instanceof TextViewStateSaver)) {
return null;
}
final TextView view = (TextView) endValues.view;

view.setPivotX(0f);
view.setPivotY(0f);
float startTextSize = (float) startValues.values.get(PROPNAME_TEXTSIZE);
final float endTextSize = (float) endValues.values.get(PROPNAME_TEXTSIZE);
ObjectAnimator textSizeAnimator = ObjectAnimator.ofFloat(view, new TextSizeProperty(), startTextSize, endTextSize);

int startTextColor = (int) startValues.values.get(PROPNAME_TEXTCOLOR);
int endTextColor = (int) endValues.values.get(PROPNAME_TEXTCOLOR);
ObjectAnimator textColorAnimator = ObjectAnimator.ofArgb(view, new TextColorProperty(), startTextColor, endTextColor);

// 根据之前保存的 Start&End TextView 文字大小及颜色做渐变动画
AnimatorSet animatorSet = new AnimatorSet();
animatorSet.playTogether(textSizeAnimator, textColorAnimator);
return animatorSet;

}

private class TextSizeProperty extends Property<TextView, Float> {

public TextSizeProperty() {
super(Float.class, "textSize");
}

@Override
public void set(TextView object, Float value) {
object.setTextSize(TypedValue.COMPLEX_UNIT_PX, value);
}

@Override
public Float get(TextView object) {
return object.getTextSize();
}
}

private class TextColorProperty extends Property<TextView, Integer> {

public TextColorProperty() {
super(Integer.class, "textColor");
}

@Override
public void set(TextView object, Integer value) {
object.setTextColor(value);
}

@Override
public Integer get(TextView object) {
return object.getCurrentTextColor();
}
}
}

Transition Overlap

默认情况下,内容过渡动画的后一个页面的 Enter/Return 转换会在 前一个页面的 Exit/Reenter 转换结束前一点开始,产生一个小的重叠来让整体的效果更自然、更协调。默认 overlap 是 true,进入转换会退出转换开始后尽可能快地开始,如果设置为 false,进入转换只能在退出转换结束后开始,通常都为 true。

1
2
getWindow().setAllowEnterTransitionOverlap(true);
getWindow().setAllowReturnTransitionOverlap(true);

Shared Element Overlay

默认情况下,共享元素视图是绘制在整个视图结构之上的(的 ViewOverlay 层)。但是如果共享元素周围有点击效果,例如 ?attr/selectableItemBackground 波纹效果,那么如果共享元素视图绘制在整个视图结构之上,点击时在波纹效果还没消失的时候,会堆叠一个共享元素视图,比较影响协调性,可以把 sharedElementsUseOverlay 置为 false。

1
getWindow().setSharedElementsUseOverlay(false)

更新共享元素

如上图所示,共享元素过渡跳转到预览页后,我们可以通过 ViewPager 切换到其他元素,那么返回时如果未更新共享元素对应关系,则返回时会出现找不到对应的共享元素,而无法执行过渡动画。理想情况如下图所示:

那么我们可以通过 SharedElementCallback 来更新共享元素对应关系。

对于列表页 ListActivity:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
setExitSharedElementCallback(new SharedElementCallback() {

private void removeOldViews(List<String> names, Map<String, View> sharedElements) {
if (!names.isEmpty()) {
for (String name : names) {
sharedElements.remove(name);
}
names.removeAll(namesTobeRemoved);
}
}

@Override
public void onMapSharedElements(List<String> names, Map<String, View> sharedElements) {
// 清除旧的共享元素关系
removeOldViews(names, sharedElements);
ViewHolder viewHolder = recyclerView.findViewHolderForPosition(currentPosition);
View imageView = viewHolder.itemView.findViewById(R.id.image);

// 更新共享元素对应关系
names.add(imageView.getTransitionName());
sharedElements.put(imageView.getTransitionName(), imageView);
}
});

对于预览页 PreviewActivity:

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
ActivityCompat.setExitSharedElementCallback(this, new SharedElementCallback() {

private void removeOldViews(List<String> names, Map<String, View> sharedElements) {
if (!names.isEmpty()) {
for (String name : names) {
sharedElements.remove(name);
}
names.removeAll(namesTobeRemoved);
}
}

@Override
public void onMapSharedElements(List<String> names, Map<String, View> sharedElements) {
// 清除旧的共享元素关系
removeOldViews(names, sharedElements);
Fragment fragment = getCurrentFragment();
if(fragment != null) {
View view = fragment.getView();
ImageView imageView = view.findViewById(R.id.image);

// 更新共享元素关系
sharedElements.put(imageView.getTransitionName(), imageView);
names.add(imageView.getTransitionName());
}
}
});

Tips
1、在PreviewActivity ViewPager 切换时,需要把当前的 position 通知给 ListActivity,RecyclerView 自动滚动到对应 position 位置,返回时才能获取到对应的 ViewHolder
2、若不希望实时更新 position,也可以在预览页返回的时候的时候再更新,那么可能就需要延迟过渡动画,等待 RecyclerView 滚动到对应位置绘制完成,再执行过渡动画。对于 Activity 可以通过 postponeEnterTransition()startPostponedEnterTransition() 来停止和恢复过渡动画,对于 Fragment 可以通过 getActivity().supportPostponeEnterTransition()getActivity().supportStartPostponedEnterTransition() 来停止和恢复过渡动画

布局变化过渡动画

布局变化自动监听

1
TransitionManager.beginDelayedTransition(viewGroup)

当我们调用 TransitionManager.beginDelayedTransition(viewGroup) 方法时,会立即记住当前 viewGroup 底下子节点的状态,然后在下一帧中再次记录 viewGroup 所有子节点的状态,根据状态差异执行过渡动画。默认动画是 AutoTransition。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// CHANGE SIZE
TransitionManager.beginDelayedTransition(rootViewGroup);
ViewGroup.LayoutParams layoutParams = imageView.getLayoutParams();
if (sizeChanged) {
layoutParams.width = 200;
layoutParams.height = 200;
} else {
layoutParams.width = 100;
layoutParams.height = 100;
}
imageView.setLayoutParams(layoutParams);

// CHANGE POSITION
TransitionManager.beginDelayedTransition(rootViewGroup, new AutoTransition());

LinearLayout.LayoutParams layoutParams = (LinearLayout.LayoutParams) imageView.getLayoutParams();
if (locationChanged) {
layoutParams.gravity = Gravity.CENTER;
} else {
layoutParams.gravity = Gravity.LEFT;
}
imageView.setLayoutParams(layoutParams);

animateLayoutChanges 属性

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
...
android:animateLayoutChanges="true">

<ImageView
android:layout_width="48dp"
android:layout_height="48dp"
android:src="@mipmap/ic_launcher"/>

<TextView
android:id="@+id/tvText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="some text" />

</LinearLayout>

通过布局属性 animateLayoutChanges,可以为子 View 变化自动执行过渡动画。如图所示:

场景过渡框架

前文主要说明 Activity 之间跳转的过渡动画,那么如果在同一个 Activity 内的各个组件之间打造过渡动画效果,就可以通过场景过渡框架。场景过渡有两个关键概念:场景(Scene)和转换(Transition),简单的说就是每个 Scene 提供对应的UI布局组件,Transition 负责 Scene 执行两个 Scene 之间变换的过渡动画执行。对于两个场景之间添加过渡动画效果流程如下:

1、为起始布局和结束布局分别创建一个 Scene 对象。
2、创建一个 Transition 对象以定义所需的动画类型。
3、调用 TransitionManager.go(),然后系统会运行动画以交换布局。

布局变化场景动画

为两个布局创建Scene。

1
2
3
4
5
6
7
8
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/scene_root"
android:layout_width="match_parent"
android:layout_height="match_parent">

<include layout="@layout/a_scene" />
</FrameLayout>

第一个场景布局:res/layout/a_scene.xml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/scene_container"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="horizontal">

<TextView
android:id="@+id/text_view1"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Text Line 1" />

<TextView
android:id="@+id/text_view2"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Text Line 2" />
</LinearLayout>

第二个场景布局:res/layout/b_scene.xml,更改了布局方向和文本排列方式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/scene_container"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">

<TextView
android:id="@+id/text_view2"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Text Line 2" />

<TextView
android:id="@+id/text_view1"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Text Line 1" />
</LinearLayout>

注意:两个场景布局需要做过渡动画的节点id需要保持一致!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class SceneTransitionActivity extends AppCompatActivity {

@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);

setContentView(R.layout.activity_scene_transition);

ViewGroup sceneRoot = (ViewGroup) findViewById(R.id.scene_root);

Scene aScene = Scene.getSceneForLayout(sceneRoot, R.layout.a_scene, this);
final Scene bScene = Scene.getSceneForLayout(sceneRoot, R.layout.b_scene, this);

sceneRoot.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
// 执行场景动画
TransitionManager.go(bScene, new ChangeBounds());
}
});
}
}

执行场景动画效果如图所示: