文章出處

本文要介紹的是一個參照手機支付寶app里面記賬本功能里的“餅狀圖”實現的控件。通常app中可能的數據展示控件有柱狀圖,折線圖,餅狀圖等,如果需要一個包含多種View控件的庫,那么 MPAndroidChart 是不錯的選擇,如果只是需要一個簡單的獨立的餅狀圖控件,希望PieGraphView滿足你的要求。

控件介紹

效果圖如下:

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


PieGraph效果圖

控件功能:

  • 展示的數據
    可以展示多組數據(ItemGroup),每次展示一組數據,一組數據對應形成一個圓環。一組數據由多個Item組成,對應圓環中的扇形。
public static class ItemGroup {
     public String id;
     public Item[] items;
 }

 public static class Item {
     public double value;
     public int color;
     public String id;
 }
  • 圓環
    一個ItemGroup最終顯示為一個圓環。它的中的items是包含的數據項。這些數據項根據其value占總數據的比例對應不同的扇形角度。ItemGroup的所有Item依次繪制,形成360°。

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

  • 選中并高亮Item
    點擊可以選擇一個扇形,選中的扇形作為“當前項”,使用字段int mCurrentItem記錄它的索引。選擇一個扇形后,它會旋轉其中間角度到mStartAngle的角度,然后對應扇形執行“grow”動畫進行高亮突出。

  • 切換ItemGroup
    點擊圓環內部可以切換顯示不同的ItemGroup。切換會有一個動畫,先是順時針從mStartAngle繪制整個圓環。之后在自動選中最后一個Item。

實現過程

圓環的基本繪制

圓環的繪制實際就是通過先后繪制兩個半徑不同的圓實現,圓就是360度的扇形,canvas.drawArc提供了這個功能:

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

需要先繪制有顏色的外圓對應的各個扇形,之后再“覆蓋”繪制內圓對應的各個扇形。

繪制圓環的時候需要考慮開始角度mStartAngle和當前的旋轉mRotation。這里設計了一個方法drawPieFromEnd用來在(start, end)的角度范圍內繪制“被顯示”的那些扇形。這里的角度是扇形數組的形成的0-360的連續角度范圍。

為了繪制的簡單,方法選擇從最后一個扇形開始繪制,相當于從end繪制到start,這樣的好處是不用去計算實際上start對應的是哪個扇形了,而根據傳遞的角度范圍,當下一個繪制的扇形的起始角度大于start時,結束繪制:

/**
 * 從尾部開始繪制圓環,只繪制endAngle到startAngle之間的,不一定繪制所有圓環。
 *
 * @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;
    }
}

動畫

當前控件交互過程中總共有三個動畫:

  • showOut
    每個ItemGroup顯示時執行切換動畫。
  • rotate
    旋轉動畫,被選中的Item會旋轉其中心角度到mStartAngle。
  • grow
    被選中的扇形旋轉結束后,或者再次點擊當前已選扇形,就對它執行一次grow動畫,使得扇形高亮突出。

所有動畫通過Animation實現,這里只是使用Animation完成動畫時間和進度的控制。
重寫applyTransformation方法來記錄當前動畫的進度progress,然后invalidate通知onDraw的執行。
開始動畫執行時將當前動畫模式字段int mAnimMode設置為不同的ANIM_MODE_xxx常量,然后onDraw中會根據當前的mAnimMode值,選擇對應動畫的繪制方法去執行。

代碼結構如下:

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;
            // 旋轉操作可以通過改變開始繪制的角度,也可以旋轉整個View
            // 設置旋轉角度后會使得可點擊區域不再是沿著水平/豎直方向的正方形,所以不采用
            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()方法中對動畫進行初始化。執行runAnimRotate()來開啟動畫。onDraw方法中根據動畫模式選擇執行不同的繪制方法。
三個動畫都是這樣的設計思路。

旋轉

mStartAngle和mRotation兩個字段的值決定了繪制圓環的起始角度。這里旋轉的方式不能是執行View.setRotation()方法,因為會旋轉整個View的區域——View的坐標跟著旋轉!!!使得之后點擊事件的處理會比較麻煩。
旋轉每次只需要計算“要旋轉到的目標角度”和“當前已旋轉的角度”的差值int mRotateDelta,然后執行旋轉動畫,不斷修改mRotation值執行onDraw即可:

/**
 * 讓整個圓旋轉到targetDegree的角度,旋轉是相對mStartAngle開始繪制的圓而言
 *
 * @param targetDegree 應該介于0-360,是從第一個扇形片段作為0度算出來的角度,不是從X正軸開始的角度
 * @param smartRotate  是否抄近路旋轉?
 */
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) {
        // 將旋轉控制在180度內
        if (mRotateDelta > 180) {
            mRotateDelta = mRotateDelta - 360;
        } else if (mRotateDelta < -180) {
            mRotateDelta = 360 + mRotateDelta;
        }
    }

    runAnimRotate();
}

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

突出顯示扇形

選擇的扇形記錄其對應Item的索引int mCurrentItem,只有在沒有任何動畫執行時,或者是正在執行grow動畫時才會對當前選擇的扇形進行突出顯示。
繪制的思路是改變要突出的扇形角度對應的扇形的外圓、內圓的區域大小(drawArc中的oval參數),也就是修改drawArc方法需要的橢圓的矩形區域:

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;
        }

        // 繪制扇形圓環
        canvas.drawArc(oval, sweepStart, sweep, true, mPaintOuter);

        // 繪制圓環上扇形的中心“點”
        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;
    }

    // 繪制內圓,分當前扇形和非當前扇形兩部分
    mGrownOval.set(mSmallOval);
    float grownRadius = mGrownWidth * mGrowProgress;
    float otherStart = currentItemStart + currentItemSweep;
    float otherSweep = 360f - currentItemSweep;
    if (mGrowMode == GROW_MODE_MOVE_OUT) {
        // 小圓轉一圈,消掉可能的縫隙
        otherStart = 0f;
        otherSweep = 360f;
        mGrownOval.inset(-grownRadius, -grownRadius);
    } else if (mGrowMode == GROW_MODE_BOLD) {
        mGrownOval.inset(grownRadius, grownRadius);
        // 小圓轉一圈,消掉可能的縫隙
        currentItemStart = 0f;
        currentItemSweep = 360f;
    }

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

上面繪制的順序是:

  1. 繪制所有扇形的外圓扇形,當前項的半徑會不同。
  2. 繪制對應當前扇形角度的內圓的扇形。
  3. 繪制除去當前扇形角度的其余角度的內圓的扇形。

grow動畫又分為加粗(GROW_MODE_BOLD)和向外移動(GROW_MODE_MOVE_OUT)兩個動畫,不同動畫時內圓扇形的半徑不同,上面因為float值得原因扇形可能會有縫隙,為了消除這個縫隙,最終在繪制的時候會讓“當前扇形的繪制”或者“剩余圓環部分”的繪制直接是繪制360度,因為最終的扇形的確存在包含關系。

點擊事件

重寫onTouchEvent方法,根據ACTION_DOWN時的(x, y)來確定點擊區域是發生在圓環內部、圓環上、還是圓環外。之后會執行不同的處理。

@Override
public 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);
}

只有在動畫未執行時處理點擊事件。這里只是簡單的監聽手指按下的動作,如果為了“更自然”的監聽,可以在ACTION_UP中根據前后的坐標變動來選擇是否判定為對餅狀圖的有效點擊。也可以結合OnClickListener處理“click”事件。總之,關鍵是獲得點擊的(x, y)坐標。

方法calcClickItem完成了點擊事件的不同處理:如果點擊發生在內圓就切換顯示的ItemGroup,點擊發生在圓環外不處理。點擊圓環上某個扇形后,就設置扇形對應的Item為“當前項”,對應扇形會被旋轉到mStartAngle的位置,旋轉后執行grow動畫進行突出顯示。

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) {
        // 點擊發生在小圓內部,也就是點擊到標題區域
        onTitleRegionClicked();
        return -1;
    } else if (clickRadius > outerRadius) {
        // 點擊發生在大圓環外
        return -2;
    }

    // 計算點擊的坐標(x, y)和圓中心點形成的角度,角度從0-360,順時針增加
    int clickedDegree = GeomTool.calcAngle(x, y, centerX, centerY);

    // 計算出來的clickedDegree是整個View原始的,被點擊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;
}

計算點擊的角度

根據點擊的坐標(x, y)和圓心(centerX, centerY)可以計算出點擊的點相對圓心的角度。下面方法calcAngle完成此任務。

代碼如下:

/**
 * 計算坐標(x1, y1)和(x2, y2)形成的角度,角度從0-360,順時針增加
 * (x軸向右,y軸向下)
 */
public static int calcAngle(float x1, float y1, float x2, float y2) {
    double resultDegree = 0;

    double vectorX = x1 - x2; // 點到圓心的X軸向量,X軸向右,向量為(0, vectorX)
    double vectorY = y2 - y1; // 點到圓心的Y軸向量,Y軸向上,向量為(0, vectorY)
    // 點落在X,Y軸的情況這里就排除
    if (vectorX == 0) {
        // 點擊的點在Y軸上,Y不會為0的
        if (vectorY > 0) {
            resultDegree = 90;
        } else {
            resultDegree = 270;
        }
    } else if (vectorY == 0) {
        // 點擊的點在X軸上,X不會為0的
        if (vectorX > 0) {
            resultDegree = 0;
        } else {
            resultDegree = 180;
        }
    } else {
        // 根據形成的正切值算角度
        double tanXY = vectorY / vectorX;
        double arc = Math.atan(tanXY);
        // degree是正數,相當于正切在四個象限的角度的絕對值
        double degree = Math.abs(arc / Math.PI * 180);
        // 將degree換算為對應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根據此角度,結合當前圓環的mStartAngle、mRotation就可以確定點擊落在的扇形區域了。

計算扇形中心

繪制扇形過程中,可以得到扇形的中間角度middleAngle,而中心的半徑就是圓環外半徑減去一半圓環寬度,使用GeomTool.calcCirclePoint工具方法,可以根據“圓心、半徑、角度”計算出扇形中心點的坐標。

代碼如下:

/**
 * 計算指定角度、圓心、半徑時,對應圓周上的點。
 * @param angle 角度,0-360度,X正軸開始,順時針增加。
 * @param radius 圓的半徑
 * @param cx 圓心X
 * @param cy 圓心Y
 * @param resultOut 計算的結果(x, y) ,方便對象的重用。
 * @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正軸順時針增加。而sin,cos等的計算是X正軸開始逆時針增加
    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對象,然后Activity中可以對它設置數據,設置圓環寬度等。主要有下面幾個方法:

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

  • public void setRingWidthFactor(float factor)
    設置圓環寬度

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

資料


文章列表


不含病毒。www.avast.com
arrow
arrow
    全站熱搜
    創作者介紹
    創作者 AutoPoster 的頭像
    AutoPoster

    互聯網 - 大數據

    AutoPoster 發表在 痞客邦 留言(0) 人氣()