重学 Android 自定义 View 系列(十):带指针的渐变环形进度条

前言

该篇文章根据前面 重学 Android 自定义 View 系列(六):环形进度条 拓展而来。

最终效果如下:

1. 扩展功能


  1. 支持进度顺时针或逆时针显示
  2. 在进度条末尾添加自定义指针图片
  3. 使用线性渐变为进度条添加颜色效果

2. 关键技术点解析


2.1 进度方向控制的实现

通过添加一个 direction 属性,设置角度的正负,决定进度条是顺时针还是逆时针绘制:

bash 复制代码
public static final int CLOCKWISE = 1;
public static final int COUNTERCLOCKWISE = -1;
private int direction = COUNTERCLOCKWISE; // 默认逆时针

// 在 onDraw 方法中计算扫过的角度时,加入方向
float sweepAngle = 360f * progress / maxProgress;
sweepAngle *= direction;

// 绘制进度条
canvas.drawArc(rectF, startAngle, sweepAngle, false, progressPaint);

2.2 自定义指针图片的绘制

在环形进度条的末尾,绘制一张 Bitmap 图片作为指针,图片可由你传入,但指针的方向要和demo中的一致,如下:

绘制指针的步骤:

  1. 调整指针的绘制半径:确保指针贴合圆环外侧,加入一个 outerSize 参数用于控制指针漏出圆环的长度。
  2. 计算指针位置:使用三角函数计算图片中心点坐标。
  3. 旋转画布并绘制图片(关键):将画布旋转到指定角度后,再绘制指针图片。

用到的三角函数原理如下,再重温一下学校的知识:),因为在Java中 Math 函数计算三角函数用的是弧度而不是角度,所以代码使用了Math.toRadians进行了角度转弧度。

核心代码实现:

java 复制代码
private void drawPointer(Canvas canvas, float angle) {
    // 调整半径,使指针图片紧贴圆环外部
    float adjustedRadius = radius + backgroundPaint.getStrokeWidth() / 2 + outerSize;

    // 计算指针的中心点位置
    float rightCenterX = centerX + adjustedRadius * (float) Math.cos(Math.toRadians(angle));
    float rightCenterY = centerY + adjustedRadius * (float) Math.sin(Math.toRadians(angle));

    // 计算Bitmap左上角位置
    float left = rightCenterX - bitmapWidth;
    float top = rightCenterY - bitmapHeight / 2;

    // 保存画布状态,旋转画布
    canvas.save();
    canvas.rotate(angle, rightCenterX, rightCenterY);

    // 绘制指针Bitmap
    canvas.drawBitmap(pointerBitmap, left, top, null);

    // 恢复画布状态
    canvas.restore();
}

重点是角度的计算 angle = startAngle + sweepAngle,和指针的位移与旋转,结合三角函数计算坐标,并通过旋转画布保持图片对齐,使指针始终指向圆心位置。

2.3 渐变颜色的实现

为进度条添加线性渐变效果使用了 LinearGradient 着色器,实际效果按需求自定义,重点是 计算渐变起点和终点,因为有起始角度的存在,需要用到 圆心坐标、半径和起始角度计算:

bash 复制代码
private void updateGradient() {
    // 计算圆上的起点和终点坐标
    double startAngleRadians = Math.toRadians(startAngle);
    float startX = centerX + (float) (radius * Math.cos(startAngleRadians));
    float startY = centerY + (float) (radius * Math.sin(startAngleRadians));
    float endX = centerX - (float) (radius * Math.cos(startAngleRadians));
    float endY = centerY - (float) (radius * Math.sin(startAngleRadians));

    //线性渐变,从一个点渐变到另一个点,因为渐变的距离是圆的直径 所以,TileMode 在这里实际无意义
    gradientShader = new LinearGradient(
            startX, startY, endX, endY,
            progressColors, null,
            Shader.TileMode.CLAMP
    );
    progressPaint.setShader(gradientShader);
}

4. 定义自定义属性


xml 复制代码
<declare-styleable name="CircularProgressBarEx">
        <!-- 进度条的最大值 -->
        <attr name="maxProgress" format="integer"/>
        <!-- 当前进度 -->
        <attr name="progress" format="integer"/>
        <!-- 环形进度条的背景色 -->
        <attr name="circleBackgroundColor" format="color"/>
        <!-- 进度条的颜色 -->
        <attr name="progressColor" format="color"/>
        <!-- 进度条的宽度 -->
        <attr name="circleWidth" format="dimension"/>
        <!-- 显示进度文本 -->
        <attr name="showProgressText" format="boolean"/>
        <!-- 进度文本的颜色 -->
        <attr name="progressTextColor" format="color"/>
        <!-- 进度文本的大小 -->
        <attr name="progressTextSize" format="dimension"/>
        <!-- 开始角度 -->
        <attr name="startAngle" format="enum">
            <enum name="angle0" value="0"/>
            <enum name="angle90" value="90"/>
            <enum name="angle180" value="180"/>
            <enum name="angle270" value="270"/>
        </attr>
        <!-- 进度方向 -->
        <attr name="direction">
            <enum name="clockwise" value="1"/>
            <enum name="counterclockwise" value="-1"/>
        </attr>
        <!-- 指针漏出的长度 -->
        <attr name="outerSize" format="dimension"/>

    </declare-styleable>

5. 完整代码


bash 复制代码
public class CircularProgressBarEx extends View {

    private Paint backgroundPaint;
    private Paint progressPaint;
    private Paint textPaint;
    private RectF rectF;
    private Bitmap pointerBitmap; // 指针图标
    private float bitmapWidth; // Bitmap的宽度
    private float bitmapHeight; // Bitmap的高度
    private int outerSize = 5; //让指针漏出圆环的长度
    private int maxProgress = 100;
    private int progress = 0;
    private int circleBackgroundColor = Color.GRAY;
    private int[] progressColors = {Color.GREEN, Color.BLUE}; // 渐变颜色
    private int circleWidth = 20;
    private boolean showProgressText = true;
    private int progressTextColor = Color.BLACK;
    private int progressTextSize = 50;
    private int startAngle = 0; // 默认从左边开始
    private float centerX, centerY;
    private float radius;

    public static final int CLOCKWISE = 1;
    public static final int COUNTERCLOCKWISE = -1;
    private int direction = COUNTERCLOCKWISE; // 默认顺时针

    private LinearGradient gradientShader;

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

    public CircularProgressBarEx(Context context, AttributeSet attrs) {
        this(context, attrs, 0);
    }

    public CircularProgressBarEx(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);

        if (attrs != null) {
            TypedArray typedArray = context.getTheme().obtainStyledAttributes(
                    attrs,
                    R.styleable.CircularProgressBarEx,
                    0, 0
            );
            try {
                maxProgress = typedArray.getInt(R.styleable.CircularProgressBarEx_maxProgress, 100);
                progress = typedArray.getInt(R.styleable.CircularProgressBarEx_progress, 0);
                circleBackgroundColor = typedArray.getColor(R.styleable.CircularProgressBarEx_circleBackgroundColor, Color.GRAY);
                circleWidth = typedArray.getDimensionPixelSize(R.styleable.CircularProgressBarEx_circleWidth, 20);
                showProgressText = typedArray.getBoolean(R.styleable.CircularProgressBarEx_showProgressText, true);
                progressTextColor = typedArray.getColor(R.styleable.CircularProgressBarEx_progressTextColor, Color.BLACK);
                progressTextSize = typedArray.getDimensionPixelSize(R.styleable.CircularProgressBarEx_progressTextSize, 50);
                startAngle = typedArray.getInt(R.styleable.CircularProgressBarEx_startAngle, 0);
                direction = typedArray.getInt(R.styleable.CircularProgressBarEx_direction, CLOCKWISE);
                outerSize = typedArray.getDimensionPixelSize(R.styleable.CircularProgressBarEx_outerSize, 5);
            } finally {
                typedArray.recycle();
            }
        }

        backgroundPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
        backgroundPaint.setColor(circleBackgroundColor);
        backgroundPaint.setStyle(Paint.Style.STROKE);
        backgroundPaint.setStrokeWidth(circleWidth);

        progressPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
        progressPaint.setStyle(Paint.Style.STROKE);
        progressPaint.setStrokeWidth(circleWidth);
        //progressPaint.setStrokeCap(Paint.Cap.ROUND);

        textPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
        textPaint.setColor(progressTextColor);
        textPaint.setTextSize(progressTextSize);
        textPaint.setTextAlign(Paint.Align.CENTER);

        rectF = new RectF();
    }

    @Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
        super.onSizeChanged(w, h, oldw, oldh);
        int padding = circleWidth / 2 + outerSize;
        rectF.set(padding, padding, w - padding, h - padding);

        centerX = rectF.centerX();
        centerY = rectF.centerY();

        radius = rectF.width() / 2;
        updateGradient();
    }

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

        // 绘制背景圆环
        //canvas.drawArc(rectF, 0f, 360f, false, backgroundPaint);

        // 绘制背景圆环
        canvas.drawCircle(centerX, centerY, radius, backgroundPaint);

        // 计算进度角度
        float sweepAngle = 360f * progress / maxProgress;
        sweepAngle *= direction;

        // 绘制进度
        canvas.drawArc(rectF, startAngle, sweepAngle, false, progressPaint);

        // 绘制进度文本
        if (showProgressText) {
            String progressText = progress + "%";
            float x = getWidth() / 2f;
            float y = getHeight() / 2f - (textPaint.descent() + textPaint.ascent()) / 2f;
            canvas.drawText(progressText, x, y, textPaint);
        }

        // 绘制指针
        if (pointerBitmap != null) {
            drawPointer(canvas, startAngle + sweepAngle);
        }

    }

    /**
     * 绘制指针
     *
     * @param angle 指针的角度(画布坐标系下的角度)
     */
    private void drawPointer(Canvas canvas, float angle) {

        //Log.d("drawPointer", "angle: " + angle);
        // 计算调整后的半径,使Bitmap边缘紧贴圆环最外部
        float adjustedRadius = radius + backgroundPaint.getStrokeWidth() / 2 + outerSize;

        // 确保Bitmap的右边上的中心点在圆上
        float rightCenterX = centerX + adjustedRadius * (float) Math.cos(Math.toRadians(angle));
        float rightCenterY = centerY + adjustedRadius * (float) Math.sin(Math.toRadians(angle));

        // 计算Bitmap左上角的位置,使得右边上的中心点位于计算出的坐标
        float left = rightCenterX - bitmapWidth;
        float top = rightCenterY - bitmapHeight / 2;

        // 保存画布状态
        canvas.save();

        // 将画布旋转,使得Bitmap对齐到半径上
        canvas.rotate(angle, rightCenterX, rightCenterY);

        // 绘制指针Bitmap
        canvas.drawBitmap(pointerBitmap, left, top, null);

        // 恢复画布状态
        canvas.restore();
    }


    // 更新渐变
    private void updateGradient() {
        float centerX = rectF.centerX();
        float centerY = rectF.centerY();
        float radius = rectF.width() / 2;

        double startAngleRadians = Math.toRadians(startAngle);

        float startX = centerX + (float) (radius * Math.cos(startAngleRadians));
        float startY = centerY + (float) (radius * Math.sin(startAngleRadians));
        float endX = centerX - (float) (radius * Math.cos(startAngleRadians));
        float endY = centerY - (float) (radius * Math.sin(startAngleRadians));

        //Log.d("updateGradient", "startX: " + startX + ", startY: " + startY + ", endX: " + endX + ", endY: " + endY);

        //线性渐变,从一个点渐变到另一个点,因为渐变的距离是圆的直径 所以,TileMode 在这里实际无意义
        gradientShader = new LinearGradient(
                startX, startY, endX, endY,
                progressColors, null,
                Shader.TileMode.CLAMP
        );
        progressPaint.setShader(gradientShader);
    }

    // 设置进度
    public void setProgress(int progress) {
        this.progress = Math.max(0, Math.min(progress, maxProgress));
        invalidate();
    }

    // 设置指针图标
    public void setPointerBitmap(Bitmap bitmap) {
        this.pointerBitmap = bitmap;
        bitmapWidth = bitmap.getWidth();
        bitmapHeight = bitmap.getHeight();
        invalidate();
    }


    // 设置渐变颜色
    public void setProgressColors(int[] colors) {
        this.progressColors = colors;
        updateGradient();
        invalidate();
    }

    // 设置绘制方向
    public void setDirection(int direction) {
        this.direction = direction;
        invalidate();
    }

    // 设置开始角度
    public void setStartAngle(int angle) {
        this.startAngle = angle;
        updateGradient();
        invalidate();
    }

    // 获取当前进度
    public int getProgress() {
        return progress;
    }
}

6. 使用示例


xml:

xml 复制代码
    <com.xaye.diyview.view.progressEx.CircularProgressBarEx
        android:id="@+id/circularProgressBar"
        android:layout_width="140dp"
        android:layout_height="140dp"
        app:maxProgress="100"
        app:circleBackgroundColor="#DDDDDD"
        app:progressColor="#00B8D4"
        app:circleWidth="15dp"
        app:showProgressText="true"
        app:progressTextColor="#000000"
        app:progressTextSize="20sp"
        app:startAngle="angle0"
        app:direction="clockwise"
        app:outerSize="10dp" />

Activity:

kotlin 复制代码
        mBind.circularProgressBar.setPointerBitmap(BitmapFactory.decodeResource(getResources(), R.drawable.point))
        mBind.circularProgressBar2.setPointerBitmap(BitmapFactory.decodeResource(getResources(), R.drawable.point))
        mBind.circularProgressBar3.setPointerBitmap(BitmapFactory.decodeResource(getResources(), R.drawable.point))
        mBind.circularProgressBar4.setPointerBitmap(BitmapFactory.decodeResource(getResources(), R.drawable.point))
        mBind.btnStartAnimation.clickNoRepeat {
            val animator = ValueAnimator.ofInt(0, 95)
            animator.setDuration(2000)
            animator.interpolator = LinearInterpolator()
            animator.addUpdateListener { animation ->
                val value = animation.animatedValue as Int
                mBind.circularProgressBar.setProgress(value)
                mBind.circularProgressBar2.progress = value
                mBind.circularProgressBar3.progress = value
                mBind.circularProgressBar4.progress = value
            }

            animator.start()
        }
    }

7. 最后


本篇文章由网友 Fas 的评论拓展而来,相比于之前那一篇还是稍稍有点难度的,哈哈。

源码及更多自定义View 已上传GithubDiyView

另外给喜欢记笔记的同学安利一款好用的云笔记软件,对比大部分国内的这个算还不错的,免费好用:wolai

相关推荐
美狐美颜SDK开放平台1 分钟前
多场景美颜SDK解决方案:直播APP(iOS/安卓)开发接入详解
android·人工智能·ios·音视频·美颜sdk·第三方美颜sdk·短视频美颜sdk
嗷o嗷o39 分钟前
Android BLE 里,MTU、分包和长数据发送到底该怎么处理
android
Gary Studio2 小时前
Android AIDL HAL工程结构示例
android
y = xⁿ3 小时前
MySQL八股知识合集
android·mysql·adb
andr_gale3 小时前
04_rc文件语法规则
android·framework·aosp
祖国的好青年4 小时前
VS Code 搭建 React Native 开发环境(Windows 实战指南)
android·windows·react native·react.js
黄林晴5 小时前
警惕!AGP 9.2 别只改版本号,R8 规则与构建链路全线收紧
android·gradle
小米渣的逆袭5 小时前
Android ADB 完全使用指南
android·adb
儿歌八万首5 小时前
Jetpack Compose Canvas 进阶:结合 animateFloatAsState 让自定义图形动起来
android·动画·compose