这篇文章在说什么?
3d翻页部分其实比较简单,因为Google在ApiDemos里给了动画部分的实现源码。麻烦的是FragmentTransaction.setCustomAnimations如何设置一个特殊的不是通过xml创建的Animation。本文给了解决方法,以及是如何发现这个解决办法的。
这个地址有完整的源码。
https://github.com/aesean/Rotate3d
源码包含:
- 如何让Fragment实现翻转
- 如何让View实现翻转
- 以及一个跟Google的Rotate3dAnimation效果一摸一样的Animator实现。
正文
最近遇到一个需求,是某个界面有两种显示样式。然后有按钮可以在这两种样式之间随意切换。大致有点像下面图中效果。
最终效果差不多就是类似这个图。那假如第一次看到这个效果图,思考下,我们应该如何实现呢?
实现思路
虽然图有左右两个,实际其实只要实现其中一个另一个其实就做同样实现就可以了。下面所有讨论都只针对左半部分。
图中效果就两部分组成:View+动画。
- View
View的话用Fragment实现就OK(当然ViewGroup嵌套也能做到,但为了更方便的封装复用,显然Fragment会更好)。 - 动画
然后动画的话可以直接用Fragment(V4)的CustomAnimations来实现。 - Rotate3dAnimation
剩下一个唯一难点,CustomAnimations是个Animation动画,那这个效果如何实现呢?如果你看过或者用过Google在AndroidSDK中附带的ApiDemos的话,有个类完全就是一摸一样的效果。
https://android.googlesource.com/platform/development/+/master/samples/ApiDemos/src/com/example/android/apis/animation/Rotate3dAnimation.java
再梳理下思路。左右两部分都用Fragment实现。然后左边是两个Fragment(正面一个背面一个),右边也是两个。然后需要切换的时候就通过transaction.setCustomAnimations设置切换需要的动画,然后通过show/hide(根据你需要也可以add/remove)来控制Fragment的显示与消失。这样一来动画效果完全与Fragment解耦,相当于是任意Fragment都可以使用,似乎没什么问题。
开始实现
- 定义Fragment
先定义好自己的Fragment。左边需要两个Fragment,假如就叫:FragmentA和FragmentB,分别对应正面和背面。 - 控制显示与消失
控制显示与消失,可以用add/remove(每次会重新创建Fragment实例),也可以使用show/hide(会复用Fragment实例)。当然这里我们肯定用show/hide了。在Activity中你可能会写出类似下面的代码。
private static final String FRAGMENT_TAG_A = "FRAGMENT_TAG_A";
private static final String FRAGMENT_TAG_B = "FRAGMENT_TAG_B";
public void showFragmentA() {
showFragment(FRAGMENT_TAG_A, FRAGMENT_TAG_B);
}
public void showFragmentB() {
showFragment(FRAGMENT_TAG_B, FRAGMENT_TAG_A);
}
public void showFragment(String showTag, String hideTag) {
FragmentTransaction transaction = getSupportFragmentManager().beginTransaction();
// 设置动画
transaction.setCustomAnimations(enterId ?, exitId ?);
Fragment fragment = getSupportFragmentManager().findFragmentByTag(showTag);
if (fragment == null) {
// 没有找到表示没有被创建过
fragment = new FragmentA();
// 直接add
transaction.add(R.id.fragment_content, fragment, showTag);
} else {
// 找到了,表示已经被add了,所以直接show
transaction.show(fragment);
}
fragment = getSupportFragmentManager().findFragmentByTag(hideTag);
if (fragment != null) {
// 找到了,直接hide
transaction.hide(fragment);
}
transaction.commit();
}
所有代码都非常顺利,唯独
transaction.setCustomAnimations(enterId ?, exitId ?);
出问题了,我们这里有个Google写好的Rotate3dAnimation,但这里只能指定RId,也就是说这里只能指定xml定义的Animation。而且没有任何重载方法可以设置Animation实例。
怎么设置自定义Animation实例
怎么办呢?先看下setCustomAnimations注释怎么写的。
/**
* Set specific animation resources to run for the fragments that are
* entering and exiting in this transaction. These animations will not be
* played when popping the back stack.
*/
解释的非常清楚,然并卵。
然后,最直接的就是把Fragment相关源码读一遍,看下整个处理过程,看看Google有没有留下什么方式能做到自定义Animation。
但Fragment源码代码量还是非常大的,如果你之前完全没有细读过Fragment实现,那效率会比较低,这里不急着看Fragment实现代码,我们来猜测下Google这里是如何通过Rid来实现切换动画的。
- 虽然这时候还没细读Fragment源码,但这个转场动画,最终一定是把Animation作用到View上,而且代码非常可能就是view.startAnimation。
- transaction.setCustomAnimations之后,应该是保存了动画资源Id,然后再某个时候把xml加载成Animation。加载xml定义的Animation基本跑不了肯定就是AnimationUtils.loadAnimation
这时候最简单的,先去Fragment类源码中搜下“.startAnimation”和“AnimationUtils.loadAnimation”,非常遗憾都没有找到。
不要紧,Fragment有三个很重要的类:Fragment、FragmentTransaction和FragmentManager。分别去另外两个实现类中搜下。FragmentTransaction和实现类是BackStackRecord,FragmentManager的实现类是FragmentManagerImpl。
在FragmentManagerImpl类中搜到了startAnimation,而且还不止一处。这里其实随便选一处就可以了(几个地方其实都能找到需要的信息)。这里选个相关代码最简单的。
// run animations:
Animation anim = loadAnimation(f, f.getNextTransition(), true,
f.getNextTransitionStyle());
if (anim != null) {
setHWLayerAnimListenerIfAlpha(f.mView, anim);
f.mView.startAnimation(anim);
}
这里其实就是Animation实际是怎么从Rid变成Animation实例的。f.getNextTransition就是之前设置的动画资源id,true表示是enter还是exit。这里通过loadAnimation方法来加载动画。
Animation loadAnimation(Fragment fragment, int transit, boolean enter,
int transitionStyle) {
Animation animObj = fragment.onCreateAnimation(transit, enter, fragment.getNextAnim());
if (animObj != null) {
return animObj;
}
if (fragment.getNextAnim() != 0) {
Animation anim = AnimationUtils.loadAnimation(mHost.getContext(),
fragment.getNextAnim());
if (anim != null) {
return anim;
}
}
......
}
代码不多,这里一下子答案就清晰了。最终确实是通过AnimationUtils.loadAnimation来加载动画资源的。但在加载之前会先
调用fragment.onCreateAnimation方法,如果这个方法返回空才会去调用AnimationUtils.loadAnimation。办法来了,可以复写Fragment的onCreateAnimation方法来拦截Animation的创建。复写这个方法,return Rotate3dAnimation就可以了。
这里我们创建两个空xml anim(只是为了用这个id)。名字叫:rotate_3d_enter和rotate_3d_exit。实现都是空。
然后复写Fragment的
@Override
public Animation onCreateAnimation(int transit, boolean enter, int nextAnim) {
if (nextAnim == R.anim.rotate_3d_enter) {
return Rotate3dAnimation;
}
if (nextAnim == R.anim.rotate_3d_exit) {
return Rotate3dAnimation;
}
return super.onCreateAnimation(transit, enter, nextAnim);
}
这样就可以使用Rotate3dAnimation了。
Rotate3dAnimation参数
现在可以transaction.setCustomAnimations已经可以使用自定义的Animation了。但上面还遗留了一个问题,怎么创建Rotate3dAnimation。这个类有6个参数。
float fromDegrees 起始角度
float toDegrees 结束角度
角度参数很简单,正面的应该是从0度到90度,背面的应该是从270度到360度。
float centerX 中心点x
float centerY 中心点y
float depthZ 深度
中心点第一感觉就是通过getView.getWidth()0.5f getView().getHeight()0.5f。实际这样是会有问题的,因为onCreateAnimation并不一定就是在View全部绘制完成才回调的。但是因为initialize方法会把View实际大小传过来。所以我们可以不需要自己计算View的宽和高。
@Override
public void initialize(int width, int height, int parentWidth, int parentHeight) {
}
我们可以把构造方法改造下,宽和高不再传实际的像素,而是传对应的比例。
/**
* An animation that rotates the view on the Y axis between two specified angles.
* This animation also adds a translation on the Z axis (depth) to improve the effect.
*/
public class Rotate3dAnimation extends Animation {
private static final int TYPE_SCALE = 0;
private static final int TYPE_PX = 1;
private final float mFromDegrees;
private final float mToDegrees;
private float mCenterX;
private float mCenterY;
private float mDepthZ;
private int mType = TYPE_PX;
private final boolean mReverse;
private Camera mCamera;
/**
* Creates a new 3D rotation on the Y axis. The rotation is defined by its
* start angle and its end angle. Both angles are in degrees. The rotation
* is performed around a center point on the 2D space, definied by a pair
* of X and Y coordinates, called centerX and centerY. When the animation
* starts, a translation on the Z axis (depth) is performed. The length
* of the translation can be specified, as well as whether the translation
* should be reversed in time.
*
* @param fromDegrees the start angle of the 3D rotation
* @param toDegrees the end angle of the 3D rotation
* @param centerX the X center of the 3D rotation
* @param centerY the Y center of the 3D rotation
* @param reverse true if the translation should be reversed, false otherwise
*/
public Rotate3dAnimation(float fromDegrees, float toDegrees,
float centerX, float centerY, float depthZ, boolean reverse) {
mFromDegrees = fromDegrees;
mToDegrees = toDegrees;
mCenterX = centerX;
mCenterY = centerY;
mDepthZ = depthZ;
mReverse = reverse;
}
public Rotate3dAnimation(float fromDegrees, float toDegrees
, float centerX, float centerY, float depthZ
, boolean reverse, int type) {
mFromDegrees = fromDegrees;
mToDegrees = toDegrees;
mCenterX = centerX;
mCenterY = centerY;
mDepthZ = depthZ;
mReverse = reverse;
mType = type;
}
public Rotate3dAnimation(float fromDegrees, float toDegrees, boolean reverse) {
this(fromDegrees, toDegrees, 0.5f, 0.5f, 0.5f, reverse, TYPE_SCALE);
}
@Override
public void initialize(int width, int height, int parentWidth, int parentHeight) {
super.initialize(width, height, parentWidth, parentHeight);
mCamera = new Camera();
if (mType == TYPE_SCALE) {
mCenterX = width * mCenterX;
mCenterY = height * mCenterY;
mDepthZ = width * mDepthZ;
}
}
@Override
protected void applyTransformation(float interpolatedTime, Transformation t) {
final float fromDegrees = mFromDegrees;
float degrees = fromDegrees + ((mToDegrees - fromDegrees) * interpolatedTime);
final float centerX = mCenterX;
final float centerY = mCenterY;
final Camera camera = mCamera;
final Matrix matrix = t.getMatrix();
camera.save();
if (mReverse) {
camera.translate(0.0f, 0.0f, mDepthZ * interpolatedTime);
} else {
camera.translate(0.0f, 0.0f, mDepthZ * (1.0f - interpolatedTime));
}
camera.rotateY(degrees);
camera.getMatrix(matrix);
camera.restore();
matrix.preTranslate(-centerX, -centerY);
matrix.postTranslate(centerX, centerY);
}
}
Rotate3dAnimation整个类被改造成上面的样子。可以直接使用三个参数的构造方法Rotate3dAnimation(float fromDegrees, float toDegrees, boolean reverse)以中心点为旋转轴心,以宽度一半为旋转深度。这里为什么要取一半呢?仔细思考下,当翻转进行到一半的时候View处于什么状态?这时候View刚好与屏幕垂直,View深度也刚好是View宽度的一半,而此时也是翻转过程中的最大深度,所以默认取宽度一半的深度效果比较好。
boolean reverse 反转(这个参数Google给的注释是:true if the translation should be reversed, false otherwise。这个参数看源码会发现其实只影响深度depthZ,表示深度是从0变到depthZ,还是从depthZ变到0)正面翻的时候应该是从0到depthZ,而此时背面应该是从depthZ到0。
Rotate3dHelper
public class AnimationHelper {
private AnimationHelper(){
}
public static void setUpRotate3dAnimation(android.support.v4.app.FragmentTransaction transaction) {
transaction.setCustomAnimations(R.anim.rotate_3d_enter, R.anim.rotate_3d_exit);
}
public static Animation onCreateAnimation(int transit, boolean enter, int nextAnim) {
if (nextAnim == R.anim.rotate_3d_enter) {
final Rotate3dAnimation animation = new Rotate3dAnimation(270, 360, false);
animation.setDuration(600);
animation.setStartOffset(300);
animation.setFillAfter(false);
animation.setInterpolator(new DecelerateInterpolator());
return animation;
}
if (nextAnim == R.anim.rotate_3d_exit) {
Rotate3dAnimation animation = new Rotate3dAnimation(0, 90, true);
animation.setDuration(300);
animation.setFillAfter(false);
animation.setInterpolator(new AccelerateInterpolator());
return animation;
}
return null;
}
}
写个工具类,方便调用。然后在对应需要用到这个效果的Fragment中添加下面的代码。
@Override
public Animation onCreateAnimation(int transit, boolean enter, int nextAnim) {
Animation animation = AnimationHelper.onCreateAnimation(transit, enter, nextAnim);
if (animation == null) {
return super.onCreateAnimation(transit, enter, nextAnim);
} else {
return animation;
}
}
最终实现
此时问题就全部排除了。前面显示与隐藏Fragment的代码改造成下面这样:
private static final String FRAGMENT_TAG_A = "FRAGMENT_TAG_A";
private static final String FRAGMENT_TAG_B = "FRAGMENT_TAG_B";
public void showFragmentA() {
showFragment(FRAGMENT_TAG_A, FRAGMENT_TAG_B);
}
public void showFragmentB() {
showFragment(FRAGMENT_TAG_B, FRAGMENT_TAG_A);
}
public void showFragment(String showTag, String hideTag) {
FragmentTransaction transaction = getSupportFragmentManager().beginTransaction();
// 设置动画
AnimationHelper.setUpRotate3dAnimation(transaction);
Fragment fragment = getSupportFragmentManager().findFragmentByTag(showTag);
if (fragment == null) {
// 没有找到表示没有被创建过
fragment = new FragmentA();
// 直接add
transaction.add(R.id.fragment_content, fragment, showTag);
} else {
// 找到了,表示已经被add了,所以直接show
transaction.show(fragment);
}
fragment = getSupportFragmentManager().findFragmentByTag(hideTag);
if (fragment != null) {
// 找到了,直接hide
transaction.hide(fragment);
}
transaction.commit();
}
这样就可以setCustomAnimations使用自己自定义的Animation了。
其他
这里主要是介绍一种思路,setCustomAniamtions不能set自定义Animation的时候怎么办?看注释,Google,都不能解决的时候,如果通过分析猜测快速定位解决问题。当然中间还有很多Fragment相关的一些东西并没有直接分析到。比如Fragment,FragmentManageer,FragmentTransaction之间的关系等。主要是Fragment本身相对还是比较复杂的,什么时候有空了,会把Fragment的源码写个文章分析下,会解释清楚,Fragment到底是什么,Fragment最后是如何显示的,DialogFragment明明没有指定ContainerId,为什么它还是能显示等等。
Rotate3dAnimator
最后再加一个Rotate3dAnimator。为什么有个Animator?前面Google给的是Animation,但是假如你的项目使用的是android.app.Fragment。那么你在Fragment需要复写的就是
@Override
public Animator onCreateAnimator(int transit, boolean enter, int nextAnim) {
Animator animator = AnimationHelper.onCreateAnimator(transit, enter, nextAnim);
if (animator == null) {
return super.onCreateAnimator(transit, enter, nextAnim);
} else {
return animator;
}
}
这里就是3.0之后的属性动画了。所以前面Google给的Rotate3dAnimation就不能用了。那怎么办?这里就需要写一个3d变换的Animator实现了。下面给出实现代码。
import android.animation.Animator;
import android.animation.AnimatorListenerAdapter;
import android.animation.ValueAnimator;
import android.support.annotation.Nullable;
import android.view.View;
public class Rotate3dAnimator extends ValueAnimator implements ValueAnimator.AnimatorUpdateListener {
private static double K = Math.sqrt(2.0f);
private static final int TYPE_SCALE = 0;
private static final int TYPE_PX = 1;
private View mTargetView;
private final float mFromDegrees;
private final float mToDegrees;
private float mCenterX;
private float mCenterY;
private float mDepthZ;
private int mType = TYPE_PX;
private boolean mNeedInit = true;
private final boolean mReverse;
private boolean mException = true;
private boolean mVisibleBeforeStart = false;
public Rotate3dAnimator(float fromDegrees, float toDegrees, boolean reverse) {
this(fromDegrees, toDegrees, 0.5f, 0.5f, 0.5f, reverse, TYPE_SCALE);
}
public Rotate3dAnimator(float fromDegrees, float toDegrees,
float centerX, float centerY, float depthZ,
boolean reverse, int type) {
this.mFromDegrees = fromDegrees;
this.mToDegrees = toDegrees;
this.mReverse = reverse;
this.mCenterX = centerX;
this.mCenterY = centerY;
this.mDepthZ = depthZ;
this.mType = type;
addUpdateListener(this);
addListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationStart(Animator animation) {
if (!mVisibleBeforeStart) {
mTargetView.setVisibility(View.VISIBLE);
}
if (mNeedInit) {
if (mType == TYPE_SCALE) {
mCenterX = mCenterX * mTargetView.getWidth();
mCenterY = mCenterY * mTargetView.getHeight();
}
mTargetView.setPivotX(mCenterX);
mTargetView.setPivotY(mCenterY);
mNeedInit = false;
}
removeListener(this);
}
});
setFloatValuesSafe(0f, 1f);
}
private void setFloatValuesSafe(float... values) {
mException = false;
setFloatValues(values);
mException = true;
}
@Override
public void setFloatValues(float... values) {
if (mException) {
throw new IllegalAccessError("Disable call. ");
}
super.setFloatValues(values);
}
public void setStartDelay(long startDelay, boolean visibleBeforeStart) {
super.setStartDelay(startDelay);
mVisibleBeforeStart = visibleBeforeStart;
}
View getTargetView() {
return mTargetView;
}
@Override
public void setTarget(@Nullable Object target) {
super.setTarget(target);
if (target == null) {
throw new NullPointerException("Target can't be null.");
}
mTargetView = (View) target;
if (!mVisibleBeforeStart) {
mTargetView.setVisibility(View.INVISIBLE);
}
}
@Override
public void onAnimationUpdate(ValueAnimator animation) {
float progress = (float) animation.getAnimatedValue();
final float fromDegrees = mFromDegrees;
float degrees = fromDegrees + ((mToDegrees - fromDegrees) * progress);
double value = mDepthZ * K;
if (mReverse) {
// progress 0 - 1
// exit 1 -> value
mTargetView.setScaleX((float) (1 - (1 - value) * progress));
mTargetView.setScaleY((float) (1 - (1 - value) * progress));
} else {
// progress 0 - 1
// enter value -> 1
mTargetView.setScaleX((float) (value + (1 - value) * progress));
mTargetView.setScaleY((float) (value + (1 - value) * progress));
}
mTargetView.setRotationY(degrees);
}
}
注意有个setStartDelay方法有两个参数,第二个参数是让View在start前不显示。为什么要这样?因为翻转的时候,背面的View需要在第一个View动画处理完了才开始显示,如果这个参数不指定false,那么第一次翻转时候会有问题。具体可以自行尝试下。
另外就是为什么这里翻转时候不是移动Z轴,而是对XY轴做Scale变换?这个。。。怎么解释呢?首先translationZ是5.0之后的Api。其次translationZ是不能实现3d效果的翻转的。整个翻转的深度效果其实就是尽量保证翻转时候有一条边的高度搞好一直与容器高度相同。所以这里通过Scale来实现。具体大家可自行尝试translationZ看看实际是什么效果就明白了。