在Android中,圖表的實現是比較麻煩的,基本只能經過自定義View來實現。目前Github上有一些集成度高功能性強的三方庫,好比MPAndroidChart等。但三方庫雖然強大,定製性老是有限的,在項目中爲了達成一些特別需求,就要靠咱們本身去畫啦。雖然費點時間,不過計算各類繪製點的位置的過程仍是頗有趣的。我我的對於自定義View這部分只是小有了解,因此你們若是對本文中的代碼有什麼改進意見,歡迎在評論區或者個人github項目上提issues出來啦~git
先來看一下,在項目中設計師給到我要實現的樣子:程序員
無視設計師畫圖時數字和佔比不符的偷懶,能夠看到這是一個普通的餅狀圖加上延長線、文字描述和一些圈圈點點,那麼整理一下大體的繪製思路,個人想法是:github
drawArc
繪製扇區,繪製出餅圖的各個部分主要用到了數學中座標系象限的概念和正餘弦的算法,看着有點繞,確實也是挺繞的,接下來分步驟詳細描述吧。面試
首先咱們須要存儲各個餅圖所須要的屬性:算法
public class PieEntry { //顏色 private int color; //比分比 private float percentage; //條目名 private String label; //扇區起始角度 private float currentStartAngle; //扇區總角度 private float sweepAngle; //省略get&set } 複製代碼
在繪製餅圖中,咱們只須要顏色、百分比就夠了,其餘的在後面的步驟纔會用到。canvas
在佈局文件中,咱們將自定義View的寬度設爲match_paren
,高度設爲300dp,並添加一個淺色做爲背景色。小程序
餅圖做爲一個圓,那麼在繪製這個圓前,咱們先找出圓心的位置,並將其做爲整個View的原點,即座標(0,0)的位置。性能優化
在這裏我向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... } 複製代碼
在肯定圓心並將其設爲座標原點後,建立一個邊長等於View短邊長的正方形RectF:ide
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中繪製出來,能夠看到其邊長是和高度一致的:
那麼爲何須要建立這個正方形RectF呢?由於在接下來的餅圖繪製中會用到。能夠簡單理解爲這個正方形就是餅圖的外輪廓所處的範圍,也就是長方形的邊長便是餅圖的直徑。
雖然餅圖是一個圓,但這是相對於其總體而言。在一個餅圖中,不一樣的類目佔比不一樣,將餅圖分割成了多個扇形,因此咱們其實是要繪製扇形。在Android自定義View中,對應的方法是 drawArc
,所須要的參數包括:
圖片引用自:劉某人程序員——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); } } 複製代碼
相比設計稿,發現還有中間一個空洞,這個就簡單啦,肯定空洞半徑佔餅圖的比例,再繪製一個同心白色圓形就好:
//餅圖中間的空洞佔據的比例 float holeRadiusProportion = 59; canvas.drawCircle(0, 0, mRadius * holeRadiusProportion / 100, mPaint); 複製代碼
如今來看一下效果吧:
每一個扇形都有一個延長點,點所處的位置在扇形圓弧中點的外部,對於扇形的角度咱們已經知道了,因此延長點鏈接圓心的線,和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); } } 複製代碼
獲得點的位置,再以其做爲圓心繪製一個小圈。運行一下,效果是這樣的:
咦,出現問題了,怎麼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個象限
能夠發現,在不一樣的象限中,延長線的延申方向是不同的,因此要按照象限來對延長線和文字進行處理,這裏限於篇幅不詳細講解算法思路了,這部分本身去思考一下也是蠻有意思的:
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); } } 複製代碼
看一下出來的效果:
到這裏能夠說已經完成了設計師想要的效果了,是否是挺好看的呢^ ^ 不過能夠看到仍是有顯示不全的問題,特別是在極端數據的狀況,好比將數據設成下面的樣子:
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的高度,咱們來看一下適配後的效果吧:
到這裏已經按照設計稿的樣子作完了,但還有不少能夠添加的內容,好比延長線的角度也能夠跟着變等等,都是經過正餘弦算法算出座標來,思路大致是同樣的。
若是代碼對你有一些幫助或啓示,能幫我點一個小小的star就是最大的支持啦。若是本文或者代碼有任何疏漏或錯誤,也歡迎你們給出指導意見,阿里嘎多~
想學習更多Android知識,請加入Android技術開發企鵝交流 7520 16839
進羣與大牛們一塊兒討論,還可獲取Android高級架構資料、源碼、筆記、視頻
包括 高級UI、Gradle、RxJava、小程序、Hybrid、移動架構、React Native、性能優化等全面的Android高級實踐技術講解性能優化架構思惟導圖,和BATJ面試題及答案!
羣裏免費分享給有須要的朋友,但願可以幫助一些在這個行業發展迷茫的,或者想系統深刻提高以及困於瓶頸的朋友,在網上博客論壇等地方少花些時間找資料,把有限的時間,真正花在學習上,因此我在這免費分享一些架構資料及給你們。但願在這些資料中都有你須要的內容。