很多场景下都用到这种进度条,有的还带动画效果,
 
今天我也来写一个。
写之前先拆解下它的组成:
- 底层圆形
- 上层弧形
- 中间文字
那我们要做的就是:
- 绘制底层圆形;
- 在同位置绘制上层弧形,但颜色不同;
- 在中心点绘制文本,显示进度。
按照这个目标,学习下自定义View的流程。
1.基础
新建一个类,继承 View ,重写构造函数,如,
package com.test.luodemo.customerview;
import android.content.Context;
import android.util.AttributeSet;
import android.view.View;
import androidx.annotation.Nullable;
public class CircleProgressBar extends View {
    public CircleProgressBar(Context context) {
        super(context);
    }
    public CircleProgressBar(Context context, @Nullable AttributeSet attrs) {
        super(context, attrs);
    }
    public CircleProgressBar(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
    }
}
在 xml 中使用,LinearLayout 加了背景颜色,方便看出所在位置。
<LinearLayout
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:background="@color/purple_200">
            <com.test.luodemo.customerview.CircleProgressBar
                android:layout_width="300dp"
                android:layout_height="300dp"/>
        </LinearLayout>
此时运行,是没效果的,因为这个View还没有绘制,啥也没有。
2.绘制底层圆形
初始化3个图形的画笔 ,底层圆形和上层弧形的画笔宽度一致、颜色不一致,方便区分
重写 onDraw(Canvas canvas) 方法,用 canvas.drawCircle 绘制底层圆形,
package com.test.luodemo.customerview;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.util.AttributeSet;
import android.view.View;
import androidx.annotation.Nullable;
public class CircleProgressBar extends View {
    private Paint paintCircleBottom = new Paint();
    private Paint paintArcTop = new Paint();
    private Paint paintText = new Paint();
    
    public CircleProgressBar(Context context) {
        super(context);
        init();
    }
    public CircleProgressBar(Context context, @Nullable AttributeSet attrs) {
        super(context, attrs);
        init();
    }
    public CircleProgressBar(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        init();
    }
    private void init(){
        //初始化文本的画笔
        paintText.setFlags(Paint.ANTI_ALIAS_FLAG);
        paintText.setColor(Color.BLACK);
        paintText.setTextAlign(Paint.Align.CENTER);
        paintText.setTextSize(80f);
        //初始化底层圆形的画笔
        paintCircleBottom.setFlags(Paint.ANTI_ALIAS_FLAG);
        paintCircleBottom.setColor(Color.LTGRAY);
        paintCircleBottom.setStrokeWidth(10f);
        paintCircleBottom.setStrokeCap(Paint.Cap.ROUND);
        paintCircleBottom.setStyle(Paint.Style.STROKE);
        //初始化弧形的画笔
        paintArcTop.setFlags(Paint.ANTI_ALIAS_FLAG);
        paintArcTop.setColor(Color.MAGENTA);
        paintArcTop.setStrokeWidth(10f);
        paintArcTop.setStrokeCap(Paint.Cap.ROUND);
        paintArcTop.setStyle(Paint.Style.STROKE);
    }
    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        //绘制底层圆形
        canvas.drawCircle(300, 300, 200, paintCircleBottom);
    }
}
效果,
 
3.绘制上层弧形
在之前的基础上绘制上层弧形,弧形的中心和圆心一致。
用 canvas.drawArc 绘制弧形。这里直接指定绘制的角度是 90° ,后续会动态指定。
@Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        //绘制底层圆形
        canvas.drawCircle( 300, 300, 200, paintCircleBottom);
        //绘制上层弧形,从顶部开始,顺时针走90°
        _angle = 90;
        canvas.drawArc(100,100,500,500,270, _angle,false, paintArcTop);
    }
效果,
 
4.绘制文本
用 canvas.drawText 绘制文本,
使用 DecimalFormat 格式化输入,保留小数点后两位,如果小数点后两位都是0则不显示小数点后两位。
	@Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        //绘制底层圆形
        canvas.drawCircle(300, 300, 200, paintCircleBottom);
        //绘制上层弧形,从顶部开始,顺时针走90°
        _angle = 90;
        canvas.drawArc(100,100,500,500,270, _angle,false, paintArcTop);
        //绘制文本
        DecimalFormat dt = new DecimalFormat("0.##");
        canvas.drawText(dt.format(100 * _angle/360)+"%", 300 , 300, paintText);
    }
效果,
 
可以看到,文本虽然居中,但是文本是显示在中心线上,
 
期望结果是文本的水平中心线和圆心重合,改为,
		//绘制文本,文字中心和圆心保持一致
        Paint.FontMetrics fontMetrics = paintText.getFontMetrics();
        float distance =(fontMetrics.bottom - fontMetrics.top)/2 - fontMetrics.bottom;
        float baseline= 300 + distance;
        canvas.drawText(dt.format(100 * _angle/360)+"%", 300, baseline, paintText);//文字中心和圆心一致        
效果,
 
5.添加动画
创建一个接口,供外部调用。
使用 ValueAnimator ,监听动画过程,然后逐渐刷新角度值。使用 AccelerateInterpolator 插值器,动画速度开始慢、逐渐加速。
	@Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        //绘制底层圆形
        canvas.drawCircle(300, 300, 200, paintCircleBottom);
        //绘制上层弧形,从顶部开始,顺时针走90°
        canvas.drawArc(100,100,500,500,270, _angle,false, paintArcTop);
        //绘制文本,文字中心和圆心保持一致
        DecimalFormat dt = new DecimalFormat("0.##");
        Paint.FontMetrics fontMetrics = paintText.getFontMetrics();
        float distance =(fontMetrics.bottom - fontMetrics.top)/2 - fontMetrics.bottom;
        float baseline= 300 + distance;
        canvas.drawText(dt.format(100 * _angle/360)+"%", 300, baseline, paintText);//文字中心和圆心一致
    }
	
	/**
     * 设置进度,展现动画
     * */
    public void setProgress(int progress){
        ValueAnimator animator = ValueAnimator.ofFloat(0,100f);
        animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
            @Override
            public void onAnimationUpdate(ValueAnimator animation) {
                float cur = (float) animation.getAnimatedValue();
                _angle = cur/100 * 360 * progress/100;
                invalidate(); //刷新 View
            }
        });
        animator.setDuration(3000);
        animator.setInterpolator(new AccelerateInterpolator());
        animator.start();
    }
注意要去掉 3.绘制上层弧形 中固定90°的逻辑。
外部调用,
CircleProgressBar mCircleProgressBar1 = (CircleProgressBar) findViewById(R.id.circle_progress_bar1);
mCircleProgressBar1.setProgress((int) (100 * Math.random()));
随机生成一个 0.0 - 0.1 的数值,乘以 100 设置为进度。
 效果,
 
 可以看到动画效果, 虽然 git 丢帧了 ~ 。
6.调整位置、宽高
前文我是设定了 View 宽高都是 300dp ,并且绘制图形是随意指定的坐标。
实际开发时,不可能用这些值,所以要优化下绘制的逻辑。
实际使用时,可能宽度高度一样,宽度大于高度 ,宽度小于高度,
采用这个逻辑:
- 取宽度、高度的最小值,作为圆的直径,除以 2 得到半径。
- 对角线交汇点作为圆心。
简言之,以对角线为圆心画最大内切圆。
重写 onMeasure 方法,重绘 View 的宽高,这部分参考《Android 开发艺术探索》,
	private int DEFAULT_WIDTH = 100;//默认宽度
    private int DEFAULT_HEIGHT = 100;//默认宽度
    private int DEFAULT_RADIUS = 50;//默认半径
    
	@Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        int widthMode = MeasureSpec.getMode(widthMeasureSpec);
        int heightMode = MeasureSpec.getMode(heightMeasureSpec);
        if (widthMode == MeasureSpec.AT_MOST && heightMode == MeasureSpec.AT_MOST) {
            setMeasuredDimension(DEFAULT_WIDTH, DEFAULT_HEIGHT);
        } else if (widthMode == MeasureSpec.AT_MOST) {
            setMeasuredDimension(DEFAULT_WIDTH, heightMeasureSpec);
        } else if (heightMode == MeasureSpec.AT_MOST) {
            setMeasuredDimension(widthMeasureSpec, DEFAULT_HEIGHT);
        }
    }
修改 onDraw 绘制逻辑 ,
	@Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        // 圆心坐标是(centerX,centerY)
        int centerX = getWidth()/2;
        int centerY = getHeight()/2;
        //确定半径
        float radius = Math.min(centerX, centerY) - paintCircleBottom.getStrokeWidth();
        //绘制底层圆形
        canvas.drawCircle(centerX, centerY, radius, paintCircleBottom);
        //绘制上层弧形,从顶部开始,顺时针走 _angle
        canvas.drawArc(centerX - radius,centerY-radius,centerX + radius,centerY + radius,270, _angle,false, paintArcTop);
        //绘制文本,文字中心和圆心保持一致
        Paint.FontMetrics fontMetrics = paintText.getFontMetrics();
        float distance =(fontMetrics.bottom - fontMetrics.top)/2 - fontMetrics.bottom;
        float baseline= centerY + distance;
        canvas.drawText(dt.format(100 * _angle/360)+"%", centerX, baseline, paintText);//文字中心和圆心一致
    }
分别写了 3 个布局,布局依次是 宽度等于高度 、宽度大宇高度、宽度小于高度,效果,
 
 至此,基本是一个还可以的版本了。
附代码
贴下当前代码,
 CircleProgressBar.java
package com.test.luodemo.customerview;
import android.animation.ValueAnimator;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.util.AttributeSet;
import android.util.Log;
import android.view.View;
import android.view.animation.AccelerateInterpolator;
import androidx.annotation.Nullable;
import java.text.DecimalFormat;
public class CircleProgressBar extends View {
    private Paint paintCircleBottom = new Paint();
    private Paint paintArcTop = new Paint();
    private Paint paintText = new Paint();
    private int DEFAULT_WIDTH = 100;//默认宽度
    private int DEFAULT_HEIGHT = 100;//默认宽度
    private int DEFAULT_RADIUS = 50;//默认半径
    private float _angle;//弧形的角度
    
    public CircleProgressBar(Context context) {
        super(context);
        init();
    }
    public CircleProgressBar(Context context, @Nullable AttributeSet attrs) {
        super(context, attrs);
        init();
    }
    public CircleProgressBar(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        init();
    }
    private void init(){
        //初始化文本的画笔
        paintText.setFlags(Paint.ANTI_ALIAS_FLAG);
        paintText.setColor(Color.BLACK);
        paintText.setTextAlign(Paint.Align.CENTER);
        paintText.setTextSize(80f);
        //初始化底层圆形的画笔
        paintCircleBottom.setFlags(Paint.ANTI_ALIAS_FLAG);
        paintCircleBottom.setColor(Color.LTGRAY);
        paintCircleBottom.setStrokeWidth(10f);
        paintCircleBottom.setStrokeCap(Paint.Cap.ROUND);
        paintCircleBottom.setStyle(Paint.Style.STROKE);
        //初始化弧形的画笔
        paintArcTop.setFlags(Paint.ANTI_ALIAS_FLAG);
        paintArcTop.setColor(Color.MAGENTA);
        paintArcTop.setStrokeWidth(10f);
        paintArcTop.setStrokeCap(Paint.Cap.ROUND);
        paintArcTop.setStyle(Paint.Style.STROKE);
    }
    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        int widthMode = MeasureSpec.getMode(widthMeasureSpec);
        int heightMode = MeasureSpec.getMode(heightMeasureSpec);
        if (widthMode == MeasureSpec.AT_MOST && heightMode == MeasureSpec.AT_MOST) {
            setMeasuredDimension(DEFAULT_WIDTH, DEFAULT_HEIGHT);
        } else if (widthMode == MeasureSpec.AT_MOST) {
            setMeasuredDimension(DEFAULT_WIDTH, heightMeasureSpec);
        } else if (heightMode == MeasureSpec.AT_MOST) {
            setMeasuredDimension(widthMeasureSpec, DEFAULT_HEIGHT);
        }
    }
    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        // 圆心坐标是(centerX,centerY)
        int centerX = getWidth()/2;
        int centerY = getHeight()/2;
        //确定半径
        float radius = Math.min(centerX, centerY) - paintCircleBottom.getStrokeWidth();
        //绘制底层圆形
        canvas.drawCircle(centerX, centerY, radius, paintCircleBottom);
        //绘制上层弧形,从顶部开始,顺时针走90°
        canvas.drawArc(centerX - radius,centerY-radius,centerX + radius,centerY + radius,270, _angle,false, paintArcTop);
        //绘制文本,文字中心和圆心保持一致
        DecimalFormat dt = new DecimalFormat("0.##");
        Paint.FontMetrics fontMetrics = paintText.getFontMetrics();
        float distance =(fontMetrics.bottom - fontMetrics.top)/2 - fontMetrics.bottom;
        float baseline= centerY + distance;
        canvas.drawText(dt.format(100 * _angle/360)+"%", centerX, baseline, paintText);//文字中心和圆心一致
    }
    /**
     * 设置进度,展现动画
     * */
    public void setProgress(int progress){
        ValueAnimator animator = ValueAnimator.ofFloat(0,100f);
        animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
            @Override
            public void onAnimationUpdate(ValueAnimator animation) {
                float cur = (float) animation.getAnimatedValue();
                _angle = cur/100 * 360 * progress/100;
                invalidate();
            }
        });
        animator.setDuration(3000);
        animator.setInterpolator(new AccelerateInterpolator());
        animator.start();
    }
}
布局文件,
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical"
    tools:context=".customerview.CircleProgressBarActivity">
    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:orientation="horizontal">
        <LinearLayout
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:background="@color/purple_200">
            <com.test.luodemo.customerview.CircleProgressBar
                android:id="@+id/circle_progress_bar1"
                android:layout_width="300dp"
                android:layout_height="300dp" />
        </LinearLayout>
        <LinearLayout
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:background="@color/teal_200">
            <com.test.luodemo.customerview.CircleProgressBar
                android:id="@+id/circle_progress_bar2"
                android:layout_width="300dp"
                android:layout_height="200dp" />
        </LinearLayout>
        <LinearLayout
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:background="@color/teal_700">
            <com.test.luodemo.customerview.CircleProgressBar
                android:id="@+id/circle_progress_bar3"
                android:layout_width="200dp"
                android:layout_height="300dp" />
        </LinearLayout>
        <!--<LinearLayout
            android:layout_width="50dp"
            android:layout_height="70dp"
            android:background="@color/purple_200">
            <com.test.luodemo.customerview.CircleProgressBar
                android:layout_width="match_parent"
                android:layout_height="match_parent"/>
        </LinearLayout>
        <LinearLayout
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:background="@color/purple_200">
            <com.test.luodemo.customerview.CircleProgressBar
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"/>
        </LinearLayout>-->
    </LinearLayout>
    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:visibility="visible">
        <Button
            android:id="@+id/button_cpb1"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:onClick="onCPBButtonClick"
            android:text="Button1" />
        <Button
            android:id="@+id/button_cpb2"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:onClick="onCPBButtonClick"
            android:text="Button2" />
        <Button
            android:id="@+id/button_cpb3"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:onClick="onCPBButtonClick"
            android:text="Button3" />
        <Button
            android:id="@+id/button_cpb_all"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:onClick="onCPBButtonClick"
            android:text="Button All" />
    </LinearLayout>
</LinearLayout>
Activity 调用
public class CircleProgressBarActivity extends AppCompatActivity {
    private CircleProgressBar mCircleProgressBar1 , mCircleProgressBar2 , mCircleProgressBar3;
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_circle_progress_bar);
        Objects.requireNonNull(getSupportActionBar()).setTitle("CircleProgressBarActivity");
        mCircleProgressBar1 = (CircleProgressBar) findViewById(R.id.circle_progress_bar1);
        mCircleProgressBar2 = (CircleProgressBar) findViewById(R.id.circle_progress_bar2);
        mCircleProgressBar3 = (CircleProgressBar) findViewById(R.id.circle_progress_bar3);
    }
    public void onCPBButtonClick(View view) {
        switch (view.getId()) {
            case R.id.button_cpb1:
                mCircleProgressBar1.setProgress((int) (100 * Math.random()));
                break;
            case R.id.button_cpb2:
                mCircleProgressBar2.setProgress((int) (100 * Math.random()));
                break;
            case R.id.button_cpb3:
                mCircleProgressBar3.setProgress((int) (100 * Math.random()));
                break;
            case R.id.button_cpb_all:
                mCircleProgressBar1.setProgress((int) (100 * Math.random()));
                mCircleProgressBar2.setProgress((int) (100 * Math.random()));
                mCircleProgressBar3.setProgress((int) (100 * Math.random()));
                break;
            default:
                break;
        }
    }
}
7.自定义属性 attr
需求是不停的,会有这些需求:可指定画笔(宽度、颜色等)、可指定动画时长等。
这些可以通过创建 Java 接口来设置,但我要学自定义View,就要用 attr 。
未完待续~~
参考资料:
 Android Canvas的drawText()和文字居中方案



















