Android 属性动画的原理及应用

3,980 阅读7分钟

动画是交互的关键元素,好的动画效果可以吸引更多的用户,因此掌握动画是一种很重要也很基础的技能。在 Android 3.0 之后,官方主推的就是属性动画,因此本文着重对属性动画进行说明,主要包括基本用法、原理以及应用。

属性动画的原理

原理

属性动画是对属性进行动画的,那么什么是属性呢?可以理解成 Java Bean 的属性,一个有 getXxx/setXxx 方法的字段,因为内部原理是通过反射去改变这些属性的值的,从而达到动画的效果。 如下图所示:

属性动画的原理

属性动画有两个要素:

  1. 时间:时间确定了动画时长

  2. 属性:动画改变的参数,需要设置起始状态和终止状态

已知动画时长以及起始和终止状态,对于中间怎么变换,属性动画引入了 TimeInterceptor 的概念。

举个例子,假设总时长为 M,每一帧刷新时间间隔为 m,那么一共有 M / m 帧,那么每一帧的时刻是固定的,分别是 i * m(i 表示第几帧),TimeInterceptor 的工作就是将时间比例转换为属性比例。 Android 官方提供了不少 Interceptor,具体可以参考 inloop.github.io/interpolato…,里面可以看到时间比例 —> 属性比例 的转换。

当知道了每一时刻 i * m 的属性后,通过反射去改变属性值,从而达到了动画的目的。

那么问题来了,每一时刻已知的数据有属性比例、起始属性和结束属性,如何计算每一时刻的属性呢?属性动画引入了 TypeEvaluator,该类型根据已知数据计算得到该中间时刻的属性值。因为属性动画是支持对属性的动画,因此肯定会有很多自定义的属性, Android 巧妙地设计了 TypeEvaluator,将属性的具体计算交给用户自己。官方提供了一些基本类型的 Evaluator,比如 IntEvalutar、FloatEvaluator 等。

基本用法

属性动画的 API 主要包括:

创建一个属性动画,核心是时长、起始和结束参数。为了能够计算属性以及不同的变化,需要提供 TypeEvaluator 和 TimeInterceptor。如果需要监听动画过程,可以设置监听器。属性动画的使用无外乎这些东西。

下面以一个例子进行介绍。

定义了一个 Point 和 PointView 类,其中 PointView 有一个属性 Point,根据 Point 的位置去绘制一个红点。因为 Point 是一个自定义属性,因此需要提供一个 TypeEvaluator 进行中间帧属性的计算。另外还提供了一个类似弹簧效果的 Interceptor。定义如下:

data class Point(var x: Float, var y: Float)

class PointView
@JvmOverloads
constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0, defaultResId: Int = 0)
    : View(context, attrs, defStyleAttr, defaultResId) {
    var point: Point = Point(40f, 40f)
        set(value) {
            field = value
            invalidate()
        }

    val paint: Paint = Paint().apply {
        isAntiAlias = true
        isDither = true
        color = Color.RED
    }
    
    override fun onDraw(canvas: Canvas?) {
        super.onDraw(canvas)

        canvas?.drawCircle(point.x, point.y, 30f, paint)
    }
}

class PointEvaluator : TypeEvaluator<Point> {
    override fun evaluate(fraction: Float, startValue: Point, endValue: Point): Point {
        val x = startValue.x + (endValue.x - startValue.x) * fraction
        val y = startValue.y + (endValue.y - startValue.y) * fraction
        return Point(x, y)
    }
}

class SpringInterceptor(private val factor: Float) : TimeInterpolator {
    override fun getInterpolation(input: Float): Float {
        return (Math.pow(2.0, -10.0 * input) * Math.sin((input - factor / 4) * (2 * Math.PI) / factor) + 1).toFloat()
    }
}

准备工作做完后,再来创建动画,如下:

ObjectAnimator.ofObject(pointView, "point", PointEvaluator(), Point(500f, 600f)).apply {
        duration = 2000
        interpolator = SpringInterceptor(0.6f)
        start()
}

动画效果如下图所示:

Demo效果

关于属性动画更详细的介绍,可以参考我写的其他几篇文章:

介绍完了属性动画的原理和基本使用方法后,下面将介绍几个 Android 官方中属性动画的应用。

属性动画的应用

所谓官方应用,指的是 Android 对属性动画封装的一些 API。这边主要介绍两种:StateListAnimator 和模拟物理世界的 FlingAnimation 和 SpringAnimaion。

StateListAnimator

StateListAnimator 和 Selector 类似,设置 View 每种状态对应的动画,比如 View 被按下后是一种动画,手指松开后又是一种状态。

这边再以一个例子介绍,仿抖音按住拍的效果,可以先看下效果:

仿抖音按住拍效果

可以看到,当按下去后,有个呼吸的效果;手指释放后,恢复到初始状态。这种效果可以通过 StateListAnimator 实现。

由于呼吸效果有个环,因此增加了一个 innerRaduisFactor 的参数,初始情况下为 0,按下的时候为 0.75-0.9。

这里看下 StateListAnimator 的定义,具体代码可以参考 GitHub:wangli135/ClimbDemo/jetpackdemo

val breathAnimator: StateListAnimator by lazy {
        //按下状态的外环动画,外环整体尺寸从1x变成1.5x
        val pressedOuterAnim = AnimatorSet().apply {
            play(ObjectAnimator.ofFloat(this@BreathView, SCALE_X, 1.0f, 1.5f))
                    .with(ObjectAnimator.ofFloat(this@BreathView, SCALE_Y, 1.0f, 1.5f))
        }
        //按下状态的内环动画,内环参数由0.75变成0.9,并且是个往返循环的
        val pressedInnerAnim = ObjectAnimator.ofFloat(this, "innerRaduisFactor", MAX_INNER_RADIUS_FACTOR, MIN_INNER_RADIUS_FACTOR).apply {
            repeatMode = ValueAnimator.REVERSE
            repeatCount = ValueAnimator.INFINITE
            duration = 1000
        }
        //按下状态的动画,两个组合起来的
        val pressedAnim = AnimatorSet().apply {
            play(pressedOuterAnim).before(pressedInnerAnim)
        }
        //释放状态的外环动画,恢复到1x尺寸
        val normalOuterAnim = AnimatorSet().apply {
            play(ObjectAnimator.ofFloat(this@BreathView, SCALE_X, 1.0f))
                    .with(ObjectAnimator.ofFloat(this@BreathView, SCALE_Y, 1.0f))
        }
        //释放状态的内环动画,内环参数恢复到0
        val normalInnerAnim = ObjectAnimator.ofFloat(this, "innerRaduisFactor", 0f)
        //释放状态的动画,两个组合起来的
        val normalAnim = AnimatorSet().apply {
            play(normalOuterAnim).before(normalInnerAnim)
        }
        //设置按下状态、释放状态的动画
        StateListAnimator().apply {
            addState(intArrayOf(android.R.attr.state_pressed), pressedAnim)
            addState(intArrayOf(-android.R.attr.state_pressed), normalAnim)
        }
    }

创建好 StateListAnimator 对象后,通过 addState() 方法添加每种状态下的动画。对于相对的状态,用 - 号表示,比如上面的 -android.R.attr.state_pressed

可以看到 StateListAnimator 和 Selector Drawable 是十分类似的,只不过一个是根据状态显示动画,另一个是根据状态显示图片。

关于更详细的介绍,可以参考我写的其他两篇文章:

模拟物理世界的动画 — DynamicAnimation

属性动画设置了时长、起始和结束的属性值,一旦在中间状态终止动画,那么属性动画的结束将会显得很急促,有种戛然而止的感觉。如果使用 DynamicAnimation ,则不会有这种感觉。

DynamicAnimation 的使用需要引入 support-dynamic-animation 库,主要包括两种动画:

  • FlingAnimation:基于摩擦力的动画,给定初始速度以及摩擦力,那么根据物理学,可以运动多久、每一时刻的速度都是可以根据牛顿力学定律计算得到的

  • SpringAnimation:基于弹力的动画,给定初始速度以及阻尼,可以弹性定律,也是可以计算出来后面每一时刻的状态的

SpringAnimation 有一种 Chained Spring 效果,一种联动的效果,下面看个 demo,效果图如下:

Chained Spring效果

通过上面动图可以看到,每一个 button 都是弹性运动的。同时,发布文字按钮跟随发布视频运动运动,发布视频按钮跟随发布图片按钮运动。上面的效果是 SpringAnimation+UpdateListener 实现的,具体代码如下:

val springForce = SpringForce(-600f).apply {
            dampingRatio = SpringForce.DAMPING_RATIO_HIGH_BOUNCY
            stiffness = SpringForce.STIFFNESS_LOW
        }
        //发布文字按钮的动画
        val tvTextSpringAnimation = SpringAnimation(tvPublishText,
                DynamicAnimation.TRANSLATION_Y)
        //发布视频按钮的动画
        val tvVideoSpringAnimation = SpringAnimation(tvPublishVideo,
                DynamicAnimation.TRANSLATION_Y).apply {
            addUpdateListener { animation, value, velocity ->
                //发布文字按钮移动到某一位置
                tvTextSpringAnimation.animateToFinalPosition(value)
            }
        }
        //发布图片按钮的动画
        val tvPicSpringAnimation = SpringAnimation(tvPublishPic,
                DynamicAnimation.TRANSLATION_Y).apply {
            spring = springForce
            addUpdateListener { dynamicAnimation, value, velocity ->
                //发布视频按钮移动到某一位置
                tvVideoSpringAnimation.animateToFinalPosition(value)
            }
        }
        fab.setOnClickListener {
            tvPicSpringAnimation.start()
        }

关于 FlingAnimation 和 SpringAnimation 更详细的使用,可以参考我写的其他两篇文章:

总结

属性动画的核心是总时长+起始和结束状态,每一帧时间间隔是相同的,为了有不同的效果,有了 TimeInterceptor,根据时间比例得到状态比例(即状态应该变化多少);而为了应对不同属性的变化,引入了 TypeEvaluator,可以自定义属性的计算,比如上文 demo 中的 PointEvaluator。这样整个属性动画就运转起来了。

官方对属性动画进行了一些封装,比如 StateListAnimator, 它是一种类似 SelectorDrawable 的动画,可以设置 View 不同状态下的动画,可以实现比如抖音按住拍的呼吸效果;官方在 support-dynamic-animation 库中又提出了两种模拟物理事件的动画,模拟摩擦力的 FlingAnimation 和模拟弹力的 SpringAnimation,和属性动画的区别是不需要设置时间和结束状态了,因为可以通过物理规律计算得到终态以及中间每一时刻的状态。


作者介绍

  • 王立,51信用卡客户端基础组 Android 开发工程师,目前主要负责客户端跨平台组件开发维护工作。