zoukankan      html  css  js  c++  java
  • 自定义动画(仿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
  • 相关阅读:
    python BUGGGGGGGGGG
    Golang channel底层原理及 select 和range 操作channel用法
    Go reflect包用法和理解
    Golang 之sync包应用
    Golang 之 sync.Pool揭秘
    深入理解字节码文件
    java中的回调,监听器,观察者
    范式
    BIO,NIO,AIO总结(二)
    anaconda命令行运行过程中出现的错误
  • 原文地址:https://www.cnblogs.com/lizhigang/p/6126369.html
Copyright © 2011-2022 走看看