[译] Android Transition Animation 过渡动画解析

本文主要译自 lgvalle 的 Material-Animations 的 README.MD,文中的源码见 Material-Animations

Android 4.4.2 中引入了 Transition 过渡动画,不过那时的 API 的功能比较简单,只能对整个 Activity 或 Fragment 做动画,Google 在 Android 5.0 的 Material Design 中引入更完整的 Transition 框架。Android 的过渡动画可以分为四个部分:

  1. Activity/Fragment 切换时的内容过渡动画(content transition)

  2. Activity/Fragment 切换时的共享元素过渡动画

  3. 同一个页面中的场景过渡动画

  4. 共享元素过渡动画 + 揭露效果(circular reveal)

在介绍过渡动画之前,先要明白 Transition 是什么?

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

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

(1)确定开始场景和结束场景中每个 view 的状态。

(2)根据状态差异创建 Animator,用以场景切换时 view 的动画。

下面再来看四种不同的过渡动画,本文只以 Activity 作为例子分析,Fragment 也是类似的。

1. Activity/Fragment 切换时的内容过渡动画

页面切换时的内容过渡动画可以细分为 4 个动画:

Android 5.0(API 21)中默认提供了三种转换:

  • 分解(Explode) - 从场景中心移入或移出视图

  • 滑动(Slide)- 从场景边缘移入或移出视图

  • 淡入淡出(Fade)- 通过调整透明度在场景中增添或移除视图

1.1 启用内容过渡动画

在 Android 5.0 以上,要启用内容过渡动画要完成下面几步:

1. 打开窗口内容转换开关

在风格定义中设置:

1
2
3
4
<style name="BaseAppTheme" parent="Theme.AppCompat.Light">
<!-- enable window content transitions -->
<item name="android:windowContentTransitions">true</item>
</style>

或者在代码中设置:

1
2
3
4
5
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState)
getWindow().requestFeature(Window.FEATURE_CONTENT_TRANSITIONS)
}

2. 指定进入、退出转换

(1)可以在风格定义中指定转换:

1
2
3
4
5
<style name="BaseAppTheme" parent="Theme.AppCompat.Light">
<!-- specify enter and exit transitions -->
<item name="android:windowEnterTransition">@transition/activity_fade</item>
<item name="android:windowExitTransition">@transition/activity_slide</item>
</style>

转换文件存放在res/transition目录下。

res/transition/activity_fade.xml

1
2
3
<?xml version="1.0" encoding="utf-8"?>
<fade xmlns:android="http://schemas.android.com/apk/res/"
android:duration="1000"/>

res/transition/activity_slide.xml

1
2
3
<?xml version="1.0" encoding="utf-8"?>
<slide xmlns:android="http://schemas.android.com/apk/res/"
android:duration="1000"/>

(2) 也可以在代码中指定转换:

MainActivity.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_transition);
setupWindowAnimations();
}
private void setupWindowAnimations() {
// inflate from xml
Slide slide = TransitionInflater.from(this).inflateTransition(R.transition.activity_slide);
// or create directly
Slide slide = new Slide();
slide.setDuration(1000);
getWindow().setExitTransition(slide);
}

TransitionActivity.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_transition);
setupWindowAnimations();
}
private void setupWindowAnimations() {
// inflate from xml
Fade fade = TransitionInflater.from(this).inflateTransition(R.transition.activity_fade);
// or create directly
Fade fade = new Fade();
fade.setDuration(1000);
getWindow().setEnterTransition(fade);
}

注:如果没有指定returnTransitionreenterTransition,返回 MainActivity 时会分别执行反转的进入和退出转换

上面代码设置的内容过渡动画效果如下表中的左边的图所示:

没有指定返回转换 指定了返回转换
Enter: Fade In
Exit: Slide out
Enter: Fade In
Exit: Slide out
Return: Slide out

1.2 内容过渡动画的原理

内容过渡动画的创建之前,必须要确定视图的状态信息,而这需要修改所有过渡视图(transitioning view)的可见性。当 Activity A 启动 Activity B 时,下面的事件将会发生:

1) Activity A 调用 startActivity()

  1. 系统遍历 A 的视图节点,找到将要运行退退出转换的所有过渡视图

  2. A 的退出转换记录所有过渡视图的开始状态

  3. 系统将所有过渡视图的可见性设置为INVISIBLE

  4. 在下一帧,A 的退出转换记录所有过渡视图的结束状态

  5. A 的退出转换比较每个过渡视图的开始状态和结束状态,然后创建 Animator 作为退出动画,运行该动画。

2)Activity B 启动了

  1. 系统遍历 B 的视图节点,找到将要运行进入转换的所有过渡视图,设置这些过渡视图的可见性为INVISIBLE

  2. B 的进入转换记录所有过渡视图的开始状态

  3. 系统将所有过渡视图的可见性设置为VISIBLE

  4. 在下一帧,B 的进入转换记录所有过渡视图的结束状态

  5. B 的进入转换比较每个过渡视图的开始状态和结束状态,然后创建 Animator 作为进入动画,运行该动画。

通过上面的分析可以知道,所有的内容转换都需要记录每个过渡视图的开始状态和结束状态。而抽象类Visibility已经做了这部分内容了,Visibility的子类只需要实现 onAppear() 和 onDisappear() 方法,创建过渡视图进入或退出场景的 Animator。Android 5.0 中Visibility有三个子类 – Fade、Slide、Explode,如果有需要的话也可以自定义Visibility子类。

1.3 Transitioning View & Transition Groups

这个小节将讨论系统如何找出过渡视图,以及通过 transition group 自定义过渡视图。

在转换开始前,系统通过遍历视图节点递归寻找过渡视图,这个递归查找是从对根视图调用ViewGroup.captureTransitioningViews方法开始的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/** @hide */
@Override
public void captureTransitioningViews(List<View> transitioningViews) {
if (getVisibility() != View.VISIBLE) {
return;
}
if (isTransitionGroup()) {
transitioningViews.add(this);
} else {
int count = getChildCount();
for (int i = 0; i < count; i++) {
View child = getChildAt(i);
child.captureTransitioningViews(transitioningViews);
}
}
}

上面的递归查找会遍历整个视图树节点,直到找到一个 VISIBLE 的 View 或者一个 transition group。Transition group 允许我们把整个 ViewGroup 当作一个整体在运行动画时,所有的子视图都会一起作为一个元素进行动画。否则的话,递归继续进行,所有子视图会单独对待。

1
2
3
4
5
6
7
8
9
public boolean isTransitionGroup() {
if ((mGroupFlags & FLAG_IS_TRANSITION_GROUP_SET) != 0) {
return ((mGroupFlags & FLAG_IS_TRANSITION_GROUP) != 0);
} else {
final ViewOutlineProvider outlineProvider = getOutlineProvider();
return getBackground() != null || getTransitionName() != null ||
(outlineProvider != null && outlineProvider != ViewOutlineProvider.BACKGROUND);
}
}

默认情况下,有非空背景、transitionName 不为空、outlineProvider 不为空且不等于 ViewOutlineProvider.BACKGROUND 的 ViewGroup 会当作 Transition Group。不过我们也可以通过setTransitionGroup(boolean)来设置是否为 Transition Group。特别在运行 WebView 相关的过渡动画时,需要调用webview.setTransitionGroup(true)来将 WebView 当作一个整体。

1.4 Transition Overlap

默认情况下,内容过渡动画的 进入/返回 转换会在 退出/重新进入 转换结束前一点点开始,产生一个小的重叠来让整体的效果更自然、更协调。这个行为其实可以通过 Window/Fragment 的setAllowEnterTransitionOverlap(boolean)setAllowReturnTransitionOverlap(boolean)方法来设置,默认 overlap 是 true,进入转换会退出转换开始后尽可能快地开始,如果设置为 false,进入转换只能在退出转换结束后开始。这个对 Activity 和 Fragment 的共享元素过渡动画也是生效的。

也可以在风格定义中设置这个属性:

1
2
3
4
5
<style name="BaseAppTheme" parent="Theme.AppCompat.Light">
<!-- specify transition overlap -->
<item name="android:windowAllowEnterTransitionOverlap">true</item>
<item name="android:windowAllowReturnTransitionOverlap">true</item>
</style>

2. Activity/Fragment 切换时的共享元素过渡动画

Activity/Fragment 切换时的内容过渡动画分为 calling Activity 的退出动画和 called Activity 的进入动画,两个动画实际上是没有什么关系的。而共享元素过渡动画,在确定开始状态和结束状态是分别在两个页面上的,可以实现共享元素从一个页面到另一个页面的动画。

Android 5.0(API 21)默认提供了下面四种恭喜元素转换:

  • changeBounds - 为目标视图的布局布局边界的变化添加动画

  • changeClipBounds - 为目标视图的裁剪边界的变化添加动画

  • changeTransform - 为目标视图的缩放与旋转变化添加动画

  • changeImageTransform - 为目标图像的大小与缩放变化添加动画

与内容过渡动画类似,共享元素过渡动画也分为 sharedElementExitTransition、sharedElementEnterTransition、sharedElementReturnTransition、sharedElementReenterTransition。

2.1 启用共享元素过渡动画

一般启用了共享元素过渡动画时,都会启用内容过渡动画,所以下面的例子中也包含内容过渡动画。

在 Android 5.0 以上,要启用共享元素过渡动画要完成下面几步:

1. 打开窗口内容转换开关

在风格定义中设置:

1
2
3
4
<style name="BaseAppTheme" parent="Theme.AppCompat.Light">
<!-- enable window content transitions -->
<item name="android:windowContentTransitions">true</item>
</style>

或者在代码中设置:

1
2
3
4
5
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState)
getWindow().requestFeature(Window.FEATURE_CONTENT_TRANSITIONS)
}

这步和启用内容过渡动画的第一步是一样的。

2. 指定共享元素转换

(1)可以在风格定义中指定转换:

1
2
3
4
5
<style name="BaseAppTheme" parent="Theme.AppCompat.Light">
<!-- specify shared element transitions -->
<item name="android:windowSharedElementEnterTransition">@transition/changebounds</item>
<item name="android:windowSharedElementExitTransition">@transition/changebounds</item>
</style>

(2) 也可以在代码中指定转换:

下面代码中是指定 Activity 共享元素转换,Fragment 的话只需要改为 Fragment 的同名方法即可。

MainActivity.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_transition);
setupWindowAnimations();
}
private void setupWindowAnimations() {
// inflate from xml
ChangeImageTransform changeImageTransform = TransitionInflater.from(this).inflateTransition(R.transition.changebounds);
// or create directly
ChangeImageTransform changeImageTransform = new ChangeImageTransform();
getWindow().setSharedElementExitTransition(changeImageTransform);
}

SharedElementActivity.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_shared_element);
setupWindowAnimations();
}
private void setupWindowAnimations() {
// inflate from xml
ChangeImageTransform changeImageTransform = TransitionInflater.from(this).inflateTransition(R.transition.changebounds);
// or create directly
ChangeImageTransform changeImageTransform = new ChangeImageTransform();
getWindow().setSharedElementEnterTransition(changeImageTransform);
}

3. 为共享元素指定统一的 transition name

在布局文件中使用android:transitionName属性或者在代码中使用View.setTransitionName(String)方法来为两个页面的共享元素指定同样 的transitionName。

layout/activity_transition.xml

1
2
3
4
5
<ImageView
android:id="@+id/small_blue_icon"
style="@style/MaterialAnimations.Icon.Small"
android:src="@drawable/circle"
android:transitionName="@string/blue_name" />

layout/activity_shared_element.xml

1
2
3
4
5
<ImageView
android:id="@+id/big_blue_icon"
style="@style/MaterialAnimations.Icon.Big"
android:src="@drawable/circle"
android:transitionName="@string/blue_name" />

4.a 启动 Activity 时带上共享元素参数

使用ActivityOpstions.makeSceneTransitionAnimation()方法来指定共享元素的 origin view 和 transition name。如果要结束第二个 Activity 时也显示共享元素过渡动画,请调用Activity.finishAfterTransitino()而非Activity.finish()

MainActivity.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
blueIconImageView.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
Intent i = new Intent(MainActivity.this, SharedElementActivity.class);
View sharedView = blueIconImageView;
String transitionName = getString(R.string.blue_name);
ActivityOptions transitionActivityOptions = ActivityOptions.makeSceneTransitionAnimation(MainActivity.this, sharedView, transitionName);
startActivity(i, transitionActivityOptions.toBundle());
// version may be lower than Android 5.0
ActivityOptionsCompat transitionActivityOptions = ActivityOptionsCompat.makeSceneTransitionAnimation(MainActivity.this, sharedView, transitionName);
}
});

以上代码产生的共享元素过渡动画效果如下图:

4.b 切换 Fragment 时带上共享元素参数

在 FragmentTransaction 提交前使用FragmentTransaction.addSharedElement()方法加上共享元素信息。

1
2
3
4
getFragmentManager().beginTransaction()
.replace(R.id.content, fragmentB)
.addSharedElement(blueView, getString(R.string.blue_name))
.commit();

2.2 共享元素过渡动画的原理

和内容过渡动画类似的是,系统也会在创建共享元素动画前直接修改共享元素视图的属性。当 Activity A 启动 Activity B 时,下面的事件将会发生:

  1. A 调用startActivity(intent, bundle)后,B 启动时,窗口的背景是透明的。

  2. 系统以 A 为标准重新设置 B 的每个共享元素视图的大小和位置,过一会 B 的进入转换会记录 B 中所有共享元素的开始状态,而对于内容过渡来说,其他的 transitioning view 的可见性都是 INVISIBLE。

  3. 系统再重新将 B 的每个共享元素视图的大小和位置设置为原来的样子,过一会 B 的进入转换会记录 B 中所有共享元素的结束状态。

  4. B 的进入转换比较每个共享元素的开始状态和结束状态,创建 Animator 作为共享元素动画。

  5. 系统将隐藏 A 的所有共享元素视图,然后开始运行 B 的共享元素动画。在 B 的共享元素动画过程中,B 的窗口背景会逐渐变为不透明的。

对比内容过渡动画,内容过渡动画中系统会修改 transition views 的可见性,而共享元素过渡动画中系统会修改 shared element views 的位置、大小和显示。而且我们也可以看出实际上共享元素的 view 其实并没有在 Activity/ Fragment 之间共享,事实上,我们看到的进入或者返回的共享元素过渡动画都是直接在 B 的视图中运行的。

2.3 Shared Element Overlay

默认情况下,共享元素视图是绘制在整个视图结构之上的 – 窗口的 ViewOverlay 层。ViewOverlay 是在 Android 4.3(API 18)引入的,方便在视图上面绘制内容。添加到视图的 ViewOverlay 层的 Drawables 或者 Views 都会在所有其他视图上面绘制,不会被遮盖。而共享元素默认会在 ViewOverlay 层绘制的原因是:共享元素是整个进入转换中的焦点,如果 transitioning view 忽然遮盖了共享元素的话,整体的效果会大打折扣。

虽然共享元素视图默认是绘制在 ViewOverlay 层的,但是也可以在必要情况下使用 Window.setSharedElementsUseOverlay(false) 来禁用。除非真的有需要,最好修改这个,否则共享元素过渡动画可以会出现预料之外的效果。

2.4 更新共享元素对应关系

想象这样这个场景:GalleryActivity 里面有一个图片列表 RecyclerView,点击进入 PreviewActivity,PreviewActivity 里面是一个 ViewPager,每项都显示图片,可以左右滑动。那么如何实现在左右滑动后返回 GalleryActivity 也能有相应的共享元素动画呢?效果如下图:

下面一步一步来实现:

1. PreviewActivity 的 ViewPager 滑动时更新共享元素

通过Activity.setEnterSharedElementCallback给 PreviewActivity 设置一个自定义的 SharedElementCallback,实现onMapSharedElements方法,当 ViewPager 滑动项时更新共享元素。

2. 返回 GalleryActivity 更新共享元素

通过Activity.setExitSharedElementCallback给 GalleryActivity 设置一个自定义的 SharedElementCallback,再实现onActivityReenter回调,在回调中更新共享元素(需要 PreviewActivity 返回时 setResult())。

1. 返回 GalleryActivity 时 RecyclerView 滑动到相应图片位置

onActivityReenter回调让 RecyclerView 滑动到相应图片位置。

这个小节的更多实现细节,请看 “Dynamic” Shared Element Transition: How to hold a common gallery flow

3. 同一个页面中的场景过渡动画

前面介绍的内容过渡动画和共享元素过渡动画都是在不同页面间的过渡动画,其实过渡动画也可以在同一个页面中,只要开始场景和结束场景在同一个页面,那么创建的过渡动画就会在一个页面中运行。

在一个页面中确定开始场景和结束场景有两种方式:手动创建 Scene 和系统自动监听布局变更。

3.1 手动创建 Scene

我们可以通过 Scene 类来手动创建场景,在创建 Scene 实例时需要指定一个sceneRoot,在调用TransitionManager.go()变换场景时,转换先记录sceneRoot子节点下所有 transitioning view 的状态作为开始场景,然后移除sceneRoot下的所有视图,将新场景的布局添加到sceneRoot,再记录sceneRoot子节点下的所有 transitioning view 的状态作为结束场景,最后根据两个场景创建并运行过渡动画。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
scene1 = Scene.getSceneForLayout(sceneRoot, R.layout.activity_animations_scene1, this);
scene2 = Scene.getSceneForLayout(sceneRoot, R.layout.activity_animations_scene2, this);
scene3 = Scene.getSceneForLayout(sceneRoot, R.layout.activity_animations_scene3, this);
scene4 = Scene.getSceneForLayout(sceneRoot, R.layout.activity_animations_scene4, this);
(...)
@Override
public void onClick(View v) {
switch (v.getId()) {
case R.id.button1:
TransitionManager.go(scene1, new ChangeBounds());
break;
case R.id.button2:
TransitionManager.go(scene2, TransitionInflater.from(this).inflateTransition(R.transition.slide_and_changebounds));
break;
case R.id.button3:
TransitionManager.go(scene3, TransitionInflater.from(this).inflateTransition(R.transition.slide_and_changebounds_sequential));
break;
case R.id.button4:
TransitionManager.go(scene4, TransitionInflater.from(this).inflateTransition(R.transition.slide_and_changebounds_sequential_with_interpolators));
break;
}
}

上面代码的运行效果如下图:

3.2 系统自动监听布局变更

还有另外一种方式可以自动创建场景,原理和上面一种其实是一样的。

1. 开始延迟转换

1
TransitionManager.beginDelayedTransition(sceneRoot);

TransitionManager.beginDelayedTransition(sceneRoot) 时会马上记录sceneRoot子节点下所有 transitioning view 的状态作为开始场景,然后在下一帧中再次记录sceneRoot子节点下所有 transitioning view 的状态作为结束场景,所以在下一帧之前我们修改布局属性可以被感知到,因此根据这些差异转换可以创建并运行过渡动画。

2. 修改视图的布局属性

1
2
3
ViewGroup.LayoutParams params = greenIconView.getLayoutParams();
params.width = 200;
greenIconView.setLayoutParams(params);

4. 共享元素过渡动画 + 揭露效果(circular reveal)

在 Android 5.0(API 21)中也引入揭露效果(circular reveal),当你显示或隐藏一组 UI 元素时,揭露动画可为用户提供视觉连续性。使用ViewAnimationUtils.createCircularReveal()方法可以为裁剪区域添加动画以揭露或隐藏视图。更详细的介,绍请看官网文档 – 使用揭露效果

揭露动画可以和共享元素过渡动画结合起来创造更有意义的动画,来引导用户发生了什么。看下面这个效果:

一步一步来分析上面示例:

  • 橙色的圆圈是 MainActivity 跳转到 RevealActivity 的共享元素。

  • RevealActivity 的共享元素转换有一个 listener 监听共享元素转换的结束。监听到转换结束后,做了两件事情:(1)在 Toolbar 上运行了揭露动画;(2)对 RevealActivity 底部的四个按钮运行了放大动画。

监听共享元素转换结束

1
2
3
4
5
6
7
8
9
10
11
12
Transition transition = TransitionInflater.from(this).inflateTransition(R.transition.changebounds_with_arcmotion);
getWindow().setSharedElementEnterTransition(transition);
transition.addListener(new Transition.TransitionListener() {
@Override
public void onTransitionEnd(Transition transition) {
animateRevealShow(toolbar);
animateButtonsIn();
}
(...)
});

Toolbar 的揭露效果

1
2
3
4
5
6
7
8
9
10
11
private void animateRevealShow(View viewRoot) {
int cx = (viewRoot.getLeft() + viewRoot.getRight()) / 2;
int cy = (viewRoot.getTop() + viewRoot.getBottom()) / 2;
int finalRadius = Math.max(viewRoot.getWidth(), viewRoot.getHeight());
Animator anim = ViewAnimationUtils.createCircularReveal(viewRoot, cx, cy, 0, finalRadius);
viewRoot.setVisibility(View.VISIBLE);
anim.setDuration(1000);
anim.setInterpolator(new AccelerateInterpolator());
anim.start();
}

底部四个按钮的放大动画

1
2
3
4
5
6
7
8
9
10
11
private void animateButtonsIn() {
for (int i = 0; i < bgViewGroup.getChildCount(); i++) {
View child = bgViewGroup.getChildAt(i);
child.animate()
.setStartDelay(100 + i * DELAY)
.setInterpolator(interpolator)
.alpha(1)
.scaleX(1)
.scaleY(1);
}
}

还有很多其他的方式创建揭露动画,例如从上开始揭露、从点击位置开始揭露等,但是重要的使用动画帮助用户理解 app 的使用。

参考文章:

END
Johnny Shieh wechat
我的公众号,不只有技术,还有咖啡和彩蛋!