Android 自定义圆形进度条源码解析
piperjg40
7年前
<h2>效果展示</h2> <p style="text-align:center"><img src="https://simg.open-open.com/show/06c90b2be953f0f6c7d7c920da0f4671.gif"></p> <p style="text-align:center">效果展示</p> <p>这就是圆形进度条,可以实现仿 QQ 健康计步器的效果,支持配置进度条背景色、宽度、起始角度、支持进度条渐变。</p> <h2>源码解析</h2> <p>自定义控件的源代码是 CircleProgress.java,其还有一个工具类 MiscUtil.java</p> <pre> <code class="language-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;</code></pre> <p>首先我们来看看这个自定义控件具有哪些属性。原作者大概将属性分为五部分。第一部分就是根据实际情况使用的“Hint”部分,就是进度条中数值上方的文字。第二部分就是进度条的数值本身了。第三部分也就是跟第一部分搭配使用的单位部分。第四部分是根据数值主动移动的圆弧部分。第五部分就是与主动圆弧部分互补的被动圆弧部分。这里重点指出几个比较重要的属性: <strong>mXXXOffset</strong> 表示的是各文字部分绘制时的偏移量; <strong>mPrecision</strong> 是数值部分的精确度,比如精确到小数点后几位; <strong>mPrecisionFormat</strong> 就是数值部分绘制的格式控制符; <strong>mTextOffsetPercentInRadius</strong> 就是控制“Hint”部分和单位部分文字绘制的偏移比例。而 <strong>mPercent</strong> 是记录当前的进度值。</p> <p>原作者将控件的测量方法进行了封装,如下所示</p> <p>MiscUtil.java</p> <pre> <code class="language-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; }</code></pre> <p>我们可以看见当我们指定控件的大小为具体数值时(MATCH_PARENT也是具体数值),他会使用具体数值。而当我们指定控件大小为 <strong>WRAP_CONTENT</strong> 时就会比较 MeasureSpec 测量得到的数值和指定的默认值,取其小者。</p> <p>CircleProgress.java</p> <pre> <code class="language-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; }</code></pre> <p>我们再来看看 onSizeChanged 方法。在这个方法里我们主要计算这个控件中最为重要的几个数值,这些数值是决定最后的绘图效果的。首先会比较主动圆弧部分的宽度和被动圆弧部分的宽度,取其大者,以统一两部分的圆弧宽度。其实我觉得这两个属性以及比较的步骤有点多余,本来一开始的设计思路就是指定一个属性值来控制圆弧的宽度就好。因为控件在 onMeasure 方法测量得到的宽高可能不是相同的,这样我们就需要比较宽高分别减去内边距以及两倍的圆弧宽度的大小,取其小作为圆弧的直径。同时根据控件大小获取中心点位置以及圆弧边界位置和大小。接下来就是获取绘制各个文字时 Baseline 的偏移量。而 getBaselineOffsetFromY 就是获取绘制文本时竖直方向上的偏移量。 getBaselineOffsetFromY 其实是使用 FontMetrics 这个类获取文字的整体高度。关于 FontMetrics 的详细介绍可以查看 用TextPaint来绘制文本 。而“Hint”部分和单位部分的偏移量还要加入 mTextOffsetPercentInRadius 偏移比例与 mRadius 圆弧半径的乘积。同时在 updateArcPaint 方法中创建以 mCenterPoint 为中心的扫描渐变(SweepGradient)实例。为方便大家理解,我将主要数值绘制在图上制成示意图。</p> <p style="text-align:center"><img src="https://simg.open-open.com/show/1fc2d5217e1b937639715ceb15581b24.png"></p> <p style="text-align:center">圆形进度条绘制示意图</p> <p>CircleProgress.java</p> <pre> <code class="language-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(); }</code></pre> <p>获取各种绘制所需的数据之后就是进入绘制阶段了。在绘制文本时,大家可以将我注释掉的验证代码恢复,这样就可以看见绘制不同文本时的各个 Baseline ,在 onSizeChanged 方法中计算得出的 mValueOffset 、 mHintOffset 以及 mUnitOffset 就是为了确定各个 Baseline 的位置。同时绘制数值时需要格式控制来控制最后显示效果。各个 Baseline 的位置如下图所示</p> <p style="text-align:center"><img src="https://simg.open-open.com/show/a99351e557994c0323555339cb381db0.jpg"></p> <p style="text-align:center">进度条各Baseline示意图</p> <p>绘制完文本部分之后最后就是绘制圆弧部分了。查看上面的源代码你会发现坐标轴沿中心点转动,以第一个 CircleProgress 为例,坐标轴沿中线点顺时针转动135°后再开始绘制圆弧部分。绘制圆弧部分会首先根据进度的数值计算主动圆弧部分的角度 currentAngle,再用 sweepAngle 270°减去计算得出的 currentAngle。分别绘制两个圆弧部分。下面就是示意图,此时蓝色部分就是 currentAngle主动圆弧,黄色部分就是被动圆弧。</p> <p style="text-align:center"><img src="https://simg.open-open.com/show/a3c266adc760492a88c46c06d41ded95.jpg"></p> <p style="text-align:center">圆弧绘制示意图</p> <p>CircleProgress.java</p> <pre> <code class="language-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(); }</code></pre> <p>绘制完图后,就是如何刷新控件了。阅读上面有关源代码。我们可以知道原作者设置了一个 setValue 方法将进度条刷新到此方法的参数值。同时使用属性动画使进度条的当前进度刷新到新数值时会有一个动画效果。同时属性动画设置一个监听器,当属性动画的值在变化时就会回调 invalidate() 方法去重绘控件。这样动画的效果就显示出来了!</p> <p>至此相关重要代码我就解释完毕。希望初学自定义控件的朋友会有所收获!</p> <h2>最后</h2> <p>本项目其实还有两个圆形进度条的变种。如下图所示。这三个圆形进度条的差异主要是绘制区域和绘制操作,我后面有时间会再细讲其余圆形进度条,特别是第三个的波浪形的圆形进度条。这个波浪形的圆形进度条的难点主要是 <strong> <em>绘制区域的计算</em> </strong> 和 <strong> <em>波浪效果</em> </strong> 的实现。</p> <p><img src="https://simg.open-open.com/show/0d192b98423803876cebb7b1ff30bc53.png"></p> <p style="text-align:center">其他圆形进度条</p> <h2> </h2> <p>来自:https://juejin.im/post/59004e8ca0bb9f0065dac390</p> <p> </p>