Android自定義圓形進度條原始碼解析

六隻魚發表於2017-04-26

  引言:首先本博文開始之前,得跟各位關注我的朋友們說聲對不起。自從上篇博文的更新至今已有近一個月的時間沒有更新了。那這一個月我都死去哪了?哈哈,我就不告訴你!其實這一個月也沒什麼,主要是參加了阿里,騰訊和金山的暑期實習生面試。然後發現了即使我報的是 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

最後是廣告時間,我的博文將同步更新在三大平臺上,歡迎大家點選閱讀!謝謝

劉志宇的新天地

簡書

稀土掘金

相關文章