Android自定义圆形进度条源码解析

3,394 阅读9分钟

  引言:首先本博文开始之前,得跟各位关注我的朋友们说声对不起。自从上篇博文的更新至今已有近一个月的时间没有更新了。那这一个月我都死去哪了?哈哈,我就不告诉你!其实这一个月也没什么,主要是参加了阿里,腾讯和金山的暑期实习生面试。然后发现了即使我报的是 Android 客户端的实习生,但一般大公司都基本考你计算机专业基础知识,而且考的很广泛。例如他会涉及到计算机网络、算法与数据结构、操作系统以及编程语言等知识的考查。这无疑是考到了我的痛点。所以我就决心先缓一缓 Android 的学习,特别先恶补数据结构与计算机网络的知识。买了教材,于是就进入了宿舍到图书馆两点一线的生活。同时在图书馆里,自己的生活悄悄发生了些许变化。但这种变化还不知是好是坏。现在本人也是坐在图书馆写的这篇博文,说实话,我好像爱上了图书馆!至于本篇博文,是因为寒假的时候完整的学习过 Android 自定义控件的知识。现在就用自己的话语一点点解析网络上优秀的自定义控件的案例。算是温故而知新吧!好,拉完家常,继续正事吧。


效果展示

效果展示
效果展示

  这就是圆形进度条,可以实现仿 QQ 健康计步器的效果,支持配置进度条背景色、宽度、起始角度、支持进度条渐变。

源码解析

自定义控件的源代码是 CircleProgress.java,其还有一个工具类 MiscUtil.java

    //默认大小
    private int mDefaultSize;
    //是否开启抗锯齿
    private boolean antiAlias;
    //绘制提示
    private TextPaint mHintPaint;
    private CharSequence mHint;
    private int mHintColor;
    private float mHintSize;
    private float mHintOffset;

    //绘制单位
    private TextPaint mUnitPaint;
    private CharSequence mUnit;
    private int mUnitColor;
    private float mUnitSize;
    private float mUnitOffset;

    //绘制数值
    private TextPaint mValuePaint;
    private float mValue;
    private float mMaxValue;
    private float mValueOffset;
    private int mPrecision;
    private String mPrecisionFormat;
    private int mValueColor;
    private float mValueSize;

    //绘制圆弧,根据具体数值而进行主动移动的圆弧
    private Paint mArcPaint;
    private float mArcWidth;
    private float mStartAngle, mSweepAngle;
    private RectF mRectF;
    //渐变的颜色是360度,如果只显示270,那么则会缺失部分颜色
    private SweepGradient mSweepGradient;
    private int[] mGradientColors = {Color.GREEN, Color.YELLOW, Color.RED};
    //当前进度,[0.0f,1.0f]
    private float mPercent;
    //动画时间
    private long mAnimTime;
    //属性动画
    private ValueAnimator mAnimator;

    //绘制背景圆弧,根据主动移动圆弧部分的其他圆弧
    private Paint mBgArcPaint;
    private int mBgArcColor;
    private float mBgArcWidth;

    //圆心坐标,半径
    private Point mCenterPoint;
    private float mRadius;
    private float mTextOffsetPercentInRadius;

  首先我们来看看这个自定义控件具有哪些属性。原作者大概将属性分为五部分。第一部分就是根据实际情况使用的“Hint”部分,就是进度条中数值上方的文字。第二部分就是进度条的数值本身了。第三部分也就是跟第一部分搭配使用的单位部分。第四部分是根据数值主动移动的圆弧部分。第五部分就是与主动圆弧部分互补的被动圆弧部分。这里重点指出几个比较重要的属性:mXXXOffset表示的是各文字部分绘制时的偏移量;mPrecision是数值部分的精确度,比如精确到小数点后几位;mPrecisionFormat就是数值部分绘制的格式控制符;mTextOffsetPercentInRadius就是控制“Hint”部分和单位部分文字绘制的偏移比例。而mPercent是记录当前的进度值。

  原作者将控件的测量方法进行了封装,如下所示

MiscUtil.java

/**
 * 测量 View
 *
 * @param measureSpec
 * @param defaultSize View 的默认大小
 * @return
 */
 public static int measure(int measureSpec, int defaultSize) {
     int result = defaultSize;
     int specMode = View.MeasureSpec.getMode(measureSpec);
     int specSize = View.MeasureSpec.getSize(measureSpec);

     if (specMode == View.MeasureSpec.EXACTLY) {
          result = specSize;
     } else if (specMode == View.MeasureSpec.AT_MOST) {
          result = Math.min(result, specSize);
     }
     return result;
}

  我们可以看见当我们指定控件的大小为具体数值时(MATCH_PARENT也是具体数值),他会使用具体数值。而当我们指定控件大小为WRAP_CONTENT时就会比较 MeasureSpec 测量得到的数值和指定的默认值,取其小者。

CircleProgress.java

@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
    super.onSizeChanged(w, h, oldw, oldh);
    //求圆弧和背景圆弧的最大宽度
    float maxArcWidth = Math.max(mArcWidth, mBgArcWidth);
    //求最小值作为实际值
    int minSize = Math.min(w - getPaddingLeft() - getPaddingRight() - 2 * (int) maxArcWidth,
                h - getPaddingTop() - getPaddingBottom() - 2 * (int) maxArcWidth);
    //减去圆弧的宽度,否则会造成部分圆弧绘制在外围
    mRadius = minSize / 2;
    //获取圆的相关参数
    mCenterPoint.x = w / 2;
    mCenterPoint.y = h / 2;
    //绘制圆弧的边界
    mRectF.left = mCenterPoint.x - mRadius - maxArcWidth / 2;
    mRectF.top = mCenterPoint.y - mRadius - maxArcWidth / 2;
    mRectF.right = mCenterPoint.x + mRadius + maxArcWidth / 2;
    mRectF.bottom = mCenterPoint.y + mRadius + maxArcWidth / 2;
    //计算文字绘制时的 baseline
    //由于文字的baseline、descent、ascent等属性只与textSize和typeface有关,所以此时可以直接计算
    //若value、hint、unit由同一个画笔绘制或者需要动态设置文字的大小,则需要在每次更新后再次计算
    mValueOffset = mCenterPoint.y + getBaselineOffsetFromY(mValuePaint);
    mHintOffset = mCenterPoint.y - mRadius * mTextOffsetPercentInRadius + getBaselineOffsetFromY(mHintPaint);
    mUnitOffset = mCenterPoint.y + mRadius * mTextOffsetPercentInRadius + getBaselineOffsetFromY(mUnitPaint);
    updateArcPaint();
 }

private float getBaselineOffsetFromY(Paint paint) {
    return MiscUtil.measureTextHeight(paint) / 2;
}

  我们再来看看onSizeChanged方法。在这个方法里我们主要计算这个控件中最为重要的几个数值,这些数值是决定最后的绘图效果的。首先会比较主动圆弧部分的宽度和被动圆弧部分的宽度,取其大者,以统一两部分的圆弧宽度。其实我觉得这两个属性以及比较的步骤有点多余,本来一开始的设计思路就是指定一个属性值来控制圆弧的宽度就好。因为控件在onMeasure方法测量得到的宽高可能不是相同的,这样我们就需要比较宽高分别减去内边距以及两倍的圆弧宽度的大小,取其小作为圆弧的直径。同时根据控件大小获取中心点位置以及圆弧边界位置和大小。接下来就是获取绘制各个文字时 Baseline 的偏移量。而 getBaselineOffsetFromY就是获取绘制文本时竖直方向上的偏移量。getBaselineOffsetFromY其实是使用 FontMetrics 这个类获取文字的整体高度。关于 FontMetrics 的详细介绍可以查看用TextPaint来绘制文本。而“Hint”部分和单位部分的偏移量还要加入mTextOffsetPercentInRadius偏移比例与mRadius圆弧半径的乘积。同时在updateArcPaint方法中创建以 mCenterPoint 为中心的扫描渐变(SweepGradient)实例。为方便大家理解,我将主要数值绘制在图上制成示意图。

圆形进度条绘制示意图
圆形进度条绘制示意图

CircleProgress.java

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        /**
         * 这段为测试代码
         */

//        Paint tempPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
//        tempPaint.setStrokeWidth(5);
//        tempPaint.setStyle(Paint.Style.FILL);
//        tempPaint.setColor(Color.RED);
//        canvas.drawLine(0, mCenterPoint.y, getWidth(), mCenterPoint.y, tempPaint);
//        canvas.drawLine(0, mValueOffset, getWidth(), mValueOffset, tempPaint);
//        canvas.drawLine(0, mHintOffset, getWidth(), mHintOffset, tempPaint);
//        canvas.drawLine(0, mUnitOffset, getWidth(), mUnitOffset, tempPaint);

        drawText(canvas);
        drawArc(canvas);

//        Paint tempPaint2 = new Paint(Paint.ANTI_ALIAS_FLAG);
//        tempPaint2.setColor(Color.BLACK);
//        tempPaint2.setStyle(Paint.Style.STROKE);
//        float maxArcWidth = Math.max(mArcWidth, mBgArcWidth);
//        canvas.drawRect(mRectF, tempPaint2);
//        canvas.drawCircle(mCenterPoint.x, mCenterPoint.y, mRadius + maxArcWidth / 2, tempPaint2);
    }

    /**
     * 绘制内容文字
     *
     * @param canvas
     */
    private void drawText(Canvas canvas) {
        // 计算文字宽度,由于Paint已设置为居中绘制,故此处不需要重新计算
        // float textWidth = mValuePaint.measureText(mValue.toString());
        // float x = mCenterPoint.x - textWidth / 2;
        canvas.drawText(String.format(mPrecisionFormat, mValue), mCenterPoint.x, mValueOffset, mValuePaint);

        if (mHint != null) {
            canvas.drawText(mHint.toString(), mCenterPoint.x, mHintOffset, mHintPaint);
        }

        if (mUnit != null) {
            canvas.drawText(mUnit.toString(), mCenterPoint.x, mUnitOffset, mUnitPaint);
        }
    }

    private void drawArc(Canvas canvas) {
        // 绘制背景圆弧
        // 从进度圆弧结束的地方开始重新绘制,优化性能
        canvas.save();
        float currentAngle = mSweepAngle * mPercent;
        canvas.rotate(mStartAngle, mCenterPoint.x, mCenterPoint.y);
        canvas.drawArc(mRectF, currentAngle, mSweepAngle - currentAngle + 2, false, mBgArcPaint);
        // 第一个参数 oval 为 RectF 类型,即圆弧显示区域
        // startAngle 和 sweepAngle  均为 float 类型,分别表示圆弧起始角度和圆弧度数
        // 3点钟方向为0度,顺时针递增
        // 如果 startAngle < 0 或者 > 360,则相当于 startAngle % 360
        // useCenter:如果为True时,在绘制圆弧时将圆心包括在内,通常用来绘制扇形
        canvas.drawArc(mRectF, 2, currentAngle, false, mArcPaint);
        canvas.restore();
    }

  获取各种绘制所需的数据之后就是进入绘制阶段了。在绘制文本时,大家可以将我注释掉的验证代码恢复,这样就可以看见绘制不同文本时的各个 Baseline ,在onSizeChanged方法中计算得出的mValueOffsetmHintOffset以及mUnitOffset就是为了确定各个 Baseline 的位置。同时绘制数值时需要格式控制来控制最后显示效果。各个 Baseline 的位置如下图所示

进度条各Baseline示意图
进度条各Baseline示意图

  绘制完文本部分之后最后就是绘制圆弧部分了。查看上面的源代码你会发现坐标轴沿中心点转动,以第一个 CircleProgress 为例,坐标轴沿中线点顺时针转动135°后再开始绘制圆弧部分。绘制圆弧部分会首先根据进度的数值计算主动圆弧部分的角度 currentAngle,再用 sweepAngle 270°减去计算得出的 currentAngle。分别绘制两个圆弧部分。下面就是示意图,此时蓝色部分就是 currentAngle主动圆弧,黄色部分就是被动圆弧。

圆弧绘制示意图
圆弧绘制示意图

CircleProgress.java

    /**
     * 设置当前值
     *
     * @param value
     */
    public void setValue(float value) {
        if (value > mMaxValue) {
            value = mMaxValue;
        }
        float start = mPercent;
        Log.d(TAG, "setValue: "+mPercent);
        float end = value / mMaxValue;
        startAnimator(start, end, mAnimTime);
    }

    private void startAnimator(float start, float end, long animTime) {
        mAnimator = ValueAnimator.ofFloat(start, end);
        mAnimator.setDuration(animTime);
        mAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
            @Override
            public void onAnimationUpdate(ValueAnimator animation) {
                mPercent = (float) animation.getAnimatedValue();
                Log.d(TAG, "onAnimationUpdate: "+mPercent);
                mValue = mPercent * mMaxValue;
                if (BuildConfig.DEBUG) {
                    Log.d(TAG, "onAnimationUpdate: percent = " + mPercent
                            + ";currentAngle = " + (mSweepAngle * mPercent)
                            + ";value = " + mValue);
                }
                invalidate();
            }
        });
        mAnimator.start();
    }

  绘制完图后,就是如何刷新控件了。阅读上面有关源代码。我们可以知道原作者设置了一个setValue方法将进度条刷新到此方法的参数值。同时使用属性动画使进度条的当前进度刷新到新数值时会有一个动画效果。具体原理可以参见Android属性动画完全解析(上),初识属性动画的基本用法。同时属性动画设置一个监听器,当属性动画的值在变化时就会回调invalidate()方法去重绘控件。这样动画的效果就显示出来了!

  至此相关重要代码我就解释完毕。希望初学自定义控件的朋友会有所收获!

最后

  本项目其实还有两个圆形进度条的变种。如下图所示。这三个圆形进度条的差异主要是绘制区域和绘制操作,我后面有时间会再细讲其余圆形进度条,特别是第三个的波浪形的圆形进度条。这个波浪形的圆形进度条的难点主要是绘制区域的计算波浪效果的实现。

其他圆形进度条
其他圆形进度条

参考

  感谢 littlejie 提供的项目供各位初学者学习。欢迎大家去 Star 和 Fork CircleProgress

最后是广告时间,我的博文将同步更新在三大平台上,欢迎大家点击阅读!谢谢

刘志宇的新天地

简书

稀土掘金