前不久,我写了篇关于Activity之间的过渡跳转的文章(有兴趣的请戳 Android动画 —— Activity过渡),算是初窥了Android过渡(Transition)的基本概念,以及它在Activity跳转中的应用。今天,我们就来进一步学习一下它,然后实现自定义的过渡,填补系统内置效果的不足。
一、过渡框架浅谈
Android提供过渡框架,目的是让应用的界面“动起来”,以提升视觉吸引力和视觉上的线索,让用户知道应用到底是怎么工作的。
好像还是不甚明了?
从开发的角度来讲,根本上,过渡框架就是实现了View树(View Hierarchy)之间的动画切换 —— 用动画将两个View树下的所有View连接起来。这里的View树,可能一个View,也可能是一个复杂的ViewGroup。
过渡框架支持的功能包括:
- 组级动画
对View树中的所有View应用一个或多个动画效果
- 基于过渡的动画
根据起始View和结束View的属性值变化来应用动画
- 预置动画
引入预置动画以实现通用效果,例如渐隐或移动
- 资源文件
从资源文件加载View树和内置动画
- 生命周期
定义了生命周期回调,在动画和View树变化过程中提供更好的控制
二、场景过渡
场景
在过渡框架中,有一个很重要的概念,称为场景(Scene)。一个场景,保存了一个View树的状态及其所有View的属性值,也保存了View树的父引用,场景的改变与动画都将发生在这个父引用里面。
Android字义了类Scene来表示一个场景。
场景的过渡过程将涉及起始和终止两个状态。在多数情况下,我们不用显示设置起始场景。为什么不用设置?第一,如果已经有应用过过渡,那么接下来的过渡将以已应用过的过渡的终止场景作为起始场景。第二,如果从未应用过渡,那么过渡框架会从当前的屏幕状态收集所有View的信息,然后把它们作为过渡的起始场景。
过渡
场景过渡的使用主要包括三个步骤:
- 创建起始场景、终止场景
- 设置过渡动画
- TransitionManager启动动画
说了这么多,大概还是云里雾里?还是来个例子尝尝鲜吧。
首先,定义场景所在的View树父布局:
<FrameLayout
android:id="@+id/scene_root"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingBottom="16dp"
android:paddingTop="16dp">
<include layout="@layout/a_scene" />
</FrameLayout>
前面已经说到,场景的转换将发生在它的父引用里面,也就是这里的FrameLayout了。而这里include的布局(a_scene),其实就是我们的起始场景布局。
a_scene.xml
<?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">
<!-- Ids here must be added and be the same with those in another_scene.xml as well-->
<ImageView
android:id="@+id/sun"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:src="@drawable/ic_sun" />
<ImageView
android:id="@+id/moon"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:src="@drawable/ic_moon"/>
</LinearLayout>
然后,定义终止场景布局another_scene。
another_scene.xml
<?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">
<ImageView
android:id="@+id/moon"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:src="@drawable/ic_moon"/>
<ImageView
android:id="@+id/sun"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:src="@drawable/ic_sun" />
</LinearLayout>
两个场景的布局控件相同,只是摆放的位置不同。
场景布局定义完毕,那就要开始创建场景了。调用Scene类的静态方法getSceneForLayout (ViewGroup sceneRoot, int layoutId, Context context),第一个参数是场景的父引用,第二个参数是场景的布局id。
mSceneRoot = (FrameLayout) findViewById(R.id.scene_root);
mAScene = Scene.getSceneForLayout(mSceneRoot, R.layout.a_scene, this);
mAnotherScene = Scene.getSceneForLayout(mSceneRoot, R.layout.another_scene, this);
最后,启动场景过渡动画。这里使用ChangeBounds过渡。调用TransitionManager的静态方法go (Scene scene, Transition transition),第一个参数是要切换的目的场景,第二个参数是过渡动画。
TransitionManager.go(mAnotherScene, new ChangeBounds());
效果如下:
Scenes.gif.gif是不是很方便?虽然用一般的属性动画,也可以做得到这样的效果,但是利用过渡框架,可就简单多了。
值得注意的是:两个场景的“相同”元素,需要使用相同的id,否则没有动画效果。
三、无场景过渡
有时候,我们只是需要在当前界面添加或者删除一个View —— 也就是说,变化前后的两个View树几乎是一样的。如果使用场景过渡来实现变化效果,需要维护两个几乎相同的场景布局,未免小题大做。这种情况,无场景的过渡就有了用武之地。
无场景过渡仅包含一个View树,但是维持两个状态,通过“延时过渡”(Delayed transition)实现两个状态的过渡效果。
无场景过渡的使用也主要包括三个步骤:
- 调用TransitionManager.beginDelayedTransition()保存View树状态并设置过渡
- View树发生改变,过渡框架记录新的状态
- 系统重绘,过渡框架启动过渡动画
实际上,需要自行控制的主要是前两步,其它的都由过渡框架完成。
还是来个例子。首先,定义一个过渡动画,包括ChangeBounds和Fade两个效果的组合。然后调用beginDelayedTransition()保存View树状态,并设置好前面定义的过渡动画。接着,addView或者removeView使得View树发生变化。当系统重绘时,将执行上述过渡动画。这里,不断地添加与删除View,观察过渡效果。
mWithoutTransition = new TransitionSet().addTransition(new ChangeBounds()).addTransition(new Fade());
if (!mIsRemoved) {
TransitionManager.beginDelayedTransition(mWithoutRoot, mWithoutTransition);
mWithoutRoot.removeView(mRight);
mIsRemoved = true;
} else {
TransitionManager.beginDelayedTransition(mWithoutRoot, mWithoutTransition);
mWithoutRoot.addView(mRight);
mIsRemoved = false;
}
Without scenes.gif.gif
View的增删再也不用那么无趣了。
四、局限性
当然,虽然过渡框架这么有用,它也有使用限制。
在SurfaceView上应用过渡动画,可能就不会正常实现。前面已经看到,过渡是与View树的变化是息息相关的,View树的变化引起View树重绘,然后过渡才会执行。View的动画是在UI线程,而SurfaceView实例的更新却在非UI线程,所以很可能造成不一致问题。
TextureView上应用特定的过渡动画,也可能不能正常实现。
AdapterView系的类,也不能正确实现过渡动画,因为它们与过渡框架不兼容。
另外,如果在过渡过程中改变一个TextView的大小,文本会在完全改变大小之前跳到新位置,也没法有效实现过渡。官方建议,应该避免过渡过程中改变包含文本TextView的View的尺寸。
五、自定义过渡
介绍完过渡框架在实际应用中的基本使用,是时候切入本文重点了 —— 自定义过渡。
实现
该如何实现呢?
过渡框架提供了一个类Transition,它是一个抽象类,ChangeBounds、ChangeImageTransform等等这些都是它的实现子类。类似地,自定义过渡动画,只需要继承Transition,并实现下面几个关键方法即可。
public class CustomTransition extends Transition {
@Override
public void captureStartValues(TransitionValues values) {
//TODO 捕获需要关注的起始场景的相关属性值
}
@Override
public void captureEndValues(TransitionValues values) {
//TODO 捕获需要关注的终止场景的相关属性值
}
@Override
public Animator createAnimator(ViewGroup sceneRoot,
TransitionValues startValues,
TransitionValues endValues) {
//TODO 根据起始、终止场景的属性值,决定是否生成过渡动画及动画效果的实现
}
}
捕获属性值
过渡框架的动画来自属性动画系统(Property Animation),因为属性动画是根据View的属性值在一段时间的变化来实现的,那么,过渡框架自然也是需要确定起止属性值的。
对于一个过渡,动画所需要的属性是确定的,所以过渡框架只提供这些需要的属性到过渡中(区别于属性动画系统),然后通过捕获属性的回调方法来保存属性值。
起始属性值
起始属性值在回调方法captureStartValues(TransitionValues transitionValues)中设置,然后将View的起始属性值传递给过渡框架。
其中,参数中的TransitionValues类包含两个域:
- view:View类型,保存所关注的View的引用
- values:Map类型,保存所有的属性值
为避免冲突,官方建议values的键的命名按如下规则:
package_name:transition_name:property_name
终止属性值
终止属性值在回调方法captureEndValues(TransitionValues transitionValues)中设置。此方法之于captureStartValues中的参数,虽然包含相同的view引用,但是却维护不同的values值,独立存在。
动画适配器
自定义过渡动画需要实现的第三个方法就是动画适配器了。
Animator createAnimator(ViewGroup sceneRoot,
TransitionValues startValues,
TransitionValues endValues)
参数sceneRoot是根View,startValues和endValues分别是前面捕获生成的起始和终止属性值。如果确定起始和终止的属性值有变化,那就可以生成自定义动画,并返回给过渡框架,框架将添加动画时长、插值器等等并启动此动画。默认返回null,框架不作操作。
createAnimator()被回调的次数,依赖于起始与终止场景中的“变化数” —— 也就是说,到底我们需要改变多少个目标对象(毕竟,不同目标,动画可能并不相同)。比如,起始场景有5个目标,其中3个留至结束,2个删除。终止场景有4个目标,3个来自于起始场景,1个是新增,那么,createAnimator()将被回调3 + 2 + 1=6次。
自定义过渡动画
说了这么多,我们来看看具体的代码实现。
背景颜色过渡
首先来看一个官方的例子,此过渡用于改变背景颜色(ChangeColor)。
/*
* Copyright (C) 2014 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.djx.customtransition;
import android.animation.Animator;
import android.animation.ArgbEvaluator;
import android.animation.ValueAnimator;
import android.graphics.drawable.ColorDrawable;
import android.graphics.drawable.Drawable;
import android.transition.Transition;
import android.transition.TransitionValues;
import android.view.View;
import android.view.ViewGroup;
public class ChangeColor extends Transition {
/** Key to store a color value in TransitionValues object */
private static final String PROPNAME_BACKGROUND = "customtransition:change_color:background";
/**
* Convenience method: Add the background Drawable property value
* to the TransitionsValues.value Map for a target.
*/
private void captureValues(TransitionValues values) {
// Capture the property values of views for later use
values.values.put(PROPNAME_BACKGROUND, values.view.getBackground());
}
@Override
public void captureStartValues(TransitionValues transitionValues) {
captureValues(transitionValues);
}
// Capture the value of the background drawable property for a target in the ending Scene.
@Override
public void captureEndValues(TransitionValues transitionValues) {
captureValues(transitionValues);
}
// Create an animation for each target that is in both the starting and ending Scene. For each
// pair of targets, if their background property value is a color (rather than a graphic),
// create a ValueAnimator based on an ArgbEvaluator that interpolates between the starting and
// ending color. Also create an update listener that sets the View background color for each
// animation frame
@Override
public Animator createAnimator(ViewGroup sceneRoot,
TransitionValues startValues, TransitionValues endValues) {
// This transition can only be applied to views that are on both starting and ending scenes.
if (null == startValues || null == endValues) {
return null;
}
// Store a convenient reference to the target. Both the starting and ending layout have the
// same target.
final View view = endValues.view;
// Store the object containing the background property for both the starting and ending
// layouts.
Drawable startBackground = (Drawable) startValues.values.get(PROPNAME_BACKGROUND);
Drawable endBackground = (Drawable) endValues.values.get(PROPNAME_BACKGROUND);
// This transition changes background colors for a target. It doesn't animate any other
// background changes. If the property isn't a ColorDrawable, ignore the target.
if (startBackground instanceof ColorDrawable && endBackground instanceof ColorDrawable) {
ColorDrawable startColor = (ColorDrawable) startBackground;
ColorDrawable endColor = (ColorDrawable) endBackground;
// If the background color for the target in the starting and ending layouts is
// different, create an animation.
if (startColor.getColor() != endColor.getColor()) {
// Create a new Animator object to apply to the targets as the transitions framework
// changes from the starting to the ending layout. Use the class ValueAnimator,
// which provides a timing pulse to change property values provided to it. The
// animation runs on the UI thread. The Evaluator controls what type of
// interpolation is done. In this case, an ArgbEvaluator interpolates between two
// #argb values, which are specified as the 2nd and 3rd input arguments.
ValueAnimator animator = ValueAnimator.ofObject(new ArgbEvaluator(),
startColor.getColor(), endColor.getColor());
// Add an update listener to the Animator object.
animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
Object value = animation.getAnimatedValue();
// Each time the ValueAnimator produces a new frame in the animation, change
// the background color of the target. Ensure that the value isn't null.
if (null != value) {
view.setBackgroundColor((Integer) value);
}
}
});
// Return the Animator object to the transitions framework. As the framework changes
// between the starting and ending layouts, it applies the animation you've created.
return animator;
}
}
// For non-ColorDrawable backgrounds, we just return null, and no animation will take place.
return null;
}
非常简单,背景的变化通过ValueAnimator的更新监听进行控制。
使用起来也和系统内置过渡效果一样。
在AS中生成一个简单工程,在主界面MainActivity添加一个文本“Hello World”,然后SecondActivity中同样添加一个文本“Hello World”,设置Activity的共享过渡,并添加过渡动画为这个ChangeColor。
SecondActivity.java
package com.djx.customtransition;
import android.app.SharedElementCallback;
import android.graphics.drawable.ColorDrawable;
import android.os.Bundle;
import android.support.v7.app.AppCompatActivity;
import android.transition.ChangeBounds;
import android.transition.Transition;
import android.transition.TransitionSet;
import android.view.View;
import android.widget.TextView;
import java.util.List;
public class SecondActivity extends AppCompatActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_second);
getWindow().setSharedElementEnterTransition(getTransition());
setEnterSharedElementCallback(new SharedElementCallback() {
@Override
public void onSharedElementStart(List<String> sharedElementNames, List<View> sharedElements, List<View> sharedElementSnapshots) {
TextView target = (TextView) sharedElements.get(0);
target.setBackground(new ColorDrawable(getColor(R.color.bg_start)));
}
@Override
public void onSharedElementEnd(List<String> sharedElementNames, List<View> sharedElements, List<View> sharedElementSnapshots) {
TextView target = (TextView) sharedElements.get(0);
target.setBackground(new ColorDrawable(getColor(R.color.bg_end)));
}
});
}
private Transition getTransition() {
TransitionSet set = new TransitionSet();
ChangeBounds bounds = new ChangeBounds();
bounds.addTarget(R.id.hello);
set.addTransition(bounds);
ChangeColor bg = new ChangeColor();
bg.addTarget(R.id.hello);
set.addTransition(bg);
return set;
}
}
关于上面的代码,需要注意两点。
第一,为了设置正确的起始和终止场景属性值,共享过渡的回调SharedElementCallback必须添加,因为几个关键方法的回调顺序为:
onSharedElementStart -> captureStartValues -> onSharedElementEnd -> captureEndValues
如果不在onSharedElementStart和onSharedElementEnd中设置正确的始末属性值,那过渡无法知晓属性值的变化,createAnimator不会回调,过渡动画也就无从谈起。
第二,过渡动画不仅添加了自定义的ChangeColor,还添加了ChangeBounds。单独一个ChangeColor是无法生效的。
在主界面中启动SecondActivity,看下效果
ChangeColor.gif文本颜色过渡
前面官方的例子,自然成功实现,没有问题。现在依葫芦画瓢,来实现一个文本颜色的过渡动画。代码如下:
package com.djx.customtransition;
import android.animation.Animator;
import android.animation.ArgbEvaluator;
import android.animation.ObjectAnimator;
import android.content.Context;
import android.transition.Transition;
import android.transition.TransitionValues;
import android.util.AttributeSet;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
public class ChangeTextColor extends Transition {
private static final String PROPNAME_TEXT_COLOR = "com.djx.customtransition:ChangeTextColor:textColor";
public ChangeTextColor() {
}
public ChangeTextColor(Context context, AttributeSet attrs) {
super(context, attrs);
}
private void captureValues(TransitionValues transitionValues) {
if (transitionValues.view instanceof TextView) {
transitionValues.values.put(PROPNAME_TEXT_COLOR,
((TextView) transitionValues.view).getCurrentTextColor());
}
}
@Override
public void captureStartValues(TransitionValues transitionValues) {
captureValues(transitionValues);
}
@Override
public void captureEndValues(TransitionValues transitionValues) {
captureValues(transitionValues);
}
@Override
public Animator createAnimator(ViewGroup sceneRoot, TransitionValues startValues,
TransitionValues endValues) {
if (startValues == null || endValues == null) {
return null;
}
final View view = endValues.view;
if (view instanceof TextView) {
TextView textView = (TextView) view;
int start = (Integer) startValues.values.get(PROPNAME_TEXT_COLOR);
int end = (Integer) endValues.values.get(PROPNAME_TEXT_COLOR);
if (start != end) {
return ObjectAnimator.ofObject(textView, "textColor",
new ArgbEvaluator(), start, end);
}
}
return null;
}
}
也非常简单,颜色的变化直接使用ObjectAnimator生成了一个属性动画。在SecondActivity中,注释掉前面的ChangeColor过渡,添加ChangeTextColor。
private Transition getTransition() {
TransitionSet set = new TransitionSet();
ChangeBounds bounds = new ChangeBounds();
bounds.addTarget(R.id.hello);
set.addTransition(bounds);
// ChangeColor bg = new ChangeColor();
// bg.addTarget(R.id.hello);
// set.addTransition(bg);
ChangeTextColor textColor = new ChangeTextColor();
textColor.addTarget(R.id.hello);
set.addTransition(textColor);
return set;
}
同时,在onSharedElementStart和onSharedElementEnd中分别设置起始和终止的文本颜色值。
setEnterSharedElementCallback(new SharedElementCallback() {
@Override
public void onSharedElementStart(List<String> sharedElementNames, List<View> sharedElements, List<View> sharedElementSnapshots) {
TextView target = (TextView) sharedElements.get(0);
// target.setBackground(new ColorDrawable(getColor(R.color.bg_start)));
target.setTextColor(getColor(R.color.colorPrimary));
}
@Override
public void onSharedElementEnd(List<String> sharedElementNames, List<View> sharedElements, List<View> sharedElementSnapshots) {
TextView target = (TextView) sharedElements.get(0);
// target.setBackground(new ColorDrawable(getColor(R.color.bg_end)));
target.setTextColor(getColor(R.color.colorAccent));
}
});
主界面中启动SecondActivity,效果如下
ChangeTextColor.gif.gif同时添加ChangeColor和ChangeTextColor,主界面中启动SecondActivity,效果正常
ChangeColor & ChangeTextColor.gif.gif文本大小过渡
既然背景颜色和文本颜色都可以过渡,那么文本大小呢?
同样,实现一个文本大小过渡ChangeTextSize
package com.djx.customtransition;
import android.animation.Animator;
import android.animation.ValueAnimator;
import android.transition.Transition;
import android.transition.TransitionValues;
import android.util.TypedValue;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
public class ChangeTextSize extends Transition {
private static final String PROPNAME_TEXT_SIZE = "com.djx.customtransition:ChangeTextSize:textSize";
private void captureValues(TransitionValues transitionValues) {
if (transitionValues.view instanceof TextView) {
transitionValues.values.put(PROPNAME_TEXT_SIZE,
((TextView) transitionValues.view).getTextSize());
}
}
@Override
public void captureStartValues(TransitionValues transitionValues) {
captureValues(transitionValues);
}
@Override
public void captureEndValues(TransitionValues transitionValues) {
captureValues(transitionValues);
}
@Override
public Animator createAnimator(ViewGroup sceneRoot, TransitionValues startValues, TransitionValues endValues) {
if (startValues == null || endValues == null) {
return null;
}
final View view = endValues.view;
if (view instanceof TextView) {
final TextView textView = (TextView) view;
float start = (Float) startValues.values.get(PROPNAME_TEXT_SIZE);
float end = (Float) endValues.values.get(PROPNAME_TEXT_SIZE);
if (start != end) {
ValueAnimator animator = ValueAnimator.ofFloat(start, end);
animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
Object value = animation.getAnimatedValue();
if (null != value) {
textView.setTextSize(TypedValue.COMPLEX_UNIT_PX, (Float) value);
}
}
});
return animator;
}
}
return null;
}
}
修改SecondActivity中的“Hello Wolrd”文本大小,然后把此过渡添加至前面的过渡组合中
ChangeTextSize.gif咦?好像不是那么回事了!仔细看看,其实文本的大小,还是按预期过渡的,是一个逐渐变大变小的过程。但是,主界面到二界面时,直到过渡的最后一刻,控件的尺寸才变成实际大小,即文本变大后的实际控件尺寸。
这就是前面官方已经提到过的关于包含文本控件时的过渡局限性了。
有没有办法解决?
当然有!国外的大牛已经搞定了,膜拜之。
问题原因已经知道,就是过渡过程中,文本在变大,但是View本身的尺寸却没有跟随着变大 —— 因为过渡框架不知道应该改变控件尺寸啊!那么,就在设置终止文本大小值的时候,也把控件的尺寸修改一下可好?看看代码上怎么做:
public void onSharedElementEnd(List<String> sharedElementNames, List<View> sharedElements, List<View> sharedElementSnapshots) {
TextView target = (TextView) sharedElements.get(0);
// Record the TextView's old width/height.
int oldWidth = target.getMeasuredWidth();
int oldHeight = target.getMeasuredHeight();
target.setTextColor(getColor(R.color.colorAccent));
target.setBackground(new ColorDrawable(getColor(R.color.bg_end)));
target.setTextSize(TypedValue.COMPLEX_UNIT_PX, getResources().getDimensionPixelSize(R.dimen.end_text_size));
// Re-measure the TextView (since the text size has changed).
int widthSpec = View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED);
int heightSpec = View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED);
target.measure(widthSpec, heightSpec);
// Record the TextView's new width/height.
int newWidth = target.getMeasuredWidth();
int newHeight = target.getMeasuredHeight();
// Set the new bounds
int widthDiff = newWidth - oldWidth;
int heightDiff = newHeight - oldHeight;
target.layout(target.getLeft(), target.getTop(),
target.getRight() + widthDiff, target.getBottom() + heightDiff);
}
首先保存一个原始尺寸,设置新的文本大小后重新测量,然后根据始末尺寸差值,设置过渡结束时的控件尺寸。
Threesome.gif嗯,原生过渡框架所谓的局限性也不再局限了,完美!
六、小结
过渡框架中的场景过渡和无场景过渡,使用起来非常方便,大家可以发散思维把它们应用到除这里讲到的更多的应用场景中去。
自定义过渡时,从原生例子入手,但是过程中失败很多,也踩了很多雷,多是因为理解不透彻,到最后搞明白的时候发现,其实官书API或者文档已经说清楚了。
路漫漫其修远兮啊!
照样,例子奉上,仅供参考。
网友评论