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;</code></pre>
首先我們來看看這個自定義控件具有哪些屬性。原作者大概將屬性分為五部分。第一部分就是根據實際情況使用的“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 方法中計算得出的 mValueOffset 、 mHintOffset 以及 mUnitOffset 就是為了確定各個 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 方法將進度條刷新到此方法的參數值。同時使用屬性動畫使進度條的當前進度刷新到新數值時會有一個動畫效果。同時屬性動畫設置一個監聽器,當屬性動畫的值在變化時就會回調 invalidate() 方法去重繪控件。這樣動畫的效果就顯示出來了!
至此相關重要代碼我就解釋完畢。希望初學自定義控件的朋友會有所收獲!
最后
本項目其實還有兩個圓形進度條的變種。如下圖所示。這三個圓形進度條的差異主要是繪制區域和繪制操作,我后面有時間會再細講其余圓形進度條,特別是第三個的波浪形的圓形進度條。這個波浪形的圓形進度條的難點主要是 繪制區域的計算 和 波浪效果 的實現。

其他圓形進度條
來自:https://juejin.im/post/59004e8ca0bb9f0065dac390