记一次自定义Loading的心里路程

事情是这样的0-0,这个是我的一个好基友Anonymous___童鞋发给我的一个效果图,主要是练手一下最近研究的自定义view的各种技能,于是就搞了一下,最终实现其实很简单,但是中间也绕了几次弯,先上图,这是原图效果:

preloader.gif

下面是代码实现效果:
loading.gif

可能在细节方面有些差异,但是重点是实现方式,是吧- -。
首先分析一下动画的变化过程,姑且把它看成是一段圆弧的变化吧,一共可以分为一下几点:

  1. 颜色变化,颜色会从青色变为蓝色;
  2. 圆弧线条粗细的变化,刚开始是整个填充的,然后旋转一小节之后,线条开始变细;
  3. 圆弧旋转一周之后,长度开始缩小,并在最后延长一部分

好了,动画分析的差不多了,那么就开始实现吧。

第一步,画圆弧

图中的圆弧大概长这个样子吧

圆弧.png

这个简单,直接使用一个Path类的addArc(),然后用Canvas画出来就可以了,可是怎么画出圆弧的轮廓呢,这个当然你可以画两个圆弧加两个半圆去实现,但是太麻烦了,其实在Paint中有一个方法getFillPath (Path src, Path dst)专门可以获取到Canvas画出的path的实际的path,但是使用这个方法,记得关闭硬件加速!!!,关闭硬件加速请参考这里Android如何关闭硬件加速,关于Paint的Api详解可以到扔物线大大的HenCoder中详细了解,我就不做过多的赘述了。效果大概就是下面这个样子,第一个图是原Path画出的圆弧,第二个是获取到的轮廓:

arc_src.png

arc_dst.png

第二步,执行动画

这里我大概想到了两种方式:

第一种方式

直接使用属性动画(ObjectAnimator),去动态的改变自定义View的属性值,比如我们可以在自定义View中定义一个变量progessColor来记录颜色,并给这个变量定义setter方法,在setter方法中调用invalidate方法,这样就可以通过ObjectAnimator来动态的改变progessColor的值,来一直刷新绘制方法onDraw,从而让view“动”起来;同样的方式可以改变绘制圆弧的起始角度startAngle和绘制View的线条粗细progessWidth,然后同时执行这三个动画,就可以让圆弧转起来了,不过这只是前半段动画,还有后半段动画,让圆弧的长度缩小并在最后突出一小截,这个问题可以给ObjectAnimator设置监听,在动画结束后,通过一个handler通知下一个动画执行,缩小圆弧长度可以控制绘制圆弧角度的sweepAngle属性,让其不断的减小即可,对属性动画不熟悉的同学,同样可以去看抛物线大大的HenCoder,大致代码及效果如下:

 public void setProgessColor(int progessColor) {
        this.progessColor = progessColor;
        invalidate();
}
... 省略部分代码 ...
ObjectAnimator objectAnimator = ObjectAnimator.ofInt(this, "progessColor", startColor, endColor);
objectAnimator.setEvaluator(new ArgbEvaluator());          // 使用ARGB求值器来改变颜色
objectAnimator.setDuration(2000);

ObjectAnimator objectAnimator1 = ObjectAnimator.ofFloat(this, "progessWidth", startWidth, endWidth);
objectAnimator1.setDuration(2000);

ObjectAnimator objectAnimator2 = ObjectAnimator.ofFloat(this, "startAngle", startAngle, startAngle + 360);
objectAnimator2.setDuration(2000);

objectAnimator3 = objectAnimator.ofFloat(this, "sweepAngle", sweepAngle - 2, 10, 0, -10); // 需要在最后突出一截
objectAnimator3.setDuration(3000);

AnimatorSet animatorSet = new AnimatorSet();
animatorSet.playTogether(objectAnimator, objectAnimator1, objectAnimator2);      // 使用AnimatorSet同时执行动画
animatorSet.start();

loading1.gif

这种方式大家应该也看出来了,有几个缺陷,第一,原动画的弧线是运动一段时间,线的宽度才开始减小的,而这个动画直接就开始减小;第二,中间动画衔接的时候会有一小点停顿的状态;第三,这个动画得倒回去啊。当然使用setRepeatMode(ValueAnimator.REVERSE) 和 setRepeatCount(ValueAnimator.INFINITE)Api可以让动画倒回去,但是还需要控制动画执行顺序,太麻烦,遂弃之。

第二种方式

既然使用ObjectAnimator不好使,有限制,那么我们就换一个他的爹地,更加灵活的ValueAnimator,它的玩法非常简单,只需要调用ofFloat(),然后再添加addUpdateListener()监听,在update的过程中,不断的根据自己设计的算法改变对应的值就ok了,当然关于颜色的变化还是需要使用ObjectAnimator的方法,因为颜色的变化需要ArgbEvaluator求值器来计算,具体的算法在下面的代码里做解释:

ValueAnimator valueAnimator = ValueAnimator.ofFloat(0, 1);
// 设置update监听,并在属性更新时写自己的算法
valueAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { 
    @Override
    public void onAnimationUpdate(ValueAnimator animation) {
        // 获取到当前动画的完成度,取值为0~1
        float animatedFraction = animation.getAnimatedFraction();         
        // 用来计算线条宽度的变化,前动画的前0.2不改变初始线条宽度,从0.2~0.9,动态的将线条宽度变为最终的宽的endWidth
        // 0.9~1阶段,宽度始终为最终宽度endWidth
        progessWidth = startWidth - ((animatedFraction - 0.2f) / 0.7f) * startWidth;       
        // 前0.2的变化会进入这个判断
        if (progessWidth >= startWidth) {    
            progessWidth = startWidth;
        }
        // 0.9~1时会进这个判断
        if (progessWidth <= endWidth) {      
            progessWidth = endWidth;
        }
        // 用来计算弧线长度的变化,前0.7的变化为startAngle从50度逐渐变化为450,sweepAngle固定不变,即为固定的弧长旋转动画
        if (animatedFraction <= 0.7f) {
            startAngle = 50 + animatedFraction / 0.7f * 400f;
            sweepAngle = -100;
        }
        // 从0.7~1的动画为startAngle不变,sweepAngle逐渐减小再增大的过程(这个是绘制长度的减小,符号代表绘制圆弧的方向)
        // 即从-100变到20,从而达到长度开始缩小,并在最后延长一部分的动画
        if (animatedFraction > 0.7f) {
            startAngle = 450;
            sweepAngle = -100 + ((animatedFraction - 0.7f) / 0.25f) * 100;
        }
        invalidate();    // 别忘了invalidate触发重绘onDraw
    }
});
// 动态改变颜色
ObjectAnimator objectAnimator = ObjectAnimator.ofInt(this, "progessColor", startColor, endColor);
objectAnimator.setEvaluator(new ArgbEvaluator());
// 使用AnimatorSet同时执行两种动画
AnimatorSet animatorSet = new AnimatorSet();
animatorSet.playTogether(valueAnimator, objectAnimator);
animatorSet.start();

上面代码中,0.7f了、0.2f了这些值都是可以自己改的,不同的值效果也会不一样,大家可以自己修改试一下看看效果。先看看这个实现的效果图:


loading2.gif

是不是觉得已经ok了呢,nonono,还有最后一步,这个loading动画是循环播放的呢,而且需要倒回去,所以:

第三步,REVERSE

这个就很简单了,给动画设置setRepeatModesetRepeatCount就行了。

valueAnimator.setRepeatCount(ValueAnimator.INFINITE);
valueAnimator.setRepeatMode(ValueAnimator.REVERSE);

objectAnimator.setRepeatMode(ValueAnimator.REVERSE);
objectAnimator.setRepeatCount(ValueAnimator.INFINITE);

效果就不贴了,就是本文开头的那个效果。代码虽然很简单,但是重要的还是思路,这东西就跟魔术一样,看到的时候感觉很神奇,但是知道了原理就发现原来就那么回事。

总结一下

这种动画主要是通过使用valueAnimatorAnimatorUpdateListener中动态的改变需要绘制的图形的属性值,并不断的通过invalidate触发onDraw,从而使得图形在感官上是“动”起来。这样你就可以根据你自己的算法做出各种动画了。我的好基友的系列文章也挺不错的,推荐大家一看。当然想要做出这些东西是需要一定的自定义View的基础的,首先你得知道各种Api的使用和细节吧,巧妇还难为无米之炊呢,下面我推荐几个网站:

  1. 我大Google的官方文档,Canvas,Path,Paint,PathMeasure,Camera等等一些类的Api总得玩儿转吧,什么?你说英语差?机翻会不。不过还是希望大家没事儿了多背背单词,不能一直靠机翻吧,多low(开玩笑~~~)
  2. 再次推荐抛物线大大的HenCoder教程,内容不多,但是是精品
  3. GcsSloop的魔法首页,写的非常细,配合HenCoder食用更加

就酱,代码很简单,贴下面咯,如果大家觉得ok,那么记得点赞哈,还有,强烈推荐看看我推荐的那几个网站噻。
使用下面的代码请记得关闭硬件加速!!!关闭硬件加速!!!关闭硬件加速!!!重要的事说三遍,在清单文件的application中加android:hardwareAccelerated="false"即可

package com.moonight.customview;

import android.animation.Animator;
import android.animation.AnimatorSet;
import android.animation.ArgbEvaluator;
import android.animation.ObjectAnimator;
import android.animation.ValueAnimator;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.Path;
import android.graphics.RectF;
import android.os.Handler;
import android.os.Message;
import android.support.annotation.Nullable;
import android.util.AttributeSet;
import android.view.View;
import android.view.animation.AccelerateDecelerateInterpolator;

/**
 * Created by MooNight on 2017/9/11.
 */

public class CustomProgressView extends View {

    private Paint mPaint;

    private float left = -100;
    private float top = -100;
    private float right = 100;
    private float bottom = 100;

    private float startAngle = 90;
    private float sweepAngle = -100;

    private int mViewWidthHalf, mViewHeightHalf;        // 获取view的宽高的一半

    private Path arcSrcPath;
    private Path arcDstPath;

    private int startColor = 0xFF10D2DE;
    private int endColor = 0xFF1039DD;
    private int progessColor = startColor;

    private float progessWidth;
    private float startWidth = 70;
    private float endWidth = 5;
    private RectF rectF;


    private Handler handler = new Handler() {
        @Override
        public void handleMessage(Message msg) {
            objectAnimator3.start();                //当第阶段动画执行完毕后,执行第二阶段动画
        }
    };
    private ObjectAnimator objectAnimator3;
    private Animator.AnimatorListener animatorListener;

    public CustomProgressView(Context context) {
        this(context, null);
    }

    public CustomProgressView(Context context, @Nullable AttributeSet attrs) {
        this(context, attrs, 0);
    }

    public CustomProgressView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        init();
    }


    // 提供setter方法,使用ObjectAnimator改变对应的属性值
    public void setProgessColor(int progessColor) {
        this.progessColor = progessColor;
        invalidate();
    }
    public void setProgessWidth(float progessWidth) {
        this.progessWidth = progessWidth;
    }

    public void setStartAngle(float startAngle) {
        this.startAngle = startAngle;
    }

    public void setSweepAngle(float sweepAngle) {
        this.sweepAngle = sweepAngle;
        invalidate();                       // 当属性值改变时,调用invalidate出发onDraw回调
    }

    @Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
        super.onSizeChanged(w, h, oldw, oldh);
        mViewWidthHalf = w / 2;             // 在onSizeChanged中获取view的宽高值
        mViewHeightHalf = h / 2;
    }

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        canvas.drawColor(Color.BLACK);              // 绘制背景
        canvas.save();
        canvas.translate(mViewWidthHalf, mViewHeightHalf);      // 将绘制中心坐标(0,0)移动到View中心

        arcSrcPath = new Path();                    // 每次都绘制新的path
        arcDstPath = new Path();

        arcSrcPath.addArc(rectF, startAngle, sweepAngle);
        mPaint.setStrokeWidth(startWidth);
        mPaint.getFillPath(arcSrcPath, arcDstPath);     // 获取实际弧线path的轮廓
        canvas.clipPath(arcDstPath);                    // 只显示轮廓部分,如果不这么做,会使得出事弧线过宽

        mPaint.setColor(progessColor);
        mPaint.setStrokeWidth(progessWidth);            // 动态改变paint的颜色跟线宽
        canvas.drawPath(arcDstPath, mPaint);

        canvas.restore();
    }

    private void init() {
        initPaint();
        initRect();
        initAnimator();
    }

    private void initAnimator() {
//        initAnimator1();

        initAnimator2();
    }

    /**
     * 第二种实现方式
     */
    private void initAnimator2() {
        ValueAnimator valueAnimator = ValueAnimator.ofFloat(0, 1);
        valueAnimator.setDuration(2200);
        valueAnimator.setRepeatCount(ValueAnimator.INFINITE);
        valueAnimator.setRepeatMode(ValueAnimator.REVERSE);
        valueAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
            @Override
            public void onAnimationUpdate(ValueAnimator animation) {
                // 获取到当前动画的完成度,取值为0~1
                float animatedFraction = animation.getAnimatedFraction();
                // 用来计算线条宽度的变化,前动画的前0.2不改变初始线条宽度,从0.2~0.9,动态的将线条宽度变为最终的宽的endWidth
                // 0.9~1阶段,宽度始终为最终宽度endWidth
                progessWidth = startWidth - ((animatedFraction - 0.2f) / 0.7f) * startWidth;
                // 前0.2的变化会进入这个判断
                if (progessWidth >= startWidth) {
                    progessWidth = startWidth;
                }
                // 0.9~1时会进这个判断
                if (progessWidth <= endWidth) {
                    progessWidth = endWidth;
                }
                // 用来计算弧线长度的变化,前0.7的变化为startAngle从50度逐渐变化为450,sweepAngle固定不变,即为固定的弧长旋转动画
                if (animatedFraction <= 0.7f) {
                    startAngle = 50 + animatedFraction / 0.7f * 400f;
                    sweepAngle = -100;
                }
                // 从0.7~1的动画为startAngle不变,sweepAngle逐渐减小再增大的过程(这个是绘制长度的减小,符号代表绘制圆弧的方向)
                // 即从-100变到20,从而达到长度开始缩小,并在最后延长一部分的动画
                if (animatedFraction >= 0.7f) {
                    startAngle = 450;
                    sweepAngle = -100 + ((animatedFraction - 0.7f) / 0.25f) * 100;
                }
                invalidate();     // 别忘了invalidate触发重绘onDraw
            }
        });

        // 动态改变颜色
        ObjectAnimator objectAnimator = ObjectAnimator.ofInt(this, "progessColor", startColor, endColor);
        objectAnimator.setRepeatMode(ValueAnimator.REVERSE);
        objectAnimator.setRepeatCount(ValueAnimator.INFINITE);
        objectAnimator.setEvaluator(new ArgbEvaluator());
        objectAnimator.setDuration(2200);

        AnimatorSet animatorSet = new AnimatorSet();
        animatorSet.playTogether(valueAnimator, objectAnimator);
        animatorSet.start();
    }

    /**
     * 第一种实现方式
     */
    private void initAnimator1() {
        initObjectAnimatorListener();
        initObjectAnimator();
    }

    private void initObjectAnimatorListener() {
        animatorListener = new Animator.AnimatorListener() {
            @Override
            public void onAnimationStart(Animator animation) {

            }

            @Override
            public void onAnimationEnd(Animator animation) {
                handler.sendEmptyMessage(0);
            }

            @Override
            public void onAnimationCancel(Animator animation) {

            }

            @Override
            public void onAnimationRepeat(Animator animation) {

            }
        };
    }

    private void initObjectAnimator() {
        ObjectAnimator objectAnimator = ObjectAnimator.ofInt(this, "progessColor", startColor, endColor);
//        objectAnimator.setRepeatMode(ValueAnimator.REVERSE);
//        objectAnimator.setRepeatCount(ValueAnimator.INFINITE);
        objectAnimator.setEvaluator(new ArgbEvaluator());
        objectAnimator.addListener(animatorListener);
        objectAnimator.setDuration(2000);

        ObjectAnimator objectAnimator1 = ObjectAnimator.ofFloat(this, "progessWidth", startWidth, endWidth);
//        objectAnimator1.setRepeatMode(ValueAnimator.REVERSE);
//        objectAnimator1.setRepeatCount(ValueAnimator.INFINITE);
        objectAnimator1.setDuration(2000);

        ObjectAnimator objectAnimator2 = ObjectAnimator.ofFloat(this, "startAngle", startAngle, startAngle + 360);
        objectAnimator2.setInterpolator(new AccelerateDecelerateInterpolator());
//        objectAnimator2.setRepeatMode(ValueAnimator.REVERSE);
//        objectAnimator2.setRepeatCount(ValueAnimator.INFINITE);
        objectAnimator2.setDuration(2000);

        objectAnimator3 = objectAnimator.ofFloat(this, "sweepAngle", sweepAngle - 2, 10, 0, -10);
        objectAnimator3.setDuration(3000);

        AnimatorSet animatorSet = new AnimatorSet();
        animatorSet.playTogether(objectAnimator, objectAnimator1, objectAnimator2);
        animatorSet.start();
    }

    private void initRect() {
        rectF = new RectF(left, top, right, bottom);
    }

    private void initPaint() {
        mPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
        mPaint.setStyle(Paint.Style.STROKE);
        mPaint.setStrokeCap(Paint.Cap.ROUND);      // 线端点处设置会圆头
        mPaint.setStrokeWidth(startWidth);
        mPaint.setColor(startColor);
    }
}

推荐阅读更多精彩内容