自定义动画(仿Win10加载动画)

一、源代码

源代码及demo

二、背景

先看看Win10的加载动画(找了很久才找到):
这里写图片描述

每次打开电脑都会有这个加载动画,看上挺cool的,就想着自己能否实现它。

要实现这个动画?

首先想能否通过自定义SurfaceView控件(界面刷新是通过子线程来完成)来实现。这需要知道某一刻时间,那些小圆点在什么位置。小圆点都在做圆周运动,可以看出除了左上角,可以通过势能和动能的相互转化来计算速度。但速度是变化的,如何计算某一个时刻的位置?网上一查,晕,都是微积分,算了吧。

后来,还是使用动画吧,最后的效果:
这里写图片描述


动起来:
这里写图片描述

要玩动画,自然就得理解动画的核心部分。如果理解了,就坐电梯直达“动画分析”

三、动画的两个核心

一般复杂动画需要自定义TimeInterpolator和TypeEvaluator,前者控制运动的速度,后者控制运动的轨迹。

TypeEvaluator不仅控制运动轨迹,只要有权限且能体现效果的setter属性,都可以控制,如旋转、缩放、颜色等;TimeInterpolator能干的事情,它也能干,只是为了方便,把它抽出来了。

这是他俩的关系(详见参考5):
这里写图片描述

3.1 TimeInterpolator

TimeInterpolator,名为时间插值器(或时间校正器),用于校正动画播放的时间。默认是加速减速插值器AccelerateDecelerateInterpolator。

正常情况下,动画在执行时间duration内,从起点到终点,中间是匀速运动,每一时刻都对应着固定的位置。为了统一,把时间[0, duration]转换成时间百分比[0, 1],如duration=100ms时,在50ms,应该对应着时间比0.5,且位置在正中间。如下图红色线:
这里写图片描述

说明:

横轴是实际运行的时间百分比轴(X轴),纵轴是校正后的时间百分比轴(Y轴); 图中紫色线是经过校正后的时间百分比,在x=0.5时,正常情况是y=0.35,但校正后y不到0.3,也就是说这个时刻所在的位置还不到总路线的1/3。说白了,导数就是速度,紫色线的导数在逐渐变大,表示速度也在逐渐变快,这就是一个加速过程。 X轴是实际运行的时间轴,与Y轴,只有一对一、或一对多的关系,不能出现多对一的情况。一对多的关系就是来回运动的具体体现。

再看TimeInterpolator,是个接口,只有一个方法getInterpolation(float input),方法中的参数对应着X轴,返回值对应着Y轴。

1
2
3
<code>public interface TimeInterpolator {
    float getInterpolation(float input);
}</code>

3.2 TypeEvaluator

TypeEvaluator就是一个估值器。拿代码说来,明白一点。

1
2
3
<code>public interface TypeEvaluator<t> {
    public T evaluate(float fraction, T startValue, T endValue);
}</t></code>

也是一个接口,有一个方法evaluate(float fraction, T startValue, T endValue),参数说明:

fraction:时间插值器的校正值 startValue:开始值 endValue:结束值 T:可以是float类的单值,也可以是坐标、颜色等

一般与AnimatorUpdateListener的onAnimationUpdate()方法结合。

3.3 贝塞尔曲线

以下用到了很多二阶贝塞尔曲线,具体计算公式如下(详见参考2):
这里写图片描述
B(t)=(1?t)2P0+2t(1?t)P1+t2P2,t∈[0,1]

原理:由 P0 至 P1 的连续点 Q0,描述一条线段。
由 P1 至 P2 的连续点 Q1,描述一条线段。
由 Q0 至 Q1 的连续点 B(t),描述一条二次贝塞尔曲线。

经验:P1-P0为曲线在P0处的切线

另外加两条经验:

为了更自然,P1-P2一般情况下也是曲线在P2处的切线,这样就能算出P1的具体位置 曲线上每个点的坐标x和y,x和y分别套用此公式

3.4 效果比较

无图无真相。。。图来了
这里写图片描述
在起点、终点、运行时间都一样的情况下,三种效果比较:

普通动画——默认插值器是加速减速插值器AccelerateDecelerateInterpolator 自定义插值器动画——速度效果是先匀速,再做贝塞尔曲线运动(先反向减速后,再正向加速)。如下图红线(其他画图软件都没不好办,这时还是PS的钢笔工具好用)
这里写图片描述 自定义估值器动画——插值器是默认的(水平方向与普通动画是一致的),做正弦曲线运动

三种动画的代码:

1
2
3
4
5
6
7
8
9
10
11
<code><code>private static final long ANIM_DURATION = 5000;
/**
 * 普通动画
 * 差值器默认为AccelerateDecelerateInterpolator
 */
private void normalAnim() {
    ObjectAnimator oa = ObjectAnimator.ofFloat(v_normal, "translationX", 0, 300);
    oa.setDuration(ANIM_DURATION);
    oa.setRepeatCount(ValueAnimator.INFINITE);
    oa.start();
}</code></code>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
<code><code>/**
 * 自定义插值器的动画
 */
private void interpolatorAnim() {
ObjectAnimator oa = ObjectAnimator.ofFloat(v_interpolator, "translationX", 0, 300);
oa.setInterpolator(new TimeInterpolator() {
    /**
     * 获取插值器的值
     * @param input 原生时间比值[0, 1]
     * @return 校正后的值
     */
    @Override
    public float getInterpolation(float input) {
        // 前半段时间为直线(匀速运动),后半段贝塞尔曲线(先反向)
        if (input < 0.5) {
            return input;
        }
        // 把贝塞尔曲线范围[0.5, 1]转换成[0, 1]范围
        input = (input - 0.5f) * (1 - 0) / (1 - 0.5f);
        float tmp = 1 - input;
        return tmp * tmp * 0.5f + 2 * input * tmp * 0 + input * input * 1;
    }
});
oa.setDuration(ANIM_DURATION);
oa.setRepeatCount(ValueAnimator.INFINITE);
oa.start();
}</code></code>
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
<code><code>/**
 * 自定义估值器的动画
 */
private void evaluatorAnim() {
    ValueAnimator va = ValueAnimator.ofObject(
            new TypeEvaluator<pointf>() {
                /**
                 * 估算结果
                 *
                 * @param fraction 由插值器提供的值,∈[0, 1]
                 * @param startValue 开始值
                 * @param endValue 结束值
                 * @return
                 */
                @Override
                public PointF evaluate(float fraction, PointF startValue, PointF endValue) {
                    PointF p = new PointF();
                    float distance = endValue.x - startValue.x;
                    p.x = fraction * distance;
                    float halfDistance = distance / 2;
                    float sinX = (float) Math.sin(fraction * Math.PI / 0.5);
                    p.y = -halfDistance * sinX;
                    return p;
                }
            },
            new PointF(0, 0),
            new PointF(300, 0)
    );
    va.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
        @Override
        public void onAnimationUpdate(ValueAnimator animation) {
            PointF pointF = (PointF) animation.getAnimatedValue();
            v_evaluator.setTranslationX(pointF.x);
            v_evaluator.setTranslationY(pointF.y);
        }
    });
    va.setDuration(ANIM_DURATION);
    va.setRepeatCount(ValueAnimator.INFINITE);
    va.start();
}
</pointf></code></code>
四、动画分析

再看看原生动画:
这里写图片描述

4.1 组成与始末

毫无疑问,是由5个圆点组成。
从哪开始的呢?一般都是从无到有,即从最底部一个一个弹出开始的。经过查看Win10的动画,每次显示时确实从此处开始的。
结束,从开始的前一刻,也就是其它点都消失了,最后一个点到达底部的时刻。

4.2 动画剖析

从上面的动画核心部分可知,要分析动画,就要分析其速度变化与运行时间。

先从第一个圆点开始分析。底部起始点为0°,顺时针为正,分析每个区间段的速度与时间:

0° ~ 160°:速度变慢,时间0.5s 160° ~ 180°:匀速,时间2s 180° ~ 360°:速度变快,时间1s 360° ~ 520°:速度变慢,时间0.5s 520° ~ 540°:匀速,时间2s 540° ~ 720°:速度变快,时间1s

其中:步骤4-6重复1-3。

第二个以后的圆点,一开始以为在使用延时执行就可以,但发现有问题:动画需无限次重复执行,延时只在第一次执行时延时。

所以,用时间来模拟延时执行的效果:首先隐藏在起始点,速度为0,然后延时时间(偏移时间offsetMs)到达后就显示,开始运动了。
最后第一个圆点等待最后一个圆点到达底部的功能也是一样:到达后隐藏在终点,速度不变且为0,一直等待最后一个圆点到达终点。

这样,才算一个完整的运动轨迹(这里范围为实际运行时间):

步骤时间段时间差运动范围速度变化备注
1 0 ~ offsetMs offsetMs 0° ~ 0° 0 隐藏
2 offsetMs ~ offsetMs + 0.5 0.5 0° ~ 160° 减速 显示
3 offsetMs + 0.5 ~ offsetMs + 2.5 2 160° ~ 180° 匀速  
4 offsetMs + 2.5 ~ offsetMs + 3.5 1 180° ~ 360° 加速  
5 offsetMs + 3.5 ~ offsetMs + 4.0 0.5 360° ~ 520° 减速  
6 offsetMs + 4.0 ~ offsetMs + 6.0 2 520° ~ 540° 匀速  
7 offsetMs + 6.0 ~ offsetMs + 7.0 1 540° ~ 720° 加速  
8 offsetMs + 7.0 ~ 8.0 1-offsetMs 720° ~ 720° 0 隐藏

其中,步骤5-7运动与步骤2-3重复,也就是实际看到运动效果的几个步骤。

之后又经过了如下优化:

经过多次卡表发现,一次运行的总时间为7s,但比例还是按上面的来 测试中发现会出现挤压重叠现象,仔细查看原生动画,发现在匀速开始和结束的位置,并不都是160°和180°,五个圆点到达匀速的角度在逐渐变小,离开的角度也在逐渐变小。因此需要给每个圆点一个偏移角度 为了进一步模仿动画在左上角慢慢靠近的追逐效果,上面所述的到达偏移角度要比离开的偏移角度大

这样,就有了下面轨迹参数的确定(这些参数是我测试出来比较理想的,可以自己去设置更精确的参数):

/**
 * 创建动画
 * 
 * @param view 需执行的控件
 * @param index 该控件执行的顺序
 * @return 该控件的动画
 */
private Animator createViewAnim(final View view, final int index) {
    long duration = 7000; // 一个周期(2圈)一共运行7000ms,固定值
    int comeStepAngle = 22; // 到达的间隔角度
    int goStepAngle = 16; // 离开的间隔角度

    // 最小执行单位时间
    final float minRunUnit = duration / 16;
    // 最小执行单位时间所占总时间的比例
    double minRunPer = minRunUnit / duration;
    // 在差值器中实际值(Y坐标值),共8组
    final double[] trueRunInOne = new double[]{
            0,
            0,
            160 / 720d - index * comeStepAngle / 720d,
            180 / 720d - index * goStepAngle / 720d,
            360 / 720d,
            520 / 720d - index * comeStepAngle / 720d,
            540 / 720d - index * goStepAngle / 720d,
            1
    };
    // 动画开始的时间比偏移量。剩下的时间均摊到每个圆点上(本应该是length-1,但length效果更好)
    final float offset = (float) (index * (16 - 14) * minRunPer / mDotViews.length);
    // 在差值器中理论值(X坐标值),与realRunInOne对应
    final double[] rawRunInOne = new double[]{
            0,
            offset + 0,
            offset + 1 * minRunPer,
            offset + 5 * minRunPer,
            offset + 7 * minRunPer,
            offset + 8 * minRunPer,
            offset + 12 * minRunPer,
            offset + 14 * minRunPer
    };
}

整个动画放在了一个自定义RelativeLayout里,圆点是通过代码动态添加的,圆点背景使用的是shape:

// 2、 添加新控件
for (int i = 0; i < mDotViews.length; i++) {
    mDotViews[i] = new View(getContext());

    View view = mDotViews[i];
    LayoutParams lp = new LayoutParams(dotD, dotD);
    // 添加规则:底部 + 水平居中
    lp.addRule(ALIGN_PARENT_BOTTOM);
    lp.addRule(CENTER_HORIZONTAL);
    // 调整位置
    if (mHeight > mWidth) {
        lp.bottomMargin = (mHeight - mWidth)/2;
    }
    // 设置旋转中心点
    view.setPivotX(dotR);
    view.setPivotY(-(halfSize - dotD));
    // 背景
    view.setBackgroundResource(R.drawable.shape_dot);
    // 修改点的背景颜色
    GradientDrawable gradientDrawable = (GradientDrawable) view.getBackground();
    gradientDrawable.setColor(dotColor);
    view.setVisibility(INVISIBLE);
    addView(view, lp);
}

五个圆点的时间百分比轨迹图如下(这时PS画图也不好使了,还是要Android自己来,demo里有源代码):
这里写图片描述

五、核心代码

private Animator createViewAnim(final View view, final int index) {
   ...

    // 各贝塞尔曲线控制点的Y坐标
    final float p1_2 = calculateLineY(rawRunInOne[2], trueRunInOne[2], rawRunInOne[3], trueRunInOne[3], rawRunInOne[1]);
    final float p1_4 = calculateLineY(rawRunInOne[2], trueRunInOne[2], rawRunInOne[3], trueRunInOne[3], rawRunInOne[4]);
    final float p1_5 = calculateLineY(rawRunInOne[5], trueRunInOne[5], rawRunInOne[6], trueRunInOne[6], rawRunInOne[4]);
    final float p1_7 = calculateLineY(rawRunInOne[5], trueRunInOne[5], rawRunInOne[6], trueRunInOne[6], rawRunInOne[7]);

    // A 创建属性动画:绕着中心点旋转2圈
    ObjectAnimator objAnim = ObjectAnimator.ofFloat(view, "rotation", 0, 720);
    // B 设置一个周期执行的时间
    objAnim.setDuration(duration);
    // C 设置重复执行的次数:无限次重复执行下去
    objAnim.setRepeatCount(ValueAnimator.INFINITE);
    // D 设置差值器
    objAnim.setInterpolator(new TimeInterpolator() {
        @Override
        public float getInterpolation(float input) {
            if (input < rawRunInOne[1]) {
                // 1 等待开始
                return 0;
            } else if (input < rawRunInOne[2]) {
                if (view.getVisibility() != VISIBLE) {
                    view.setVisibility(VISIBLE);
                }
                // 2 底部 → 左上角:贝赛尔曲线1
                // 先转换成[0, 1]范围
                input = calculateNewPercent(rawRunInOne[1], rawRunInOne[2], 0, 1, input);
                return calculateBezierQuadratic(trueRunInOne[1], p1_2, trueRunInOne[2], input);

            } else if (input < rawRunInOne[3]) {
                // 3 左上角 → 顶部:直线
                return calculateLineY(rawRunInOne[2], trueRunInOne[2], rawRunInOne[3], trueRunInOne[3], input);

            } else if (input < rawRunInOne[4]) {
                // 4 顶部 → 底部:贝赛尔曲线2
                input = calculateNewPercent(rawRunInOne[3], rawRunInOne[4], 0, 1, input);
                return calculateBezierQuadratic(trueRunInOne[3], p1_4, trueRunInOne[4], input);

            } else if (input < rawRunInOne[5]) {
                // 5 底部 → 左上角:贝赛尔曲线3
                input = calculateNewPercent(rawRunInOne[4], rawRunInOne[5], 0, 1, input);
                return calculateBezierQuadratic(trueRunInOne[4], p1_5, trueRunInOne[5], input);

            } else if (input < rawRunInOne[6]) {
                // 6 左上角 → 顶部:直线
                return calculateLineY(rawRunInOne[5], trueRunInOne[5], rawRunInOne[6], trueRunInOne[6], input);

            } else if (input < rawRunInOne[7]) {
                // 7 顶部 → 底部:贝赛尔曲线4
                input = calculateNewPercent(rawRunInOne[6], rawRunInOne[7], 0, 1, input);
                return calculateBezierQuadratic(trueRunInOne[6], p1_7, trueRunInOne[7], input);

            } else {
                // 8 消失
                if (view.getVisibility() != INVISIBLE) {
                    view.setVisibility(INVISIBLE);
                }
                return 1;
            }

        }
    });
    return objAnim;
}

/**
 * 根据旧范围,给定旧值,计算在新范围中的值
 *
 * @param oldStart 旧范围的开始值
 * @param oldEnd   旧范围的结束值
 * @param newStart 新范围的开始值
 * @param newEnd   新范围的结束之
 * @param value    给定旧值
 * @return 新范围的值
 */
private float calculateNewPercent(double oldStart, double oldEnd, double newStart, double newEnd, double value) {
    if ((value < oldStart && value < oldEnd) || (value > oldStart && value > oldEnd)) {
        throw new IllegalArgumentException(String.format("参数输入错误,value必须在[%f, %f]范围中", oldStart, oldEnd));
    }
    return (float) ((value - oldStart) * (newEnd - newStart) / (oldEnd - oldStart));
}

/**
 * 根据两点坐标形成的直线,计算给定X坐标在直线上对应的Y坐标值
 *
 * @param x1 起点X坐标
 * @param y1 起点Y坐标
 * @param x2 终点X坐标
 * @param y2 终点Y坐标
 * @param x  给定的X坐标
 * @return 给定X坐标对应的Y坐标
 */
private float calculateLineY(double x1, double y1, double x2, double y2, double x) {
    if (x1 == x2) {
        return (float) y1;
    }
    return (float) ((x - x1) * (y2 - y1) / (x2 - x1) + y1);
}

/**
 * 计算贝塞尔二阶曲线的X(或Y)坐标值
 * 给定起点、控制点、终点的X(或Y)坐标值,和给定时间t(∈[0, 1]),算出此时贝塞尔曲线的X(或Y)坐标值
 *
 * @param p0 起点值
 * @param p1 控制点值
 * @param p2 终点值
 * @param t  给定的时间
 * @return 曲线的位置值
 */
private float calculateBezierQuadratic(double p0, double p1, double p2, @FloatRange(from = 0, to = 1) double t) {
    double tmp = 1 - t;
    return (float) (tmp * tmp * p0 + 2 * tmp * t * p1 + t * t * p2);
}

public synchronized void setDotColor(int dotColor) {
    this.dotColor = dotColor;
    for (View view : mDotViews) {
        GradientDrawable gradientDrawable = (GradientDrawable) view.getBackground();
        gradientDrawable.setColor(dotColor);
    }
}


http://www.2cto.com/kf/201610/553402_2.html
原文地址:https://www.cnblogs.com/lizhigang/p/6126369.html