本文要介紹的是一個(gè)參照手機(jī)支付寶app里面記賬本功能里的“餅狀圖”實(shí)現(xiàn)的控件。通常app中可能的數(shù)據(jù)展示控件有柱狀圖,折線圖,餅狀圖等,如果需要一個(gè)包含多種View控件的庫(kù),那么 MPAndroidChart 是不錯(cuò)的選擇,如果只是需要一個(gè)簡(jiǎn)單的獨(dú)立的餅狀圖控件,希望PieGraphView滿足你的要求。

控件介紹

效果圖如下:

目前實(shí)現(xiàn)的餅狀圖的效果如下所示,和支付寶app記賬本中的功能基本一樣:


Android培訓(xùn),安卓培訓(xùn),手機(jī)開發(fā)培訓(xùn),移動(dòng)開發(fā)培訓(xùn),云培訓(xùn)培訓(xùn)


控件功能:

  • 展示的數(shù)據(jù)
    可以展示多組數(shù)據(jù)(ItemGroup),每次展示一組數(shù)據(jù),一組數(shù)據(jù)對(duì)應(yīng)形成一個(gè)圓環(huán)。一組數(shù)據(jù)由多個(gè)Item組成,對(duì)應(yīng)圓環(huán)中的扇形。

public static class ItemGroup {     public String id;     public Item[] items;
 } public static class Item {     public double value;     public int color;     public String id;
 }
  • 圓環(huán)
    一個(gè)ItemGroup最終顯示為一個(gè)圓環(huán)。它的中的items是包含的數(shù)據(jù)項(xiàng)。這些數(shù)據(jù)項(xiàng)根據(jù)其value占總數(shù)據(jù)的比例對(duì)應(yīng)不同的扇形角度。ItemGroup的所有Item依次繪制,形成360°。

  • 起始角度和旋轉(zhuǎn)
    所有角度值是X正軸開始順時(shí)針增加。圓環(huán)有一個(gè)開始角度使用字段mStartAngle表示,所有扇形的繪制是從mStartAngle開始的,它是0-360度的數(shù)值,例如可以設(shè)置為90讓繪制從正下方開始等。圓環(huán)可以旋轉(zhuǎn),旋轉(zhuǎn)是針對(duì)mStartAngle而言的。

  • 選中并高亮Item
    點(diǎn)擊可以選擇一個(gè)扇形,選中的扇形作為“當(dāng)前項(xiàng)”,使用字段int mCurrentItem記錄它的索引。選擇一個(gè)扇形后,它會(huì)旋轉(zhuǎn)其中間角度到mStartAngle的角度,然后對(duì)應(yīng)扇形執(zhí)行“grow”動(dòng)畫進(jìn)行高亮突出。

  • 切換ItemGroup
    點(diǎn)擊圓環(huán)內(nèi)部可以切換顯示不同的ItemGroup。切換會(huì)有一個(gè)動(dòng)畫,先是順時(shí)針從mStartAngle繪制整個(gè)圓環(huán)。之后在自動(dòng)選中最后一個(gè)Item。

實(shí)現(xiàn)過程

圓環(huán)的基本繪制

圓環(huán)的繪制實(shí)際就是通過先后繪制兩個(gè)半徑不同的圓實(shí)現(xiàn),圓就是360度的扇形,canvas.drawArc提供了這個(gè)功能:

public void drawArc(@NonNull RectF oval, float startAngle, float sweepAngle, boolean useCenter,            @NonNull Paint paint)

需要先繪制有顏色的外圓對(duì)應(yīng)的各個(gè)扇形,之后再“覆蓋”繪制內(nèi)圓對(duì)應(yīng)的各個(gè)扇形。

繪制圓環(huán)的時(shí)候需要考慮開始角度mStartAngle和當(dāng)前的旋轉(zhuǎn)mRotation。這里設(shè)計(jì)了一個(gè)方法drawPieFromEnd用來(lái)在(start, end)的角度范圍內(nèi)繪制“被顯示”的那些扇形。這里的角度是扇形數(shù)組的形成的0-360的連續(xù)角度范圍。

為了繪制的簡(jiǎn)單,方法選擇從最后一個(gè)扇形開始繪制,相當(dāng)于從end繪制到start,這樣的好處是不用去計(jì)算實(shí)際上start對(duì)應(yīng)的是哪個(gè)扇形了,而根據(jù)傳遞的角度范圍,當(dāng)下一個(gè)繪制的扇形的起始角度大于start時(shí),結(jié)束繪制:

/** * 從尾部開始繪制圓環(huán),只繪制endAngle到startAngle之間的,不一定繪制所有圓環(huán)。 * * @param canvas * @param startAngle * @param endAngle */private void drawPieFromEnd(Canvas canvas, float startAngle, float endAngle) {    if (angles == null) return;    for (int i = angles.length - 1; i >= 0; i--) {        float itemAngle = angles[i] + 0.5f;        float sweepStart = endAngle - itemAngle;
        mPaintOuter.setColor(colors[i]);        float radius = mSmallOval.width() / 2f + mRingWidth / 2f;        if (sweepStart >= startAngle) {
            canvas.drawArc(mBigOval, sweepStart, itemAngle, true, mPaintOuter);            int middleAngle = (int) (sweepStart + itemAngle / 2);            calcAngleMiddleInRing(middleAngle, radius, mItemCenter);            drawItemCenterIcon(canvas, middleAngle, colors[i], mItemCenter);
        } else {
            itemAngle = endAngle - startAngle;            int middleAngle = (int) (startAngle + itemAngle / 2);
            canvas.drawArc(mBigOval, startAngle, itemAngle, true, mPaintOuter);            calcAngleMiddleInRing(middleAngle, radius, mItemCenter);            drawItemCenterIcon(canvas, middleAngle , colors[i], mItemCenter);            break;
        }
        endAngle -= itemAngle;
    }
}

動(dòng)畫

當(dāng)前控件交互過程中總共有三個(gè)動(dòng)畫:

  • showOut
    每個(gè)ItemGroup顯示時(shí)執(zhí)行切換動(dòng)畫。

  • rotate
    旋轉(zhuǎn)動(dòng)畫,被選中的Item會(huì)旋轉(zhuǎn)其中心角度到mStartAngle。

  • grow
    被選中的扇形旋轉(zhuǎn)結(jié)束后,或者再次點(diǎn)擊當(dāng)前已選扇形,就對(duì)它執(zhí)行一次grow動(dòng)畫,使得扇形高亮突出。

所有動(dòng)畫通過Animation實(shí)現(xiàn),這里只是使用Animation完成動(dòng)畫時(shí)間和進(jìn)度的控制。
重寫applyTransformation方法來(lái)記錄當(dāng)前動(dòng)畫的進(jìn)度progress,然后invalidate通知onDraw的執(zhí)行。
開始動(dòng)畫執(zhí)行時(shí)將當(dāng)前動(dòng)畫模式字段int mAnimMode設(shè)置為不同的ANIM_MODE_xxx常量,然后onDraw中會(huì)根據(jù)當(dāng)前的mAnimMode值,選擇對(duì)應(yīng)動(dòng)畫的繪制方法去執(zhí)行。

代碼結(jié)構(gòu)如下:

public class PieGraphView extends View {  private static final int ANIM_MODE_NONE = 0;  private static final int ANIM_MODE_ROTATE = 1;
  ...  private void initAnims() {
    mAnimRotate = new Animation() {        @Override
        protected void applyTransformation(float interpolatedTime, Transformation t) {
            mRotateAnimProgress = interpolatedTime;            // 旋轉(zhuǎn)操作可以通過改變開始繪制的角度,也可以旋轉(zhuǎn)整個(gè)View
            // 設(shè)置旋轉(zhuǎn)角度后會(huì)使得可點(diǎn)擊區(qū)域不再是沿著水平/豎直方向的正方形,所以不采用
            invalidate();            if (interpolatedTime >= 1.0f) {                cancel();                // mAnimMode = ANIM_MODE_NONE;
                setRotation(mRotation + mRotateDelta);
                mRotateDelta = 0;                post(new Runnable() {                    @Override
                    public void run() {                        growItem(mCurrentItem);
                    }
                });
            }
        }
    };
    ...
  }

  ...  @Override
  protected void onDraw(Canvas canvas) {    switch (mAnimMode) {           case ANIM_MODE_ROTATE:               drawRotatedPie(canvas);
               canvas.drawArc(mSmallOval, 0, 360, true, mPaintInner);               break;           case ANIM_MODE_SHOW_OUT:
           ...
  }  private void runAnimRotate() {
      mAnimMode = ANIM_MODE_ROTATE;      clearAnimation();
      mAnimRotate.cancel();      startAnimation(mAnimRotate);
  }
}

initAnims()方法中對(duì)動(dòng)畫進(jìn)行初始化。執(zhí)行runAnimRotate()來(lái)開啟動(dòng)畫。onDraw方法中根據(jù)動(dòng)畫模式選擇執(zhí)行不同的繪制方法。
三個(gè)動(dòng)畫都是這樣的設(shè)計(jì)思路。

旋轉(zhuǎn)

mStartAngle和mRotation兩個(gè)字段的值決定了繪制圓環(huán)的起始角度。這里旋轉(zhuǎn)的方式不能是執(zhí)行View.setRotation()方法,因?yàn)闀?huì)旋轉(zhuǎn)整個(gè)View的區(qū)域——View的坐標(biāo)跟著旋轉(zhuǎn)?。?!使得之后點(diǎn)擊事件的處理會(huì)比較麻煩。
旋轉(zhuǎn)每次只需要計(jì)算“要旋轉(zhuǎn)到的目標(biāo)角度”和“當(dāng)前已旋轉(zhuǎn)的角度”的差值int mRotateDelta,然后執(zhí)行旋轉(zhuǎn)動(dòng)畫,不斷修改mRotation值執(zhí)行onDraw即可:

/** * 讓整個(gè)圓旋轉(zhuǎn)到targetDegree的角度,旋轉(zhuǎn)是相對(duì)mStartAngle開始繪制的圓而言 * * @param targetDegree 應(yīng)該介于0-360,是從第一個(gè)扇形片段作為0度算出來(lái)的角度,不是從X正軸開始的角度 * @param smartRotate  是否抄近路旋轉(zhuǎn)? */private void rotateToDegree(float targetDegree, boolean smartRotate) {    // 使得 targetDegree 介于0-360
    targetDegree = (targetDegree + 360) % 360;    int targetRotate = (int) -targetDegree;

    mRotateDelta = targetRotate - mRotation;
    mRotateDelta = mRotateDelta % 360;    if (smartRotate) {        // 將旋轉(zhuǎn)控制在180度內(nèi)
        if (mRotateDelta > 180) {
            mRotateDelta = mRotateDelta - 360;
        } else if (mRotateDelta < -180) {
            mRotateDelta = 360 + mRotateDelta;
        }
    }    runAnimRotate();
}

上面旋轉(zhuǎn)角度控制在(-360, 360),和扇形相關(guān)的角度控制在(0, 360)。

突出顯示扇形

選擇的扇形記錄其對(duì)應(yīng)Item的索引int mCurrentItem,只有在沒有任何動(dòng)畫執(zhí)行時(shí),或者是正在執(zhí)行g(shù)row動(dòng)畫時(shí)才會(huì)對(duì)當(dāng)前選擇的扇形進(jìn)行突出顯示。
繪制的思路是改變要突出的扇形角度對(duì)應(yīng)的扇形的外圓、內(nèi)圓的區(qū)域大?。╠rawArc中的oval參數(shù)),也就是修改drawArc方法需要的橢圓的矩形區(qū)域:

private void drawGrownPie(Canvas canvas) {    if (angles == null) return;    final float rotatedStart = this.mStartAngle + mRotation;    float rotatedEnd = rotatedStart + 360f;    float currentItemStart = 0f, currentItemSweep = 360f;    for (int i = angles.length - 1; i >= 0; i--) {        float itemAngle = angles[i] + 0.5f;        float sweepStart = rotatedEnd - itemAngle;        float sweep = itemAngle;

        mPaintOuter.setColor(colors[i]);
        RectF oval = mBigOval;        if (sweepStart < rotatedStart) {
            sweepStart = rotatedStart;
            sweep = rotatedEnd - rotatedStart;
        }        if (mGrownItem == i) {
            sweepStart += mGrownPieGap;
            sweep -= 2 * mGrownPieGap;            float padding = mGrownWidth * (1f - mGrowProgress);
            mGrownOval.set(mCanvasRect);
            mGrownOval.inset(padding, padding);
            oval = mGrownOval;

            currentItemStart = sweepStart;
            currentItemSweep = sweep;
        }        // 繪制扇形圓環(huán)
        canvas.drawArc(oval, sweepStart, sweep, true, mPaintOuter);        // 繪制圓環(huán)上扇形的中心“點(diǎn)”
        int middleAngle = (int) (sweepStart + sweep / 2);        float radius = (mSmallOval.width() + mRingWidth) / 2f;        if (mGrownItem == i && mGrowMode == GROW_MODE_MOVE_OUT) {
            radius += mGrowProgress * mGrownWidth;
        }        calcAngleMiddleInRing(middleAngle, radius, mItemCenter);        drawItemCenterIcon(canvas, middleAngle, colors[i], mItemCenter);        if (sweepStart < rotatedStart) break;
        rotatedEnd -= itemAngle;
    }    // 繪制內(nèi)圓,分當(dāng)前扇形和非當(dāng)前扇形兩部分
    mGrownOval.set(mSmallOval);    float grownRadius = mGrownWidth * mGrowProgress;    float otherStart = currentItemStart + currentItemSweep;    float otherSweep = 360f - currentItemSweep;    if (mGrowMode == GROW_MODE_MOVE_OUT) {        // 小圓轉(zhuǎn)一圈,消掉可能的縫隙
        otherStart = 0f;
        otherSweep = 360f;
        mGrownOval.inset(-grownRadius, -grownRadius);
    } else if (mGrowMode == GROW_MODE_BOLD) {
        mGrownOval.inset(grownRadius, grownRadius);        // 小圓轉(zhuǎn)一圈,消掉可能的縫隙
        currentItemStart = 0f;
        currentItemSweep = 360f;
    }

    canvas.drawArc(mGrownOval, currentItemStart, currentItemSweep, true, mPaintInner);
    canvas.drawArc(mSmallOval, otherStart, otherSweep, true, mPaintInner);
}

上面繪制的順序是:

  1. 繪制所有扇形的外圓扇形,當(dāng)前項(xiàng)的半徑會(huì)不同。

  2. 繪制對(duì)應(yīng)當(dāng)前扇形角度的內(nèi)圓的扇形。

  3. 繪制除去當(dāng)前扇形角度的其余角度的內(nèi)圓的扇形。

grow動(dòng)畫又分為加粗(GROW_MODE_BOLD)和向外移動(dòng)(GROW_MODE_MOVE_OUT)兩個(gè)動(dòng)畫,不同動(dòng)畫時(shí)內(nèi)圓扇形的半徑不同,上面因?yàn)閒loat值得原因扇形可能會(huì)有縫隙,為了消除這個(gè)縫隙,最終在繪制的時(shí)候會(huì)讓“當(dāng)前扇形的繪制”或者“剩余圓環(huán)部分”的繪制直接是繪制360度,因?yàn)樽罱K的扇形的確存在包含關(guān)系。

點(diǎn)擊事件

重寫onTouchEvent方法,根據(jù)ACTION_DOWN時(shí)的(x, y)來(lái)確定點(diǎn)擊區(qū)域是發(fā)生在圓環(huán)內(nèi)部、圓環(huán)上、還是圓環(huán)外。之后會(huì)執(zhí)行不同的處理。

@Overridepublic boolean onTouchEvent(MotionEvent event) {    if (event.getAction() == MotionEvent.ACTION_DOWN && mAnimMode == ANIM_MODE_NONE) {        int item = calcClickItem(event.getX(), event.getY());        if (item >= 0 && item < angles.length) {            setCurrentItem(item, true);
        }        return true;
    }    return super.onTouchEvent(event);
}

只有在動(dòng)畫未執(zhí)行時(shí)處理點(diǎn)擊事件。這里只是簡(jiǎn)單的監(jiān)聽手指按下的動(dòng)作,如果為了“更自然”的監(jiān)聽,可以在ACTION_UP中根據(jù)前后的坐標(biāo)變動(dòng)來(lái)選擇是否判定為對(duì)餅狀圖的有效點(diǎn)擊。也可以結(jié)合OnClickListener處理“click”事件??傊P(guān)鍵是獲得點(diǎn)擊的(x, y)坐標(biāo)。

方法calcClickItem完成了點(diǎn)擊事件的不同處理:如果點(diǎn)擊發(fā)生在內(nèi)圓就切換顯示的ItemGroup,點(diǎn)擊發(fā)生在圓環(huán)外不處理。點(diǎn)擊圓環(huán)上某個(gè)扇形后,就設(shè)置扇形對(duì)應(yīng)的Item為“當(dāng)前項(xiàng)”,對(duì)應(yīng)扇形會(huì)被旋轉(zhuǎn)到mStartAngle的位置,旋轉(zhuǎn)后執(zhí)行g(shù)row動(dòng)畫進(jìn)行突出顯示。

private int calcClickItem(float x, float y) {    if (angles == null) return -1;    final float outerRadius = mBigOval.width() / 2;    final float innerRadius = mSmallOval.width() / 2;    float centerX = mBigOval.centerX();    float centerY = mBigOval.centerY();    double clickRadius = Math.sqrt((x - centerX) * (x - centerX) + (y - centerY) * (y - centerY));    if (clickRadius < innerRadius) {        // 點(diǎn)擊發(fā)生在小圓內(nèi)部,也就是點(diǎn)擊到標(biāo)題區(qū)域
        onTitleRegionClicked();        return -1;
    } else if (clickRadius > outerRadius) {        // 點(diǎn)擊發(fā)生在大圓環(huán)外
        return -2;
    }    // 計(jì)算點(diǎn)擊的坐標(biāo)(x, y)和圓中心點(diǎn)形成的角度,角度從0-360,順時(shí)針增加
    int clickedDegree = GeomTool.calcAngle(x, y, centerX, centerY);    // 計(jì)算出來(lái)的clickedDegree是整個(gè)View原始的,被點(diǎn)擊item需要考慮startAngle。
    int startAngle = mStartAngle + mRotation;    int angleStart = startAngle;    for (int i = 0; i < angles.length; i++) {        int itemStart = (angleStart + 360) % 360;        float end = itemStart + angles[i];        if (end >= 360f) {            if (clickedDegree >= itemStart && clickedDegree < 360) return i;            if (clickedDegree >= 0 && clickedDegree < (end - 360)) return i;
        } else {            if (clickedDegree >= itemStart && clickedDegree < end) {                return i;
            }
        }

        angleStart += angles[i];
    }    return -3;
}

計(jì)算點(diǎn)擊的角度

根據(jù)點(diǎn)擊的坐標(biāo)(x, y)和圓心(centerX, centerY)可以計(jì)算出點(diǎn)擊的點(diǎn)相對(duì)圓心的角度。下面方法calcAngle完成此任務(wù)。

代碼如下:

/** * 計(jì)算坐標(biāo)(x1, y1)和(x2, y2)形成的角度,角度從0-360,順時(shí)針增加 * (x軸向右,y軸向下) */public static int calcAngle(float x1, float y1, float x2, float y2) {    double resultDegree = 0;    double vectorX = x1 - x2; // 點(diǎn)到圓心的X軸向量,X軸向右,向量為(0, vectorX)
    double vectorY = y2 - y1; // 點(diǎn)到圓心的Y軸向量,Y軸向上,向量為(0, vectorY)
    // 點(diǎn)落在X,Y軸的情況這里就排除
    if (vectorX == 0) {        // 點(diǎn)擊的點(diǎn)在Y軸上,Y不會(huì)為0的
        if (vectorY > 0) {
            resultDegree = 90;
        } else {
            resultDegree = 270;
        }
    } else if (vectorY == 0) {        // 點(diǎn)擊的點(diǎn)在X軸上,X不會(huì)為0的
        if (vectorX > 0) {
            resultDegree = 0;
        } else {
            resultDegree = 180;
        }
    } else {        // 根據(jù)形成的正切值算角度
        double tanXY = vectorY / vectorX;        double arc = Math.atan(tanXY);        // degree是正數(shù),相當(dāng)于正切在四個(gè)象限的角度的絕對(duì)值
        double degree = Math.abs(arc / Math.PI * 180);        // 將degree換算為對(duì)應(yīng)x正軸開始的0-360的角度
        if (vectorY < 0 && vectorX > 0) {            // 右下 0-90
            resultDegree = degree;
        } else if (vectorY < 0 && vectorX < 0) {            // 左下 90-180
            resultDegree = 180 - degree;
        } else if (vectorY > 0 && vectorX < 0) {            // 左上 180-270
            resultDegree = 180 + degree;
        } else {            // 右上 270-360
            resultDegree = 360 - degree;
        }
    }    return (int) resultDegree;
}

上面的方法calcClickItem根據(jù)此角度,結(jié)合當(dāng)前圓環(huán)的mStartAngle、mRotation就可以確定點(diǎn)擊落在的扇形區(qū)域了。

計(jì)算扇形中心

繪制扇形過程中,可以得到扇形的中間角度middleAngle,而中心的半徑就是圓環(huán)外半徑減去一半圓環(huán)寬度,使用GeomTool.calcCirclePoint工具方法,可以根據(jù)“圓心、半徑、角度”計(jì)算出扇形中心點(diǎn)的坐標(biāo)。

代碼如下:

/** * 計(jì)算指定角度、圓心、半徑時(shí),對(duì)應(yīng)圓周上的點(diǎn)。 * @param angle 角度,0-360度,X正軸開始,順時(shí)針增加。 * @param radius 圓的半徑 * @param cx 圓心X * @param cy 圓心Y * @param resultOut 計(jì)算的結(jié)果(x, y) ,方便對(duì)象的重用。 * @return resultOut, or new Point if resultOut is null. */public static Point calcCirclePoint(int angle, float radius, float cx, float cy, Point resultOut) {    if (resultOut == null) resultOut = new Point();    // 將angle控制在0-360,注意這里的angle是從X正軸順時(shí)針增加。而sin,cos等的計(jì)算是X正軸開始逆時(shí)針增加
    angle = clampAngle(angle);    double radians = angle / 180f * Math.PI;    double sin = Math.sin(radians);    double cos = Math.cos(radians);    double dy = radius * sin;    double dx = radius * cos;    double x = cx + dx;    double y = cy + dy;

    resultOut.set((int) x, (int) y);    return resultOut;
}

使用

目前沒有添加任何attribute,方便單一類文件的閱讀。
在布局文件中可以聲明PieGraphView對(duì)象,然后Activity中可以對(duì)它設(shè)置數(shù)據(jù),設(shè)置圓環(huán)寬度等。主要有下面幾個(gè)方法:

  • public void setData(ItemGroup[] groups)
    設(shè)置要顯示的數(shù)據(jù)。

  • public void setRingWidthFactor(float factor)
    設(shè)置圓環(huán)寬度

  • public void setGrowWidthFactor(float factor)
    設(shè)置圓環(huán)上某個(gè)Item可以grow的額外半徑。