圆形波浪进度条 - 自定义 View 的绘制

这几天写了在写一个记账软件,想要用一个圆形的进度条来展示收支百分比,特此记录。

效果类似于这样:

属性

确定需要的属性

  1. 圆形背景颜色(就是圆形中黄色的部分)
  2. 圆形半径
  3. 波浪颜色(蓝色和紫色部分,做成这样,动起来会更有波浪效果)
  4. 圆形边框颜色(圆形外围绿色部分)
  5. 文字颜色
  6. 波浪的速度
  7. 波浪的高度
属性名属性类型备注
circle_backgroundcolor圆形背景颜色
circle_radiusdimension圆形半径
dark_wave_colorcolor深色波浪颜色
light_wave_colorcolor浅色波浪颜色
circle_border_colorcolor圆形边框颜色
text_colorcolor文字颜色
wave_speedinteger波浪的速度
wave_heightdimension波浪的高度

定义属性

自定义 View 之 自定义属性 这篇文章写了如何自定属性。

在 valuesattrs.xml 中创建属性:

    <!-- 自定义圆形波浪进度条属性-->
    <declare-styleable name="CircleWaveProgress">
        <!--圆形背景颜色-->
        <attr name="circle_background" format="color" />
        <!--圆形半径-->
        <attr name="circle_radius" format="dimension" />
        <!--深色波浪颜色-->
        <attr name="dark_wave_color" format="color" />
        <!--浅色波浪颜色-->
        <attr name="light_wave_color" format="color" />
        <!--圆形边框颜色-->
        <attr name="circle_border_color" format="color" />
        <!--文字颜色-->
        <attr name="text_color" format="color" />
        <!--波浪的速度-->
        <attr name="wave_speed" format="integer" />
        <!--波浪的高度-->
        <attr name="wave_height" format="dimension" />
        <!--是否位于布局中心,系统自带属性-->
        <attr name="android:layout_centerInParent" />
    </declare-styleable>

然后创建一个 View 的子类 CirclrWaveProgress,在类中获取我们设置的属性值:

public class CircleWaveProgress extends View {

    private AttributeSet mAttrs;
    private TypedArray typedArray;

    /**
     * 属性 - 圆的半径
     */
    private float circleRadius;
    /**
     * 属性 - 圆的背景色
     */
    private int circleBackground;
    /**
     * 属性 - 深色波浪颜色
     */
    private int darkWaveColor;
    /**
     * 属性 - 浅色波浪颜色
     */
    private int lightWaveColor;
    /**
     * 属性 - 圆外围边框颜色
     */
    private int circleBorderColor;
    /**
     * 属性 - 文字颜色
     */
    private int textColor;
    /**
     * 属性 - 波浪速度
     */
    private int waveSpeed;
    /**
     * 属性 - 波浪高度
     */
    private float waveHeight;

    /**
     * 属性 - 控件是否位于布局中心
     */
    private boolean centerInParent;

    public CircleWaveProgress(Context context, @Nullable AttributeSet attrs) {
        super(context, attrs);
        initAttrs(context, attrs);
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        int widthSize = MeasureSpec.getSize(widthMeasureSpec);
        int heightSize = MeasureSpec.getSize(heightMeasureSpec);
        // 在测量时确定半径,如果没有设置半径,则半径设置为长宽较短的那个值的1/2
        float defaultCircleRadius = widthSize > heightSize ? heightSize / 2 : widthSize / 2;
        circleRadius = typedArray.getDimension(R.styleable.CircleWaveProgress_circle_radius, defaultCircleRadius);
    }

    private void initAttrs(Context context, AttributeSet attrs) {
        typedArray = context.obtainStyledAttributes(attrs, R.styleable.CircleWaveProgress);
        circleBackground = typedArray.getInt(R.styleable.CircleWaveProgress_circle_background, Color.RED);
        darkWaveColor = typedArray.getInt(R.styleable.CircleWaveProgress_dark_wave_color, Color.BLUE);
        lightWaveColor = typedArray.getInt(R.styleable.CircleWaveProgress_light_wave_color, Color.GRAY);
        circleBorderColor = typedArray.getInt(R.styleable.CircleWaveProgress_circle_border_color, Color.GREEN);
        textColor = typedArray.getInt(R.styleable.CircleWaveProgress_text_color, Color.BLACK);
        waveSpeed = typedArray.getInt(R.styleable.CircleWaveProgress_wave_speed, 2000);
        waveHeight = typedArray.getInt(R.styleable.CircleWaveProgress_wave_height, 2000);
        centerInParent = typedArray.getBoolean(R.styleable.CircleWaveProgress_android_layout_centerInParent, false);
        //记得回收 TypedArray,否则容易造成内存泄漏
        typedArray.recycle();
    }
}

绘制

所有绘制操作都是在 onDraw 方法中进行的,我们重写 onDraw 方法。

贝塞尔曲线的绘制

所谓贝塞尔曲线,通俗的讲就是通过三个点来精确的画出曲线,其中两个点为起点和终点,另一个点为控制点,这个图可以很直观的展示:

可以看到,通过移动三个点可以在不同位置绘制不同的曲线,我们可以用它来绘制波浪。

Android Path 类提供了绘制贝塞尔曲线的方法:

quadTo(float x1, float y1, float x2, float y2)

其参数从左到右依次是 控制点 X 坐标,控制点 Y 坐标,终点 X 坐标,终点 Y 坐标,而起始点坐标则为默认原点(也就是 0.0),可以通过 Path.moveTo 方法来设置起始点。

现在我们来确定一下几个点的位置:

我们现在屏幕上标出五个点:

  1. 将圆心设为一个点,为基准点,其坐标为(circleRadius, circleRadius)
  2. 基准点右边一个点(circleRadius * 2, circleRadius)
  3. 基准点左边三个点,分别为:(0, circleRadius)
  4. (-circleRadius, circleRadius)
  5. (-circleRadius * 2, circleRadius)

再在两个点中间标出控制点:

  1. (-circleRadius * 3 / 2, circleRadius + waveHeight)
  2. (-circleRadius / 2, circleRadius - waveHeight)
  3. (circleRadius / 2, circleRadius + waveHeight)
  4. (circleRadius * 3 / 2, circleRadius - waveHeight)

他们的位置大致是这样的

通过这几个点去画曲线:

        PointF point1 = new PointF(-circleRadius * 2, circleRadius);
        PointF point2 = new PointF(-circleRadius, circleRadius);
        PointF point3 = new PointF(0, circleRadius);
        PointF point4 = new PointF(circleRadius, circleRadius);
        PointF point5 = new PointF(circleRadius * 2, circleRadius);

        PointF cPoint1 = new PointF(-circleRadius * 3 / 2, circleRadius + waveHeight);
        PointF cPoint2 = new PointF(-circleRadius / 2, circleRadius - waveHeight);
        PointF cPoint3 = new PointF(circleRadius / 2, circleRadius + waveHeight);
        PointF cPoint4 = new PointF(circleRadius * 3 / 2, circleRadius - waveHeight);
        
        path.moveTo(point1.x, point1.y);
        path.quadTo(cPoint1.x, cPoint1.y, point2.x, point2.y);
        path.quadTo(cPoint2.x, cPoint2.y, point3.x, point3.y);
        path.quadTo(cPoint3.x, cPoint3.y, point4.x, point4.y);
        path.quadTo(cPoint4.x, cPoint4.y, point5.x, point5.y);
        
        canvas.drawPath(path, darkWavePaint);

这个时候绘制的效果是这样的:

红框内的内容没有显示出来,等到让波浪动起来的时候会用到。

接下来的工作是让波浪动起来,我们让每个点逐渐向右移动,这样屏幕外的内容逐渐进入到屏幕内,形成波浪效果。

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);

        path.reset();

        PointF point1 = new PointF(-circleRadius * 2, circleRadius);
        PointF point2 = new PointF(-circleRadius, circleRadius);
        PointF point3 = new PointF(0, circleRadius);
        PointF point4 = new PointF(circleRadius, circleRadius);
        PointF point5 = new PointF(circleRadius * 2, circleRadius);

        PointF cPoint1 = new PointF(-circleRadius * 3 / 2, circleRadius + waveHeight);
        PointF cPoint2 = new PointF(-circleRadius / 2, circleRadius - waveHeight);
        PointF cPoint3 = new PointF(circleRadius / 2, circleRadius + waveHeight);
        PointF cPoint4 = new PointF(circleRadius * 3 / 2, circleRadius - waveHeight);

        drawPointF(canvas, point1, circleBorderPaint);
        drawPointF(canvas, point2, circleBorderPaint);
        drawPointF(canvas, point3, circleBorderPaint);
        drawPointF(canvas, point4, circleBorderPaint);
        drawPointF(canvas, point5, circleBorderPaint);

        drawPointF(canvas, cPoint1, circleBorderPaint);
        drawPointF(canvas, cPoint2, circleBorderPaint);
        drawPointF(canvas, cPoint3, circleBorderPaint);
        drawPointF(canvas, cPoint4, circleBorderPaint);


        path.moveTo(point1.x + offSet, point1.y);
        path.quadTo(cPoint1.x + offSet, cPoint1.y, point2.x + offSet, point2.y);
        path.quadTo(cPoint2.x + offSet, cPoint2.y, point3.x + offSet, point3.y);
        path.quadTo(cPoint3.x + offSet, cPoint3.y, point4.x + offSet, point4.y);
        path.quadTo(cPoint4.x + offSet, cPoint4.y, point5.x + offSet, point5.y);

        canvas.drawPath(path, darkWavePaint);
    }

    @Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
        super.onSizeChanged(w, h, oldw, oldh);
        startWaveAnimator();
    }

    private void startWaveAnimator() {
        ValueAnimator animator = ValueAnimator.ofFloat(0, circleRadius * 2);
        animator.setDuration(waveSpeed * 1000);
        animator.setInterpolator(new LinearInterpolator());
        animator.setRepeatCount(-1);
        animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
            @Override
            public void onAnimationUpdate(ValueAnimator animation) {
                offSet = (float) animation.getAnimatedValue();
                postInvalidate();
            }
        });
        animator.start();
    }

代码也很简单,offSet 值逐渐递增,并且通知系统重绘,系统重绘时,path.reset 重置路径,然后让每个点的 X 坐标加上 offSet,这样就让波浪动起来了。

效果是这样:

这样看起来还不像波浪,我们把路径闭合,就像了:

        path.lineTo(circleRadius * 2, circleRadius * 2);
        path.lineTo(0, circleRadius * 2);
        path.close();

效果是这样:

看起来丑丑的对不对,接下来用到绘制中的剪裁功能了,我们只需要显示一个圆形就可以了,完整代码如下:

public class CircleWaveProgress extends View {

    private AttributeSet mAttrs;
    private TypedArray typedArray;

    /**
     * 属性 - 圆的半径
     */
    private float circleRadius;
    /**
     * 属性 - 圆的背景色
     */
    private int circleBackground;
    /**
     * 属性 - 深色波浪颜色
     */
    private int darkWaveColor;
    /**
     * 属性 - 浅色波浪颜色
     */
    private int lightWaveColor;
    /**
     * 属性 - 圆外围边框颜色
     */
    private int circleBorderColor;
    /**
     * 属性 - 圆外围边框宽度
     */
    private float circleBorderWidth;
    /**
     * 属性 - 文字颜色
     */
    private int textColor;
    /**
     * 属性 - 波浪速度
     */
    private int waveSpeed;
    /**
     * 属性 - 波浪高度
     */
    private float waveHeight;

    /**
     * 属性 - 控件是否位于布局中心
     */
    private boolean centerInParent;

    /**
     * 绘制圆的画笔
     */
    private Paint circlePaint;
    /**
     * 绘制圆边框的画笔
     */
    private Paint circleBorderPaint;

    /**
     * 波浪偏移量
     */
    private float offSet;
    /**
     * 深色波浪画笔
     */
    private Paint darkWavePaint;
    /**
     * 圆形背景画笔
     */
    private Paint circleBackgroundPaint;



    private Path path;

    public CircleWaveProgress(Context context, @Nullable AttributeSet attrs) {
        super(context, attrs);
        initAttrs(context, attrs);
        initDrawTool();
    }

    // 初始化控件绘制的画笔、路径等
    private void initDrawTool() {
        circlePaint = new Paint();
        circlePaint.setColor(circleBackground);
        circlePaint.setStyle(Paint.Style.FILL_AND_STROKE);

        circleBorderPaint = new Paint();
        circleBorderPaint.setColor(circleBorderColor);
        circleBorderPaint.setStyle(Paint.Style.STROKE);
        circleBorderPaint.setStrokeWidth(circleBorderWidth);


        darkWavePaint = new Paint();
        darkWavePaint.setColor(darkWaveColor);
        path = new Path();

        circleBackgroundPaint = new Paint();
        circleBackgroundPaint.setColor(circleBackground);
        circleBackgroundPaint.setStyle(Paint.Style.FILL);
    }

    //初始化控件属性
    private void initAttrs(Context context, AttributeSet attrs) {
        typedArray = context.obtainStyledAttributes(attrs, R.styleable.CircleWaveProgress);
        circleBackground = typedArray.getInt(R.styleable.CircleWaveProgress_circle_background, Color.RED);
        darkWaveColor = typedArray.getInt(R.styleable.CircleWaveProgress_dark_wave_color, Color.BLUE);
        lightWaveColor = typedArray.getInt(R.styleable.CircleWaveProgress_light_wave_color, Color.GRAY);
        circleBorderColor = typedArray.getInt(R.styleable.CircleWaveProgress_circle_border_color, Color.GREEN);
        circleBorderWidth = typedArray.getDimension(R.styleable.CircleWaveProgress_circle_border_width, 20);
        textColor = typedArray.getInt(R.styleable.CircleWaveProgress_text_color, Color.BLACK);
        waveSpeed = typedArray.getInt(R.styleable.CircleWaveProgress_wave_speed, 2);
        waveHeight = typedArray.getDimension(R.styleable.CircleWaveProgress_wave_height, 0);
        centerInParent = typedArray.getBoolean(R.styleable.CircleWaveProgress_android_layout_centerInParent, false);
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        int widthSize = MeasureSpec.getSize(widthMeasureSpec);
        int heightSize = MeasureSpec.getSize(heightMeasureSpec);
        // 在测量时确定半径,如果没有设置半径,则半径设置为长宽较短的那个值的1/2
        float defaultCircleRadius = widthSize > heightSize ? heightSize / 2 : widthSize / 2;
        circleRadius = typedArray.getDimension(R.styleable.CircleWaveProgress_circle_radius, defaultCircleRadius);
    }


    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        path.reset();

        canvas.drawCircle(circleRadius, circleRadius, circleRadius, circleBackgroundPaint);

        path.addCircle(circleRadius, circleRadius, circleRadius, Path.Direction.CCW);
        canvas.clipPath(path);


        PointF point1 = new PointF(-circleRadius * 2, circleRadius);
        PointF point2 = new PointF(-circleRadius, circleRadius);
        PointF point3 = new PointF(0, circleRadius);
        PointF point4 = new PointF(circleRadius, circleRadius);
        PointF point5 = new PointF(circleRadius * 2, circleRadius);

        PointF cPoint1 = new PointF(-circleRadius * 3 / 2, circleRadius + waveHeight);
        PointF cPoint2 = new PointF(-circleRadius / 2, circleRadius - waveHeight);
        PointF cPoint3 = new PointF(circleRadius / 2, circleRadius + waveHeight);
        PointF cPoint4 = new PointF(circleRadius * 3 / 2, circleRadius - waveHeight);

        path.moveTo(point1.x + offSet, point1.y);
        path.quadTo(cPoint1.x + offSet, cPoint1.y, point2.x + offSet, point2.y);
        path.quadTo(cPoint2.x + offSet, cPoint2.y, point3.x + offSet, point3.y);
        path.quadTo(cPoint3.x + offSet, cPoint3.y, point4.x + offSet, point4.y);
        path.quadTo(cPoint4.x + offSet, cPoint4.y, point5.x + offSet, point5.y);

        path.lineTo(circleRadius * 2, circleRadius * 2);
        path.lineTo(0, circleRadius * 2);
        path.close();

        canvas.drawPath(path, darkWavePaint);
    }

    @Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
        super.onSizeChanged(w, h, oldw, oldh);
        startDarkWaveAnimator();
    }

    private void startDarkWaveAnimator() {
        ValueAnimator animator = ValueAnimator.ofFloat(0, circleRadius * 2);
        animator.setDuration(waveSpeed * 1000);
        animator.setInterpolator(new LinearInterpolator());
        animator.setRepeatCount(-1);
        animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
            @Override
            public void onAnimationUpdate(ValueAnimator animation) {
                offSet = (float) animation.getAnimatedValue();
                postInvalidate();
            }
        });
        animator.start();
    }
}

效果如下,效果是不是出来了?

画了一个波浪,我们再画一个,这次让波浪从右往左移动,给人一种错落有致的感觉,会比较好看。

在设置一条从右往左的波浪,方法和上面一样,不赘述。效果如下:

问题来了,我们现在波浪是在圆的正中央,我们想要设置波浪所占百分比,也很简单,控制每个点的 Y 坐标就可以了嘛。

我们设置一个 waveProgress 属性,然后将 Y 轴坐标设置为 半径2(1-waveProgress/100) 就 OK 啦~

代码如下:

public class CircleWaveProgress extends View {

    private AttributeSet mAttrs;
    private TypedArray typedArray;

    /**
     * 属性 - 圆的半径
     */
    private float circleRadius;
    /**
     * 属性 - 圆的背景色
     */
    private int circleBackground;
    /**
     * 属性 - 深色波浪颜色
     */
    private int darkWaveColor;
    /**
     * 属性 - 浅色波浪颜色
     */
    private int lightWaveColor;
    /**
     * 属性 - 圆外围边框颜色
     */
    private int circleBorderColor;
    /**
     * 属性 - 圆外围边框宽度
     */
    private float circleBorderWidth;
    /**
     * 属性 - 文字颜色
     */
    private int textColor;
    /**
     * 属性 - 波浪速度
     */
    private int waveSpeed;
    /**
     * 属性 - 波浪高度
     */
    private float waveHeight;

    /**
     * 属性 - 控件是否位于布局中心
     */
    private boolean centerInParent;
    /**
     * 属性 - 波浪进度
     */
    private float waveProgress;

    /**
     * 绘制圆的画笔
     */
    private Paint circlePaint;
    /**
     * 绘制圆边框的画笔
     */
    private Paint circleBorderPaint;

    /**
     * 波浪偏移量
     */
    private float offSet;
    /**
     * 深色波浪画笔
     */
    private Paint darkWavePaint;
    /**
     * 浅色波浪画笔
     */
    private Paint lightWavePaint;

    /**
     * 圆形背景画笔
     */
    private Paint circleBackgroundPaint;
    /**
     * 深色浅色波浪路径
     */
    private Path darkWavePath;
    private Path lightWavePath;
    /**
     * 切割圆形路径
     */
    private Path circleClipPath;

    private float waveProgressPercent;

    private ValueAnimator animator;

    public CircleWaveProgress(Context context, @Nullable AttributeSet attrs) {
        super(context, attrs);
        initAttrs(context, attrs);
        initDrawTool();
    }

    // 初始化控件绘制的画笔、路径等
    private void initDrawTool() {
        //圆形边框画笔及属性
        circleBorderPaint = new Paint();
        circleBorderPaint.setColor(circleBorderColor);
        circleBorderPaint.setStyle(Paint.Style.FILL);
        circleBorderPaint.setAntiAlias(true);

        //圆形背景画笔及属性
        circleBackgroundPaint = new Paint();
        circleBackgroundPaint.setColor(circleBackground);
        circleBackgroundPaint.setStyle(Paint.Style.FILL);
        circleBackgroundPaint.setAntiAlias(true);

        //深色波浪画笔及属性
        darkWavePaint = new Paint();
        darkWavePaint.setColor(darkWaveColor);
        darkWavePaint.setAntiAlias(true);
        //深色波浪路径
        darkWavePath = new Path();
        //浅色波浪画笔及属性
        lightWavePaint = new Paint();
        lightWavePaint.setColor(lightWaveColor);
        lightWavePaint.setAntiAlias(true);
        //浅色波浪路径
        lightWavePath = new Path();
        //切割圆形路径
        circleClipPath = new Path();
    }

    //初始化控件属性
    private void initAttrs(Context context, AttributeSet attrs) {
        typedArray = context.obtainStyledAttributes(attrs, R.styleable.CircleWaveProgress);
        circleBackground = typedArray.getInt(R.styleable.CircleWaveProgress_circle_background, Color.RED);
        darkWaveColor = typedArray.getInt(R.styleable.CircleWaveProgress_dark_wave_color, Color.BLUE);
        lightWaveColor = typedArray.getInt(R.styleable.CircleWaveProgress_light_wave_color, Color.GRAY);
        circleBorderColor = typedArray.getInt(R.styleable.CircleWaveProgress_circle_border_color, Color.GREEN);
        circleBorderWidth = typedArray.getDimension(R.styleable.CircleWaveProgress_circle_border_width, 20);
        textColor = typedArray.getInt(R.styleable.CircleWaveProgress_text_color, Color.BLACK);
        waveSpeed = typedArray.getInt(R.styleable.CircleWaveProgress_wave_speed, 2);
        waveHeight = typedArray.getDimension(R.styleable.CircleWaveProgress_wave_height, 0);
        centerInParent = typedArray.getBoolean(R.styleable.CircleWaveProgress_android_layout_centerInParent, false);
        waveProgress = typedArray.getFloat(R.styleable.CircleWaveProgress_wave_progress, 0);
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        int widthSize = MeasureSpec.getSize(widthMeasureSpec);
        int heightSize = MeasureSpec.getSize(heightMeasureSpec);
        // 在测量时确定半径,如果没有设置半径,则半径设置为长宽较短的那个值的1/2
        float defaultCircleRadius = widthSize > heightSize ? heightSize / 2 : widthSize / 2;
        circleRadius = typedArray.getDimension(R.styleable.CircleWaveProgress_circle_radius, defaultCircleRadius);
    }


    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        darkWavePath.reset();
        lightWavePath.reset();

        //画背景圆
        canvas.drawCircle(circleRadius + circleBorderWidth, circleRadius + circleBorderWidth, circleRadius, circleBackgroundPaint);
        //切割为圆形
        circleClipPath.addCircle(circleRadius + circleBorderWidth, circleRadius + circleBorderWidth, circleRadius, Path.Direction.CCW);
        canvas.clipPath(circleClipPath);

        //浅色波浪
        lightWavePath.moveTo(0 - offSet, circleRadius * 2 * getWaveProgressPercent());
        lightWavePath.quadTo(circleRadius / 2 - offSet, circleRadius * 2 * getWaveProgressPercent() - waveHeight, circleRadius - offSet, circleRadius * 2 * getWaveProgressPercent());
        lightWavePath.quadTo(circleRadius * 3 / 2 - offSet, circleRadius * 2 * getWaveProgressPercent() + waveHeight, circleRadius * 2 - offSet, circleRadius * 2 * getWaveProgressPercent());
        lightWavePath.quadTo(circleRadius * 5 / 2 - offSet, circleRadius * 2 * getWaveProgressPercent() - waveHeight, circleRadius * 3 - offSet, circleRadius * 2 * getWaveProgressPercent());
        lightWavePath.quadTo(circleRadius * 7 / 2 - offSet, circleRadius * 2 * getWaveProgressPercent() + waveHeight, circleRadius * 4 - offSet, circleRadius * 2 * getWaveProgressPercent());
        lightWavePath.lineTo(circleRadius * 2 + circleBorderWidth * 2, circleRadius * 2 + circleBorderWidth * 2);
        lightWavePath.lineTo(0, circleRadius * 2 + circleBorderWidth * 2);
        lightWavePath.close();
        canvas.drawPath(lightWavePath, lightWavePaint);
        //深色波浪
        darkWavePath.moveTo(-circleRadius * 2, circleRadius * 2 * getWaveProgressPercent());
        darkWavePath.quadTo(-circleRadius * 3 / 2 + offSet, circleRadius * 2 * getWaveProgressPercent() + waveHeight, -circleRadius + offSet, circleRadius * 2 * getWaveProgressPercent());
        darkWavePath.quadTo(-circleRadius / 2 + offSet, circleRadius * 2 * getWaveProgressPercent() - waveHeight, 0 + offSet, circleRadius * 2 * getWaveProgressPercent());
        darkWavePath.quadTo(circleRadius / 2 + offSet, circleRadius * 2 * getWaveProgressPercent() + waveHeight, circleRadius + offSet, circleRadius * 2 * getWaveProgressPercent());
        darkWavePath.quadTo(circleRadius * 3 / 2 + offSet, circleRadius * 2 * getWaveProgressPercent() - waveHeight, circleRadius * 2 + offSet, circleRadius * 2 * getWaveProgressPercent());
        darkWavePath.lineTo(circleRadius * 2 + circleBorderWidth * 2, circleRadius * 2 + circleBorderWidth * 2);
        darkWavePath.lineTo(0, circleRadius * 2 + circleBorderWidth * 2);
        darkWavePath.close();
        canvas.drawPath(darkWavePath, darkWavePaint);
    }

    @Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
        super.onSizeChanged(w, h, oldw, oldh);
        startWaveAnimator();
    }


    private void startWaveAnimator() {
        animator = ValueAnimator.ofFloat(0, circleRadius * 2);
        animator.setDuration(waveSpeed * 1000);
        animator.setInterpolator(new LinearInterpolator());
        animator.setRepeatCount(-1);
        animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
            @Override
            public void onAnimationUpdate(ValueAnimator animation) {
                offSet = (float) animation.getAnimatedValue();
                postInvalidate();
            }
        });
        animator.start();
    }

    public void setWaveProgress(float waveProgress) {
        this.waveProgress = waveProgress;
        postInvalidate();
    }

    public float getWaveProgress() {
        return waveProgress;
    }

    public void setWaveProgressPercent(float waveProgressPercent) {
        this.waveProgressPercent = waveProgressPercent;
    }

    public float getWaveProgressPercent() {
        return 1 - (waveProgress / 100);
    }
}

效果如下:

绘制背景圆

这个没啥可说的,就是画一个圆罢了,自定义 View 简单图形的绘制

绘制外边框

我们想要外边框也跟着进度变化,Canvas 也提供了画弧线的响应方法 canvas.drawArc,直接绘制背景圆形之前绘制一个包含圆心的圆弧,绘制角度随着变化更改,同时通知系统重绘即可。

        //画外围圆圈
        RectF rect = new RectF(0, 0, (circleRadius + circleBorderWidth) * 2, (circleRadius + circleBorderWidth) * 2);
        canvas.drawArc(rect, 270, 360 * (1 - getWaveProgressPercent()), true, circleBorderPaint);

        //画背景圆
        canvas.drawCircle(circleRadius + circleBorderWidth, circleRadius + circleBorderWidth, circleRadius, circleBackgroundPaint);
        //切割为圆形
        circleClipPath.addCircle(circleRadius + circleBorderWidth, circleRadius + circleBorderWidth, circleRadius, Path.Direction.CCW);
        canvas.clipPath(circleClipPath);

效果如下:

绘制中央文字

Android 同样提供了绘制文字的 API:canvas.drawText,但是这个绘制文字有一些需要注意的地方,我们先来画一个看看效果,然后再调整:

        canvas.drawCircle(circleRadius + circleBorderWidth, circleRadius + circleBorderWidth, circleRadius + circleBorderWidth, circleBackgroundPaint);

        TextPaint paint = new TextPaint();
        paint.setColor(textColor);
        paint.setTextSize(textSize);
        String progressStr = getWaveProgress() + "%";
        canvas.drawText(progressStr, circleRadius + circleBorderWidth, circleRadius + circleBorderWidth, paint);

这个时候的效果是这样的:

我们发现,文字并不是位于圆中央的,具体原因,这篇文章里说的很清楚:HenCoder Android 开发进阶:自定义 View 1-3 drawText() 文字的绘制

处理方法也很简单,我们获取到文字的宽和高,然后调整绘制的坐标不就 OK 了吗?!

获取文字宽高的方法看这里 android测量文字的宽高

获取到文字的宽高之后,在绘制时将原本的 X 坐标设置 -width/2,Y 坐标设置 +height/2,这样不就是在正中央绘制文字了么。
最终代码是这样的:

public class CircleWaveProgress extends View {

    private AttributeSet mAttrs;
    private TypedArray typedArray;

    /**
     * 属性 - 圆的半径
     */
    private float circleRadius;
    /**
     * 属性 - 圆的背景色
     */
    private int circleBackground;
    /**
     * 属性 - 深色波浪颜色
     */
    private int darkWaveColor;
    /**
     * 属性 - 浅色波浪颜色
     */
    private int lightWaveColor;
    /**
     * 属性 - 圆外围边框颜色
     */
    private int circleBorderColor;
    /**
     * 属性 - 圆外围边框宽度
     */
    private float circleBorderWidth;
    /**
     * 属性 - 文字颜色
     */
    private int textColor;
    /**
     * 属性 - 波浪速度
     */
    private int waveSpeed;
    /**
     * 属性 - 波浪高度
     */
    private float waveHeight;

    /**
     * 属性 - 控件是否位于布局中心
     */
    private boolean centerInParent;
    /**
     * 属性 - 波浪进度
     */
    private float waveProgress;

    /**
     * 属性 - 文字尺寸
     */
    private float textSize;
    /**
     * 绘制圆的画笔
     */
    private Paint circlePaint;
    /**
     * 绘制圆边框的画笔
     */
    private Paint circleBorderPaint;

    /**
     * 波浪偏移量
     */
    private float offSet;
    /**
     * 深色波浪画笔
     */
    private Paint darkWavePaint;
    /**
     * 浅色波浪画笔
     */
    private Paint lightWavePaint;

    /**
     * 圆形背景画笔
     */
    private Paint circleBackgroundPaint;
    /**
     * 深色浅色波浪路径
     */
    private Path darkWavePath;
    private Path lightWavePath;
    /**
     * 切割圆形路径
     */
    private Path circleClipPath;
    /**
     * 进度百分比
     */
    private float waveProgressPercent;
    /**
     * 波浪动画
     */
    private ValueAnimator animator;
    /**
     * 测量文字宽高的 Rect
     */
    private Rect textMeasureRect;
    /**
     * 外围圆弧 Rect
     */
    private RectF borderArcRect;

    public CircleWaveProgress(Context context, @Nullable AttributeSet attrs) {
        super(context, attrs);
        initAttrs(context, attrs);
        initDrawTool();
    }

    /**
     * 初始化控件绘制的画笔、路径等
     */
    private void initDrawTool() {
        //圆形边框画笔及属性
        circleBorderPaint = new Paint();
        circleBorderPaint.setColor(circleBorderColor);
        circleBorderPaint.setStyle(Paint.Style.FILL);
        circleBorderPaint.setAntiAlias(true);

        //圆形背景画笔及属性
        circleBackgroundPaint = new Paint();
        circleBackgroundPaint.setColor(circleBackground);
        circleBackgroundPaint.setStyle(Paint.Style.FILL);
        circleBackgroundPaint.setAntiAlias(true);

        //深色波浪画笔及属性
        darkWavePaint = new Paint();
        darkWavePaint.setColor(darkWaveColor);
        darkWavePaint.setAntiAlias(true);
        //深色波浪路径
        darkWavePath = new Path();
        //浅色波浪画笔及属性
        lightWavePaint = new Paint();
        lightWavePaint.setColor(lightWaveColor);
        lightWavePaint.setAntiAlias(true);
        //浅色波浪路径
        lightWavePath = new Path();
        //切割圆形路径
        circleClipPath = new Path();
        //测量文字宽高 Rect
        textMeasureRect = new Rect();
    }

    /**
     * 初始化控件属性
     * @param context
     * @param attrs
     */
    private void initAttrs(Context context, AttributeSet attrs) {
        typedArray = context.obtainStyledAttributes(attrs, R.styleable.CircleWaveProgress);
        circleBackground = typedArray.getInt(R.styleable.CircleWaveProgress_circle_background, Color.RED);
        darkWaveColor = typedArray.getInt(R.styleable.CircleWaveProgress_dark_wave_color, Color.BLUE);
        lightWaveColor = typedArray.getInt(R.styleable.CircleWaveProgress_light_wave_color, Color.GRAY);
        circleBorderColor = typedArray.getInt(R.styleable.CircleWaveProgress_circle_border_color, Color.GREEN);
        circleBorderWidth = typedArray.getDimension(R.styleable.CircleWaveProgress_circle_border_width, 20);
        textColor = typedArray.getInt(R.styleable.CircleWaveProgress_text_color, Color.BLACK);
        waveSpeed = typedArray.getInt(R.styleable.CircleWaveProgress_wave_speed, 2);
        waveHeight = typedArray.getDimension(R.styleable.CircleWaveProgress_wave_height, 0);
        centerInParent = typedArray.getBoolean(R.styleable.CircleWaveProgress_android_layout_centerInParent, false);
        waveProgress = typedArray.getFloat(R.styleable.CircleWaveProgress_wave_progress, 0);
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        int widthSize = MeasureSpec.getSize(widthMeasureSpec);
        int heightSize = MeasureSpec.getSize(heightMeasureSpec);
        // 在测量时确定半径,如果没有设置半径,则半径设置为长宽较短的那个值的1/2
        float defaultCircleRadius = widthSize > heightSize ? heightSize / 2 : widthSize / 2;
        circleRadius = typedArray.getDimension(R.styleable.CircleWaveProgress_circle_radius, defaultCircleRadius);
        textSize = typedArray.getDimension(R.styleable.CircleWaveProgress_text_size, circleRadius / 2);
    }


    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        darkWavePath.reset();
        lightWavePath.reset();

        //画外围圆圈
        borderArcRect = new RectF(0, 0, (circleRadius + circleBorderWidth) * 2, (circleRadius + circleBorderWidth) * 2);
        canvas.drawArc(borderArcRect, 270, 360 * (1 - getWaveProgressPercent()), true, circleBorderPaint);

        //画背景圆
        canvas.drawCircle(circleRadius + circleBorderWidth, circleRadius + circleBorderWidth, circleRadius, circleBackgroundPaint);
        //切割为圆形
        circleClipPath.addCircle(circleRadius + circleBorderWidth, circleRadius + circleBorderWidth, circleRadius, Path.Direction.CCW);
        canvas.clipPath(circleClipPath);

        //浅色波浪
        lightWavePath.moveTo(0 - offSet, circleRadius * 2 * getWaveProgressPercent());
        lightWavePath.quadTo(circleRadius / 2 - offSet, circleRadius * 2 * getWaveProgressPercent() - waveHeight, circleRadius - offSet, circleRadius * 2 * getWaveProgressPercent());
        lightWavePath.quadTo(circleRadius * 3 / 2 - offSet, circleRadius * 2 * getWaveProgressPercent() + waveHeight, circleRadius * 2 - offSet, circleRadius * 2 * getWaveProgressPercent());
        lightWavePath.quadTo(circleRadius * 5 / 2 - offSet, circleRadius * 2 * getWaveProgressPercent() - waveHeight, circleRadius * 3 - offSet, circleRadius * 2 * getWaveProgressPercent());
        lightWavePath.quadTo(circleRadius * 7 / 2 - offSet, circleRadius * 2 * getWaveProgressPercent() + waveHeight, circleRadius * 4 - offSet, circleRadius * 2 * getWaveProgressPercent());
        lightWavePath.lineTo(circleRadius * 2 + circleBorderWidth * 2, circleRadius * 2 + circleBorderWidth * 2);
        lightWavePath.lineTo(0, circleRadius * 2 + circleBorderWidth * 2);
        lightWavePath.close();
        canvas.drawPath(lightWavePath, lightWavePaint);
        //深色波浪
        darkWavePath.moveTo(-circleRadius * 2, circleRadius * 2 * getWaveProgressPercent());
        darkWavePath.quadTo(-circleRadius * 3 / 2 + offSet, circleRadius * 2 * getWaveProgressPercent() + waveHeight, -circleRadius + offSet, circleRadius * 2 * getWaveProgressPercent());
        darkWavePath.quadTo(-circleRadius / 2 + offSet, circleRadius * 2 * getWaveProgressPercent() - waveHeight, 0 + offSet, circleRadius * 2 * getWaveProgressPercent());
        darkWavePath.quadTo(circleRadius / 2 + offSet, circleRadius * 2 * getWaveProgressPercent() + waveHeight, circleRadius + offSet, circleRadius * 2 * getWaveProgressPercent());
        darkWavePath.quadTo(circleRadius * 3 / 2 + offSet, circleRadius * 2 * getWaveProgressPercent() - waveHeight, circleRadius * 2 + offSet, circleRadius * 2 * getWaveProgressPercent());
        darkWavePath.lineTo(circleRadius * 2 + circleBorderWidth * 2, circleRadius * 2 + circleBorderWidth * 2);
        darkWavePath.lineTo(0, circleRadius * 2 + circleBorderWidth * 2);
        darkWavePath.close();
        canvas.drawPath(darkWavePath, darkWavePaint);

        //画文字
        TextPaint textPaint = new TextPaint();
        textPaint.setColor(textColor);
        textPaint.setTextSize(textSize);
        textPaint.getTextBounds(getWaveProgress() + "%", 0, (getWaveProgress() + "%").length(), textMeasureRect);
        canvas.drawText(getWaveProgress() + "%", circleRadius + circleBorderWidth - textMeasureRect.width() / 2, circleRadius + circleBorderWidth + textMeasureRect.height() / 2, textPaint);
    }

    @Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
        super.onSizeChanged(w, h, oldw, oldh);
        startWaveAnimator();
    }


    private void startWaveAnimator() {
        animator = ValueAnimator.ofFloat(0, circleRadius * 2);
        animator.setDuration(waveSpeed * 1000);
        animator.setInterpolator(new LinearInterpolator());
        animator.setRepeatCount(-1);
        animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
            @Override
            public void onAnimationUpdate(ValueAnimator animation) {
                offSet = (float) animation.getAnimatedValue();
                postInvalidate();
            }
        });
        animator.start();
    }

    public void setWaveProgress(float waveProgress) {
        this.waveProgress = waveProgress;
        postInvalidate();
    }

    public float getWaveProgress() {
        return waveProgress;
    }

    public void setWaveProgressPercent(float waveProgressPercent) {
        this.waveProgressPercent = waveProgressPercent;
    }

    public float getWaveProgressPercent() {
        return 1 - (waveProgress / 100);
    }
}

效果是这样的:

代码都做了很详细的注释,不多做解释。

进一步修改

现在波浪的进度是通过调用 View 的 setWavePogress 方法来设置的,现在想要改成直接设置一个进度(譬如 85%) 然后波浪会直接在固定时间内直接从 0% 涨到我们设置的进度。

我们直接添加一个方法 setFinalProgressPercent:

    public void setFinalProgressPercent(int finalProgress){
        ValueAnimator animator = ValueAnimator.ofInt(0,finalProgress);
        animator.setDuration(2000);
        animator.setRepeatCount(0);
        animator.setInterpolator(new LinearInterpolator());
        animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
            @Override
            public void onAnimationUpdate(ValueAnimator animation) {
                setWaveProgress((int)animation.getAnimatedValue());
            }
        });
        animator.start();
    }

我直接设置了动画时间为 2 秒,也可以通过属性设置,添加一个属性就是了。

问题

  1. 在将图形切割为圆形时,如果全部画完再切割,会变得很卡,先切割,再画,会稍微好一些,但是运行时间一长还是卡,这是因为 View 的绘制是在主线程当中的,我们频繁的绘制,自然会造成卡顿,可以使用 postInvalidate(left ,right ,top ,bottom);方法来局部刷新,但这种频繁刷新的图形,最好是使用 surfaceView 来绘制。
  2. 很多值没有做判断,譬如进度设置为 200 怎么办?后期会添加。
  3. 开启抗锯齿会让绘制效果看起来更圆滑漂亮,但是很容易变卡。