Android自定義View,畫一個好看帶延長線的餅狀圖

喵星人萊恩發表於2019-03-14

前言

在Android中,圖表的實現是比較麻煩的,基本只能通過自定義View來實現。目前Github上有一些整合度高功能性強的三方庫,比如MPAndroidChart等。但三方庫雖然強大,定製性總是有限的,在專案中為了達成一些特別需求,就要靠我們自己去畫啦。雖然費點時間,不過計算各種繪製點的位置的過程還是很有趣的。我個人對於自定義View這部分只是小有了解,所以大家如果對本文中的程式碼有什麼改進意見,歡迎在評論區或者我的github專案上提issues出來啦~

繪製思路

先來看一下,在專案中設計師給到我要實現的樣子:

Android自定義View,畫一個好看帶延長線的餅狀圖

無視設計師畫圖時數字和佔比不符的偷懶,可以看到這是一個普通的餅狀圖加上延長線、文字描述和一些圈圈點點,那麼整理一下大致的繪製思路,我的想法是:

  1. 繪製餅狀圖
    • 確定餅狀圖所處的正方形區域,找出圓點
    • 通過drawArc繪製扇區,繪製出餅圖的各個部分
    • 中間畫一個圓,讓餅圖變為只有外面一圈
  2. 繪製餅圖外的點、圈、線、字
    • 點的角度處於每個圓弧的半分處,通過正餘弦算出點的位置
    • 以點為圓心畫圈
    • 按照四個象限,不同象限以不同角度從圈邊延長出線
    • 以線的終點對齊加上字
  3. 給自定義View增加空間,以避免延長線和字顯示不全

主要用到了數學中座標系象限的概念和正餘弦的演算法,看著有點繞,確實也是挺繞的,接下來分步驟詳細描述吧。

繪製餅圖

首先我們需要儲存各個餅圖所需要的屬性:

public class PieEntry {
    //顏色
    private int color;
    //比分比
    private float percentage;
    //條目名
    private String label;
    //扇區起始角度
    private float currentStartAngle;
    //扇區總角度
    private float sweepAngle;
    //省略get&set
}
複製程式碼

在繪製餅圖中,我們只需要顏色、百分比就夠了,其他的在後面的步驟才會用到。

確定圓點

在佈局檔案中,我們將自定義View的寬度設為match_paren,高度設為300dp,並新增一個淺色作為背景色。

餅圖作為一個圓,那麼在繪製這個圓前,我們先找出圓心的位置,並將其作為整個View的原點,即座標(0,0)的位置。

在這裡我向View中新增了座標軸和原點的輔助線,作為指示用。

Android自定義View,畫一個好看帶延長線的餅狀圖

@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
    super.onSizeChanged(w, h, oldw, oldh);
    //獲取實際View的寬高
    mTotalWidth = w - getPaddingStart() - getPaddingEnd();
    mTotalHeight = h - getPaddingTop() - getPaddingBottom();
    //繪製餅圖所處的正方形RectF
    initRectF();
}

@Override
protected void onDraw(Canvas canvas) {
    super.onDraw(canvas);
    //將座標中心設到View的中心
    canvas.translate(mTotalWidth / 2, mTotalHeight / 2);
    //draw...
}
複製程式碼

建立正方形RectF,確定餅圖半徑

在確定圓心並將其設為座標原點後,建立一個邊長等於View短邊長的正方形RectF:

private void initRectF() {
    float shortSideLength;
    //取短邊 作為餅圖所在正方形的邊長
    shortSideLength = (mTotalHeight < mTotalWidth) ? mTotalHeight : mTotalWidth;
    //除以2即為餅圖的半徑
    mRadius = shortSideLength / 2;
    //設定RectF的座標
    mRectF = new RectF(-mRadius, -mRadius, mRadius, mRadius);
}
複製程式碼

設定paint顏色為紅色,將這個Rect通過canvas.drawRect(mRectF, mPaint);在View中繪製出來,可以看到其邊長是和高度一致的:

Android自定義View,畫一個好看帶延長線的餅狀圖

那麼為什麼需要建立這個正方形RectF呢?因為在接下來的餅圖繪製中會用到。可以簡單理解為這個正方形就是餅圖的外輪廓所處的範圍,也就是長方形的邊長即是餅圖的直徑。

繪製扇形

雖然餅圖是一個圓,但這是相對於其整體而言。在一個餅圖中,不同的類目佔比不同,將餅圖分割成了多個扇形,所以我們實際上是要繪製扇形。在Android自定義View中,對應的方法是 drawArc,所需要的引數包括:

Android自定義View,畫一個好看帶延長線的餅狀圖

圖片引用自:劉某人程式設計師——Android繪圖機制(二)

這裡受限於篇幅不能詳細介紹,不瞭解的同學一定要先去網上看一下相關文章。

那麼已經確定了繪製扇形需要的矩形RectF、接下來只用傳入起始角度和扇形總角度,以及該扇形的顏色,就能繪製出餅圖了。那麼對於起始角度,我們可以通過每個條目的百分比來算出:

    private void initData() {
        //預設的起始角度為-90°
        float currentStartAngle = -90;
        for (int i = 0; i < mPieLists.size(); i++) {
            PieEntry pie = mPieLists.get(i);
            pie.setCurrentStartAngle(currentStartAngle);
            //每個資料百分比對應的角度
            float sweepAngle = pie.getPercentage() / 100 * 360;
            pie.setSweepAngle(sweepAngle);
            //起始角度不斷增加
            currentStartAngle += sweepAngle;
            //新增顏色
            pie.setColor(mColorLists.get(i));
        }
    }
複製程式碼

這裡需要注意的是:第一個扇形的起始角度為-90度,因為在自定義View中,0度是從右邊開始的,也就是座標軸中的X軸正方向那條線開始順時針增加,而我們想讓扇形從Y軸的上方這條線開始順時針繪製,所以需要減90°。

現在entry中記錄了每條資料的起始角度和掃過角度,可以直接遍歷資料進行繪製了。但要記得在繪製之前,將paint的style設為Paint.Style.FILL,這樣才能繪製出扇形:

private void drawPie(Canvas canvas) {
    for (PieEntry pie : mPieLists) {
        mPaint.setColor(pie.getColor());
        canvas.drawArc(mRectF,
                pie.getCurrentStartAngle(),
                pie.getSweepAngle(),
                true, mPaint);
    }
}
複製程式碼

Android自定義View,畫一個好看帶延長線的餅狀圖

新增中心空洞

相比設計稿,發現還有中間一個空洞,這個就簡單啦,確定空洞半徑佔餅圖的比例,再繪製一個同心白色圓形就好:

    //餅圖中間的空洞佔據的比例
    float holeRadiusProportion = 59;
    canvas.drawCircle(0, 0, mRadius * holeRadiusProportion / 100, mPaint);
複製程式碼

現在來看一下效果吧:

Android自定義View,畫一個好看帶延長線的餅狀圖

繪製延長點和圈

每個扇形都有一個延長點,點所處的位置在扇形圓弧中點的外部,對於扇形的角度我們已經知道了,所以延長點連線圓心的線,和X或Y軸形成的角度也是可知的,延長點到圓心的距離是圓半徑+一小段延長距離,所以通過正餘弦的演算法,就能求出延長點的座標值:

 private void drawPoint(Canvas canvas) {
        for (PieEntry pie : mPieLists) {
            //延長點的位置處於扇形的中間
            float halfAngle = pie.getCurrentStartAngle() + pie.getSweepAngle() / 2;
            float cos = (float) Math.cos(Math.toRadians(halfAngle));
            float sin = (float) Math.sin(Math.toRadians(halfAngle));
            //通過正餘弦算出延長點的座標
            float xCirclePoint = (mRadius + distance) * cos;
            float yCirclePoint = (mRadius + distance) * sin;

            mPaint.setColor(pie.getColor());
            //繪製延長點
            canvas.drawCircle(xCirclePoint, yCirclePoint, smallCircleRadius, mPaint);
            //繪製同心圓環
            mPaint.setStyle(Paint.Style.STROKE);
            canvas.drawCircle(xCirclePoint, yCirclePoint, bigCircleRadius, mPaint);
            mPaint.setStyle(Paint.Style.FILL);
        }
    }
複製程式碼

得到點的位置,再以其作為圓心繪製一個小圈。執行一下,效果是這樣的:

Android自定義View,畫一個好看帶延長線的餅狀圖

咦,出現問題了,怎麼5個扇形,卻只出現了4個點和圈呢? 最下面紫色扇形的點並沒有顯示出來。

還記得一開始為餅圖所處的正方形RectF設定大小嗎?我們將整個View的最短邊作為其邊長,在只有餅圖的時候是沒問題的,但現在餅圖的外部又多了一些顯示內容,所以我們要將餅圖的範圍縮小,給外部的內容一些展示空間。

目前只畫了點跟圈,後續還有延長線和文字,也就是餅圖在View中佔的空間會越來越小。如何適配餅圖區域的大小,在後面的章節會提,目前我們先簡單化處理,直接將餅圖的半徑縮小一部分:

private void initRectF() {
        float shortSideLength;
        //取短邊 作為餅圖的直徑
        shortSideLength = (mTotalHeight < mTotalWidth) ? mTotalHeight : mTotalWidth;
        //除以2即為餅圖的半徑
        mRadius = (shortSideLength) / 2;
        //減少半徑,為外部內容騰出顯示空間
        mRadius -= 50;
        //設定RectF的座標
        mRectF = new RectF(-mRadius, -mRadius, mRadius, mRadius);
    }

複製程式碼

繪製延長線和字

這裡我們回看設計稿,引入數學中的象限概念,將其分為4個象限

Android自定義View,畫一個好看帶延長線的餅狀圖

可以發現,在不同的象限中,延長線的延申方向是不一樣的,所以要按照象限來對延長線和文字進行處理,這裡限於篇幅不詳細講解演算法思路了,這部分自己去思考一下也是蠻有意思的:

private void drawLineAndText(Canvas canvas) {
        //算出延長線轉折點相對起點的正餘弦值
        double offsetRadians = Math.atan(yOffset / xOffset);
        float cosOffset = (float) Math.cos(offsetRadians);
        float sinOffset = (float) Math.sin(offsetRadians);
        
        for (PieEntry pie : mPieLists) {
            //延長點的位置處於扇形的中間
            float halfAngle = pie.getCurrentStartAngle() + pie.getSweepAngle() / 2;
            float cos = (float) Math.cos(Math.toRadians(halfAngle));
            float sin = (float) Math.sin(Math.toRadians(halfAngle));
            //通過正餘弦算出延長點的位置
            float xCirclePoint = (mRadius + distance) * cos;
            float yCirclePoint = (mRadius + distance) * sin;

            mPaint.setColor(pie.getColor());
            //繪製延長點
            canvas.drawCircle(xCirclePoint, yCirclePoint, smallCircleRadius, mPaint);
            //繪製同心圓環
            mPaint.setStyle(Paint.Style.STROKE);
            canvas.drawCircle(xCirclePoint, yCirclePoint, bigCircleRadius, mPaint);
            mPaint.setStyle(Paint.Style.FILL);

            //將餅圖分為4個象限,從右上角開始順時針,每90度分為一個象限
            int quadrant = (int) (halfAngle + 90) / 90;
            //初始化 延長線的起點、轉折點、終點
            float xLineStartPoint = 0;
            float yLineStartPoint = 0;
            float xLineTurningPoint = 0;
            float yLineTurningPoint = 0;
            float xLineEndPoint = 0;
            float yLineEndPoint = 0;
            //建立要顯示的文字
            String text = pie.getLabel() + " " +
                    new DecimalFormat("#.#").format(pie.getPercentage()) + "%";
            //延長點、起點、轉折點在同一條線上
            //不同象限轉折的方向不同
            float cosLength = bigCircleRadius * cosOffset;
            float sinLength = bigCircleRadius * sinOffset;
            switch (quadrant) {
                case 0:
                    xLineStartPoint = xCirclePoint + cosLength;
                    yLineStartPoint = yCirclePoint - sinLength;
                    xLineTurningPoint = xLineStartPoint + xOffset;
                    yLineTurningPoint = yLineStartPoint - yOffset;
                    xLineEndPoint = xLineTurningPoint + extend;
                    yLineEndPoint = yLineTurningPoint;
                    mPaint.setTextAlign(Paint.Align.RIGHT);
                    canvas.drawText(text, xLineEndPoint, yLineEndPoint - 5, mPaint);
                    break;
                case 1:
                    xLineStartPoint = xCirclePoint + cosLength;
                    yLineStartPoint = yCirclePoint + sinLength;
                    xLineTurningPoint = xLineStartPoint + xOffset;
                    yLineTurningPoint = yLineStartPoint + yOffset;
                    xLineEndPoint = xLineTurningPoint + extend;
                    yLineEndPoint = yLineTurningPoint;
                    mPaint.setTextAlign(Paint.Align.RIGHT);
                    canvas.drawText(text, xLineEndPoint, yLineEndPoint - 5, mPaint);
                    break;
                case 2:
                    xLineStartPoint = xCirclePoint - cosLength;
                    yLineStartPoint = yCirclePoint + sinLength;
                    xLineTurningPoint = xLineStartPoint - xOffset;
                    yLineTurningPoint = yLineStartPoint + yOffset;
                    xLineEndPoint = xLineTurningPoint - extend;
                    yLineEndPoint = yLineTurningPoint;
                    mPaint.setTextAlign(Paint.Align.LEFT);
                    canvas.drawText(text, xLineEndPoint, yLineEndPoint - 5, mPaint);
                    break;
                case 3:
                    xLineStartPoint = xCirclePoint - cosLength;
                    yLineStartPoint = yCirclePoint - sinLength;
                    xLineTurningPoint = xLineStartPoint - xOffset;
                    yLineTurningPoint = yLineStartPoint - yOffset;
                    xLineEndPoint = xLineTurningPoint - extend;
                    yLineEndPoint = yLineTurningPoint;
                    mPaint.setTextAlign(Paint.Align.LEFT);
                    canvas.drawText(text, xLineEndPoint, yLineEndPoint - 5, mPaint);
                    break;
                default:
            }
            //繪製延長線
            canvas.drawLine(xLineStartPoint, yLineStartPoint, xLineTurningPoint, yLineTurningPoint, mPaint);
            canvas.drawLine(xLineTurningPoint, yLineTurningPoint, xLineEndPoint, yLineEndPoint, mPaint);
        }
    }
複製程式碼

看一下出來的效果:

Android自定義View,畫一個好看帶延長線的餅狀圖

寬高適配

到這裡可以說已經完成了設計師想要的效果了,是不是挺好看的呢^ ^ 不過可以看到還是有顯示不全的問題,特別是在極端資料的情況,比如將資料設成下面的樣子:

mPieLists.add(new PieEntry(0.01F, "服裝"));
        mPieLists.add(new PieEntry(49.98F, "數碼產品"));
        mPieLists.add(new PieEntry(0.01F, "保健品"));
        mPieLists.add(new PieEntry(49.98F, "戶外運動用品"));
複製程式碼

所以接下來,我們要對餅圖的大小進行自動適配。還是在建立RectF的方法中進行修改:

private void initRectF() {

        Paint.FontMetrics fontMetrics = mPaint.getFontMetrics();
        //文字的高度
        float textHeight = fontMetrics.bottom - fontMetrics.top + fontMetrics.leading;
        //延長線的縱向長度
        float lineHeight = distance + bigCircleRadius + yOffset;
        //延長線的橫向長度
        float lineWidth = distance + bigCircleRadius + xOffset + extend;
        //求出餅狀圖加延長線和文字 所有內容需要的長方形空間的長寬比
        mScale = mTotalWidth / (mTotalWidth + lineHeight * 2 + textHeight * 2 - lineWidth * 2);

        //長方形空間其短邊的長度
        float shortSideLength;
        //通過寬高比選擇短邊
        if (mTotalWidth / mTotalHeight >= mScale) {
            shortSideLength = mTotalHeight;
        } else {
            shortSideLength = mTotalWidth / mScale;
        }
        //餅圖所在的區域為正方形,處於長方形空間的中心
        //空間的高度減去上下兩部分文字顯示需要的高度,除以2即為餅圖的半徑
        mRadius = shortSideLength / 2 - lineHeight - textHeight;
        //設定RectF的座標
        mRectF = new RectF(-mRadius, -mRadius, mRadius, mRadius);
    }
複製程式碼

而且作為嚴謹的程式猿,肯定不允許有多餘的空間浪費掉,所以在XML中設定高度為wrap_content時,也要能按照寬度進行適配:

 @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        //高度為WrapContent時,設定預設高度
        if (mScale != 0 && MeasureSpec.getMode(heightMeasureSpec) == MeasureSpec.AT_MOST) {
            int height = (int) (mTotalWidth / mScale);
            setMeasuredDimension(widthMeasureSpec, height);
        }
    }
複製程式碼

在MainaActivity中增加了兩個按鈕可以動態加大和減少自定義View的高度,我們來看一下適配後的效果吧:

Android自定義View,畫一個好看帶延長線的餅狀圖

到這裡已經按照設計稿的樣子做完了,但還有很多可以新增的內容,比如延長線的角度也可以跟著變等等,都是通過正餘弦演算法算出座標來,思路大體是一樣的。

完整的程式碼可以在我的Github上檢視:https://github.com/Leelion96/PieChartView

如果程式碼對你有一些幫助或啟示,能幫我點一個小小的star就是最大的支援啦。如果本文或者程式碼有任何疏漏或錯誤,也歡迎大家給出指導意見,阿里嘎多~

相關文章