Android 视图动画的两个坑

阅读时间 约 1 分钟

AnimationSet 顺序执行动画的问题

有个需求是要做的一个TextView弹跳三次的动画,基本上就是六个参数不同的位移动画,比较简单,所以就懒得用属性动画,准备直接用xml写一个Animation实现了。

于是在xml文件里面是这么写的:第一个translate动画从原位置(0%)移动到上面一定高度(-40%),然后再从这个高度(-40%)移动到原位置(0%),以此类推,然后用startOffset参数控制动画顺序。

<set xmlns:android="http://schemas.android.com/apk/res/android">
    <translate
        android:fromYDelta="0%"
        android:toYDelta="-40%"
        android:duration="350" />
    <translate
        android:fromYDelta="-40%"
        android:toYDelta="0%"
        android:startOffset="350"
        android:duration="200" />
    ...
</set>

然后就发现悲剧了,动画播放的时候TextView总会突然跳上去,然后继续向上运动,动画全部完成后又再跳回来。

研究了一下,发现是掉入fillEnabled和fillBefore的坑中了:

  1. fill开头的属性总共有三个:fillEnabled、fillBefore和fillAfter
  2. 其中fillAfter属性是独立的、与其它两个属性无关,表示动画结束以后是否将动画的最后一帧应用到View上
  3. fillAfter默认为false,即回到原处,上面的例子里没有涉及这个属性
  4. fillEnabled属性是fillBefore属性的开关,fillBefore属性表示动画执行的第一帧是否应用到这个View
  5. 默认fillEnabled和fillBefore都为true
  6. 这两个属性的逻辑比较绕,是因为fillEnabled属性是Google为了解决一些问题后续版本才加上去的

在上面的例子中,虽然我是想让这几个translate动画顺序执行,设置了不同的startOffset,但是AnimationSet在加载并判断是否应该fillBefore时,却并没有考虑startOffset。

这就导致了一个问题,fillBefore默认为true,那么在试图startAnimation时,系统会将AnimationSet中的每一个动画的初始值都设置到这个View上,相同状态后面的会覆盖前面的,在上面这个代码的三次弹跳(6个translate动画)中,最后一次恰好是从上到下,即初始状态在上。所以整个动画刚开始的时候这个TextView会向上跳一下,然后再向上运动,这就与我们所期望的效果不一致了

明白了问题的原因,就有办法解决了。

最直观的解决办法就是使用AnimationListener,在一个动画播放结束以后再播放下一个动画,避免后面的动画被提前fillBefore。但显然,这个方法需要写大量的Java代码,太过啰嗦,多了几个内部类对方法数也有影响,不够优雅。

第二个办法就是利用AnimationSet播放时整个动画播放完毕才会判断是否fillAfter的特性,每个动画都只以自身为原点做相对运动,保持初始状态均为0,即:

<set xmlns:android="http://schemas.android.com/apk/res/android">
    <translate
        android:fromYDelta="0%"
        android:toYDelta="-40%"
        android:duration="350" />
    <translate
        android:fromYDelta="0%"
        android:toYDelta="40%"
        android:startOffset="350"
        android:duration="200" />
    <translate
        android:fromYDelta="0%"
        android:toYDelta="-25%"
        android:startOffset="550"
        android:duration="300" />
    <translate
        android:fromYDelta="0%"
        android:toYDelta="25%"
        android:startOffset="850"
        android:duration="200" />
    <translate
        android:fromYDelta="0%"
        android:toYDelta="-8%"
        android:startOffset="1050"
        android:duration="200" />
    <translate
        android:fromYDelta="0%"
        android:toYDelta="8%"
        android:startOffset="1250"
        android:duration="150" />
</set>

其中,fromYDelta默认为零,可以省去。运行一下,效果完美。

Interpolator被覆盖的问题

需求中的另一个要求,就是这个弹跳动画要用贝塞尔曲线进行插值,

因为上弹和下落的贝塞尔曲线插值器的参数不同,不能在set中统一设置,需要单独设置:

mAnimSet = AnimationUtils.loadAnimation(getContext(), R.anim.textview_jump);
if (mAnimSet instanceof AnimationSet) {
    Interpolator upInterpolator = PathInterpolatorCompat.create(xx, xx, xx, xx);
    Interpolator downInterpolator = PathInterpolatorCompat.create(xx, xx, xx, xx);
    boolean even = true;
    for (Animation anim : ((AnimationSet) mAnimSet).getAnimations()) {
        anim.setInterpolator(even ? upInterpolator : downInterpolator);
        even = !even;
    }
}

如上,我在代码里对这个AnimationSet的每一个子Animation设置插值器后,发现并没有任何变化,感觉仍然是默认的加速减速插值器,效果很差。

研究了好一会,打了断点、跟踪了一下这个AnimationSet的执行过程,才发现问题的所在:

AnimationSet是Animation的子类,意味着AnimationSet本身就是一个Animation,也有自己的Interpolator插值器。而同时AnimationSet有一个名为PROPERTY_SHARE_INTERPOLATOR_MASK的flag,即是否分享(覆盖)自己的插值器到所有子Animation中。

默认情况下这个值为true,即AnimationSet中所有子Animation的Interpolator是默认被覆盖了的,在xml文件和Java代码中对子动画的插值器进行设置,都不会起任何作用!

要分别设置插值器的话,其实只需要在xml文件的set中加入一句:「android:shareInterpolator=”false”」即可,这一点确实有点坑啊……