前段時間公司前所未有的接了一個大數據外包項目(哇~咱們又不是外包公司(╯°Д°)╯︵ ┻━┻),要求搞不少圖表方便觀察運營的數據狀況,圖表固然要用到MPAndroidChart啦,但並非全部的圖表均可以用它用實現,這時就須要自定義View了,其中有一個要求,以下圖所示,這就是本篇要實現的效果:java
本篇全文適合像我同樣的小白細細觀看,若是你很趕時間,就只是進來看看標題上的解決方案,那麼請直接看第二部分分析與實現的第5章節《優化解決抗鋸齒問題》 。canvas
最終效果上圖就能夠看到了,下面就來想一想怎麼實現從0實現這個自定義View吧。微信
能夠看到這個View要根據進度,繪製對應長度的弧(或者說是不完整的環),由於環的顏色是漸變的,不能用程序來控制(或者說很差實現),因此向美術要了以下兩張切圖:markdown
別看這兩個環大小不一,實際上圖片的總體尺寸是同樣的,都是95*95。ide
那麼接下來就是根據進度把圖片的部分區域繪製出來就行了。oop
這段能夠說是自定義View的模板代碼了,就不詳細說明,基本上全部的自定義View都這樣測量控件寬高,模板代碼以下:post
public class ArithmeticView extends View { private int mWidth = 0;// 控件的寬度 private int mHeight = 0;// 控件的高度 @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { super.onMeasure(widthMeasureSpec, heightMeasureSpec); int widthSpecSize = MeasureSpec.getSize(widthMeasureSpec); int widthSpecMode = MeasureSpec.getMode(widthMeasureSpec); int heightSpecSize = MeasureSpec.getSize(heightMeasureSpec); int heightSpecMode = MeasureSpec.getMode(heightMeasureSpec); switch (widthSpecMode) { case MeasureSpec.AT_MOST: mWidth = dp2px(200); break; case MeasureSpec.EXACTLY: case MeasureSpec.UNSPECIFIED: mWidth = widthSpecSize; break; } switch (heightSpecMode) { case MeasureSpec.AT_MOST: mHeight = dp2px(200); break; case MeasureSpec.EXACTLY: case MeasureSpec.UNSPECIFIED: mHeight = heightSpecSize; break; } setMeasuredDimension(mWidth, mHeight); } } 複製代碼
由於是項目特有的自定義View,不考慮通用問題,直接在View建立後加載須要用到的圖片資源便可。測試
private Bitmap mBitmapInner; private Bitmap mBitmapOuter; public ArithmeticView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); init(); } private void init() { mBitmapInner = BitmapFactory.decodeResource(getResources(), R.mipmap.main_arithmetic_inner); mBitmapOuter = BitmapFactory.decodeResource(getResources(), R.mipmap.main_arithmetic_outer); } 複製代碼
由於要繪製的環有2個,分爲大小環,故繪製對應環的方法命名爲:drawInnerBitmap()、drawOuterBitmap()。大數據
@Override protected void onDraw(Canvas canvas) { canvas.setDrawFilter(new PaintFlagsDrawFilter(0, Paint.ANTI_ALIAS_FLAG | Paint.FILTER_BITMAP_FLAG)); drawInnerBitmap(canvas); drawOuterBitmap(canvas); } private void drawInnerBitmap(Canvas canvas) { } private void drawOuterBitmap(Canvas canvas) { } 複製代碼
能夠看到在onDraw()方法中有這樣一段代碼:優化
canvas.setDrawFilter(new PaintFlagsDrawFilter(0, >Paint.ANTI_ALIAS_FLAG | Paint.FILTER_BITMAP_FLAG));
這是爲了抗鋸齒的,但這裏先透露一下,該方法對下面的實現方案一抗鋸齒無效。
canvas繪製bitmap用到的方法就是drawBitmap(),它的全部重載方法以下圖所示
說明一下,若是你就單單只是把繪製Bitmap繪製出來,那麼最後的paint參數能夠傳入null。
實際上,當你使用drawBitmap()繪製Bitmap時,畫筆paint的做用並不大,能夠認爲無效。
那麼多方法,用哪一個呢?其實開發中經常使用的重載方法就以下兩個:
public void drawBitmap(Bitmap bitmap, float left, float top, Paint paint) public void drawBitmap(Bitmap bitmap, Rect src, RectF dst, @Nullable Paint paint) 複製代碼
第1個重載方法參數較少,其中left和top表示圖片要繪製到canvas的起始位置,這個方法沒法指定bitmap的圖片要顯示的區域(有時一張圖片就只須要顯示它的四分之一),這就是該重載方法的侷限,而第2個重載方法則能夠隨便指定bitmap要顯示的區域,並且是最經常使用的方法,功能相對更強,這裏使用該方法來實現Bitmap的繪製。
由於2個環的繪製原理同樣,因此這裏就以繪製外環爲例:
private void drawOuterBitmap(Canvas canvas) { int left = 0; int top = 0; int right = mBitmapOuter.getWidth(); int bottom = mBitmapOuter.getHeight(); Rect src = new Rect(left, top, right, bottom); RectF dsc = new RectF(0, 0, mWidth, mHeight); canvas.drawBitmap(mBitmapOuter, src, dsc, null); } 複製代碼
其中,src表示要圖片要被繪製區域(注意,是針對圖片來講的),若是隻繪製圖片的四分之一,則代碼以下:
int left = 0; int top = 0; int right = mBitmapOuter.getWidth() / 2; int bottom = mBitmapOuter.getHeight() / 2; Rect src = new Rect(left, top, right, bottom); 複製代碼
而dsc則表示要繪製到canvas上的哪一個矩形區域(注意,是針對canvas來講的),前面的懂了,相信這個也不難理解。
這樣,圖片就繪製出來了,注意此時是沒有鋸齒的。
環的完整繪製在上面已經用drawBitmap()方法實現,那麼接下來就是繪製一個不完整的環了。我首先想到的方法就是使用canvas的裁切功能,將canvas的繪製區域先裁切出來,而後再在上面繪製圖形,進而實現根據進度繪製圖片的一部分。
針對該自定義View,須要說明一點,canvas的可繪製區域應該是一個從中上方開始逆時針打開的扇形(能夠想象成一把扇子);用二維座標系的方式來講的話,就從y軸開始,以原點爲圓心,逆時針畫圓。
canvas的裁切功能須要用到clip開頭的方法,canvas中全部的clipXXX()方法以下圖所示:
絕大部分方法都是爲了裁切出一個矩形,而咱們這個不同,它是要裁切出一個扇形!因此只有一個clipPath()方法可用,由咱們來自定義裁切形狀(一樣的,除了矩形之外的任何形狀都須要咱們本身定義路徑)。
不論是哪一個clipPath()方法,都須要用到Path對象,該path對象就表明了canvas的裁切路徑,由於大小環的進度可能不一樣,但原理同樣,因此將path的設置代碼抽出來做爲一個共用方法,代碼以下:
private Path mPath; private void init() { ... mPath = new Path(); } /** * 設置裁切路徑 * * @param process 當前進度 * @param maxProcess 總進度 */ private void setClipPath(float process, float maxProcess) { mPath.reset(); float ratio = process / maxProcess; if (ratio == 1) { // 當進度比例爲1時,說明進度100%,要完整繪製Bitmap mPath.addCircle(mWidth / 2, mHeight / 2, mWidth / 2, Path.Direction.CCW); } else { float sweepAngle = ratio * -360; mPath.moveTo(mWidth / 2, mHeight / 2);// View的中心點位置 mPath.lineTo(mWidth / 2, mHeight);// View的中心點上方位置 mPath.arcTo(new RectF(0, 0, mWidth, mHeight), 270, sweepAngle * mProgressPercent, false);// 根據角度畫弧線 mPath.lineTo(mWidth / 2, mHeight / 2);// 最後再回到View的中心點位置,造成一個封閉路徑 } mPath.close(); } 複製代碼
關於Path的moveTo()、lineTo()、arcTo等方法在這裏就是不詳細科普了,由於方法比較多,要說明可能會花費很多篇幅,並且實際上這些方法顧名就可思義,若是不是很懂的同窗自行百度查一下吧。
這裏要着重說明一點,Android中的角度問題,以下圖所示:
Android中的角度是以x軸爲0°開始,以順時針方向遞增,而咱們的自定義View要繪製的方向則是逆時針,因此要計算arcTo的sweepAngle時要注意乘以一個負值。
要注意一點,canvas的裁切功能會對後續的繪製產生影響,因此在裁切以前須要將Canvas的當前狀態保存一下,在裁切繪製事後將Canvas的狀態恢復回來。不然,以後的繪製結果可能並非你想要的了。
若是不保存與恢復Canvas的狀態,那麼下次繪製只會在裁切出來的區域中進行。舉個例子,假設內環的尺寸只有50*50,你先繪製了內環,但沒有保存與恢復Canvas的狀態,那麼以後在繪製外環時(外環尺寸95*95),你就會發現外環看不到了。
保存Canvas的當前狀態代碼:
canvas.save(Canvas.CLIP_SAVE_FLAG);
恢復Canvas以前的狀態代碼:
canvas.restore();
因此外環裁切並繪製的完整代碼以下:
private void drawOuterBitmap(Canvas canvas) { int left = 0; int top = 0; int right = mBitmapOuter.getWidth(); int bottom = mBitmapOuter.getHeight(); Rect src = new Rect(left, top, right, bottom); RectF dsc = new RectF(0, 0, mWidth, mHeight); canvas.save(Canvas.CLIP_SAVE_FLAG); setClipPath(mOuterProcess, mOuterMaxProcess); canvas.clipPath(mPath); canvas.drawBitmap(mBitmapOuter, src, dsc, null); canvas.restore(); } 複製代碼
將進度設置爲80%後,繪製出來的效果以下:
注意了,鋸齒出現了!!!
爲何?明明對canvas設置了抗鋸齒了,怎麼還這樣?難道是由於在調用canvas.drawBitmap()時,沒有傳入抗鋸齒畫筆的緣由?錯,前面已經說過了,這個paint對drawBitmap()的做用並不大,就算你傳入了能夠抗鋸齒的paint,鋸齒依然存在。仔細想一想,在使用裁切先後,鋸齒的出現狀況,你就能發現貓膩,百度及Google大法後,最終得出以下幾點結論:
那麼,接下來就開始進行「曲線救國」路線。
對於PorterDuff的介紹這裏就不說了,百度吧,或者跳過,直接來看下面這圖:
這圖很好的表示了PorterDuff.Mode的各類效果,下面是對效果的詳細說明。
Mode | 說明 |
---|---|
.CLEAR | 所繪製不會提交到畫布上 |
PorterDuff.Mode.SRC | 顯示上層繪製圖片 |
PorterDuff.Mode.DST | 顯示下層繪製圖片 |
PorterDuff.Mode.SRC_OVER | 正常繪製顯示,上下層繪製疊蓋 |
PorterDuff.Mode.DST_OVER | 上下層都顯示。下層居上顯示 |
PorterDuff.Mode.SRC_IN | 取兩層繪製交集。顯示上層 |
PorterDuff.Mode.DST_IN | 取兩層繪製交集。顯示下層 |
PorterDuff.Mode.SRC_OUT | 取上層繪製非交集部分 |
PorterDuff.Mode.DST_OUT | 取下層繪製非交集部分 |
PorterDuff.Mode.SRC_ATOP | 取下層非交集部分與上層交集部分 |
PorterDuff.Mode.DST_ATOP | 取上層非交集部分與下層交集部分 |
PorterDuff.Mode.XOR | 異或:去除兩圖層交集部分 |
PorterDuff.Mode.DARKEN | 取兩圖層所有區域,交集部分顏色加深 |
PorterDuff.Mode.LIGHTEN | 取兩圖層所有,點亮交集部分顏色 |
PorterDuff.Mode.MULTIPLY | 取兩圖層交集部分疊加後顏色 |
PorterDuff.Mode.SCREEN | 取兩圖層所有區域,交集部分變爲透明色 |
仔細看圖中的Src、Dst、SrcIn,有沒有什麼想法呢?
若是你以前沒用過PorterDuff.Mode也不慌,繼續"聽"我給你吹水,並不難理解。
這裏要先理解2個單詞:src與dst。通俗的說,dst是已經繪製在canvas上的圖像,而src則是將要繪製到canvas上的圖像(不得不說跟OpenGL的模板測試部分概念很像耶~)。那麼,對於咱們這個自定義View而言,dst就是那個扇形圖像,src就是環,再結合PorterDuff.Mode的SrcIn模式,就可讓環的Bitmap只顯示出扇形的部分了。
注意:使用PorterDuff須要禁止硬件加速。
代碼很簡單,很少廢話,以下:
private Paint mPaint; private void init() { // 禁止硬件加速,硬件加速會有一些問題,這裏禁用掉 setLayerType(LAYER_TYPE_SOFTWARE, null); mPaint = new Paint(); mPaint.setAntiAlias(true); } private void drawOuterBitmap(Canvas canvas) { int left = 0; int top = 0; int right = mBitmapOuter.getWidth(); int bottom = mBitmapOuter.getHeight(); Rect src = new Rect(left, top, right, bottom); RectF dsc = new RectF(0, 0, mWidth, mHeight); mPaint.reset(); setClipPath(mOuterProcess, mOuterMaxProcess); canvas.drawPath(mPath, mPaint);// 繪製Dst mPaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.SRC_IN));// 設置轉換模式(顯示Scr與Dst交接的區域) canvas.drawBitmap(mBitmapOuter, src, dsc, mPaint);// 繪製Src } 複製代碼
效果很棒,沒有鋸齒了。
前面看似已經用PorterDuff實現了環的無鋸齒繪製,但若是內環也是按照上面的代碼來寫,你就會發現,只顯示後繪製的外環。具體緣由嘛,我也不太懂,猜想是屢次使用PorterDuff,會將以前繪製在Canvas上的圖像清除吧。(若是有人知道真實緣由,麻煩留言說一下,thx)。既然,直接對canvas操做不可取,那就換個思路吧。
咱們要的不過是最後顯示出來的不完整的環對吧,那咱們能夠在另外一個Canvas上把這個不完整的環畫出來,獲得它的Bitmap,再在onDraw()的Canvas上對這個Bitmap進行繪製便可。因此,改進後的代碼以下:
private void drawOuterBitmap(Canvas canvas) { int left = 0; int top = 0; int right = mBitmapOuter.getWidth(); int bottom = mBitmapOuter.getHeight(); Rect src = new Rect(left, top, right, bottom); RectF dsc = new RectF(0, 0, mWidth, mHeight); // 1. 在另外一個Canvas中使用 path + mBitmapOuter 將最終圖形finalBitmap繪製出來。 mPaint.reset(); setClipPath(mOuterProcess, mOuterMaxProcess); Bitmap finalBitmap = Bitmap.createBitmap(mWidth, mHeight, Bitmap.Config.ARGB_8888);// 一張白紙位圖 Canvas mCanvas = new Canvas(finalBitmap);// 用指定的位圖構造一個畫布來繪製 mCanvas.setDrawFilter(new PaintFlagsDrawFilter(0, Paint.ANTI_ALIAS_FLAG | Paint.FILTER_BITMAP_FLAG));// 畫布繪製Bitmap時搞鋸齒 mCanvas.drawPath(mPath, mPaint);// 繪製Dst mPaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.SRC_IN));// 設置轉換模式(顯示Scr與Dst交接的區域) mCanvas.drawBitmap(mBitmapOuter, src, dsc, mPaint);// 繪製Src // 2. 再在原來的Canvas中將finalBitmap繪製出來。 canvas.drawBitmap(finalBitmap, 0, 0, null); } 複製代碼
一樣的,內環的代碼基本一致,直接看效果吧。
至於那個執行繪製動畫效果的功能並不是本文重點,實現上能見仁見智,下面貼出該自定義View的完整代碼,固然執行繪製動畫效果的功能也在裏面(具體實現看animateStart()方法)。完整代碼以下:
public class ArithmeticView extends View { private int mWidth = 0;// 控件的寬度 private int mHeight = 0;// 控件的高度 private Bitmap mBitmapInner; private Bitmap mBitmapOuter; private Path mPath; private float mInnerProcess = 50; private float mInnerMaxProcess = 100; private float mOuterProcess = 80; private float mOuterMaxProcess = 100; private float mProgressPercent = 1;// 當前進度百分比 private ValueAnimator mValueAnimator; private Paint mPaint; public ArithmeticView(Context context) { this(context, null); } public ArithmeticView(Context context, @Nullable AttributeSet attrs) { this(context, attrs, 0); } public ArithmeticView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); init(); } @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { super.onMeasure(widthMeasureSpec, heightMeasureSpec); int widthSpecSize = MeasureSpec.getSize(widthMeasureSpec); int widthSpecMode = MeasureSpec.getMode(widthMeasureSpec); int heightSpecSize = MeasureSpec.getSize(heightMeasureSpec); int heightSpecMode = MeasureSpec.getMode(heightMeasureSpec); switch (widthSpecMode) { case MeasureSpec.AT_MOST: mWidth = dp2px(200); break; case MeasureSpec.EXACTLY: case MeasureSpec.UNSPECIFIED: mWidth = widthSpecSize; break; } switch (heightSpecMode) { case MeasureSpec.AT_MOST: mHeight = dp2px(200); break; case MeasureSpec.EXACTLY: case MeasureSpec.UNSPECIFIED: mHeight = heightSpecSize; break; } setMeasuredDimension(mWidth, mHeight); } private void init() { // 禁止硬件加速,硬件加速會有一些問題,這裏禁用掉 setLayerType(LAYER_TYPE_SOFTWARE, null); mBitmapInner = BitmapFactory.decodeResource(getResources(), R.mipmap.main_arithmetic_inner); mBitmapOuter = BitmapFactory.decodeResource(getResources(), R.mipmap.main_arithmetic_outer); mPath = new Path(); mPaint = new Paint(); mPaint.setAntiAlias(true); } @Override protected void onDraw(Canvas canvas) { canvas.setDrawFilter(new PaintFlagsDrawFilter(0, Paint.ANTI_ALIAS_FLAG | Paint.FILTER_BITMAP_FLAG)); drawInnerBitmap(canvas); drawOuterBitmap(canvas); } private void drawInnerBitmap(Canvas canvas) { int left = 0; int top = 0; int right = mBitmapInner.getWidth(); int bottom = mBitmapInner.getHeight(); Rect src = new Rect(left, top, right, bottom); RectF dsc = new RectF(0, 0, mWidth, mHeight); mPaint.reset(); setClipPath(mInnerProcess, mInnerMaxProcess); Bitmap finalBitmap = Bitmap.createBitmap(mWidth, mHeight, Bitmap.Config.ARGB_8888);// 一張白紙位圖 Canvas mCanvas = new Canvas(finalBitmap);// 用指定的位圖構造一個畫布來繪製 mCanvas.setDrawFilter(new PaintFlagsDrawFilter(0, Paint.ANTI_ALIAS_FLAG | Paint.FILTER_BITMAP_FLAG));// 畫布繪製Bitmap時搞鋸齒 mCanvas.drawPath(mPath, mPaint);// 繪製Dst mPaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.SRC_IN));// 設置轉換模式(顯示Scr與Dst交接的區域) mCanvas.drawBitmap(mBitmapInner, src, dsc, mPaint);// 繪製Src canvas.drawBitmap(finalBitmap, 0, 0, null); } private void drawOuterBitmap(Canvas canvas) { int left = 0; int top = 0; int right = mBitmapOuter.getWidth(); int bottom = mBitmapOuter.getHeight(); Rect src = new Rect(left, top, right, bottom); RectF dsc = new RectF(0, 0, mWidth, mHeight); /*------------------ 方案1:使用clipPath方式繪製圖片,但沒法抗鋸齒 ------------------*/ // canvas.save(Canvas.CLIP_SAVE_FLAG); // setClipPath(mOuterProcess, mOuterMaxProcess); // canvas.clipPath(mPath); // canvas.drawBitmap(mBitmapOuter, src, dsc, null); // canvas.restore(); /*------------------ 方案二:使用PorterDuff方式,能夠抗鋸齒 ------------------*/ // 1. 在另外一個Canvas中使用 path + mBitmapOuter 將最終圖形finalBitmap繪製出來。 mPaint.reset(); setClipPath(mOuterProcess, mOuterMaxProcess); Bitmap finalBitmap = Bitmap.createBitmap(mWidth, mHeight, Bitmap.Config.ARGB_8888);// 一張白紙位圖 Canvas mCanvas = new Canvas(finalBitmap);// 用指定的位圖構造一個畫布來繪製 mCanvas.setDrawFilter(new PaintFlagsDrawFilter(0, Paint.ANTI_ALIAS_FLAG | Paint.FILTER_BITMAP_FLAG));// 畫布繪製Bitmap時搞鋸齒 mCanvas.drawPath(mPath, mPaint);// 繪製Dst mPaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.SRC_IN));// 設置轉換模式(顯示Scr與Dst交接的區域) mCanvas.drawBitmap(mBitmapOuter, src, dsc, mPaint);// 繪製Src // 2. 再在原來的Canvas中將finalBitmap繪製出來。 canvas.drawBitmap(finalBitmap, 0, 0, null); } /** * 設置裁切路徑 * * @param process 當前進度 * @param maxProcess 總進度 */ private void setClipPath(float process, float maxProcess) { mPath.reset(); float ratio = process / maxProcess; if (ratio == 1) { mPath.addCircle(mWidth / 2, mHeight / 2, mWidth / 2, Path.Direction.CCW); } else { float sweepAngle = ratio * -360; mPath.moveTo(mWidth / 2, mHeight / 2); mPath.lineTo(mWidth / 2, mHeight); mPath.arcTo(new RectF(0, 0, mWidth, mHeight), 270, sweepAngle * mProgressPercent, false); mPath.lineTo(mWidth / 2, mHeight / 2); } mPath.close(); } public float getInnerProcess() { return mInnerProcess; } public void setInnerProcess(float innerProcess) { mInnerProcess = innerProcess; postInvalidate(); } public float getInnerMaxProcess() { return mInnerMaxProcess; } public void setInnerMaxProcess(float innerMaxProcess) { mInnerMaxProcess = innerMaxProcess; } public float getOuterProcess() { return mOuterProcess; } public void setOuterProcess(float outerProcess) { mOuterProcess = outerProcess; postInvalidate(); } public float getOuterMaxProcess() { return mOuterMaxProcess; } public void setOuterMaxProcess(float outerMaxProcess) { mOuterMaxProcess = outerMaxProcess; } /** * 開始動畫 * * @param duration 動畫時長(毫秒) */ public void animateStart(long duration) { if (mValueAnimator != null) { mValueAnimator.cancel(); mValueAnimator = null; } mValueAnimator = ValueAnimator.ofFloat(0, 100); mValueAnimator.setDuration(duration).start(); mValueAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { @Override public void onAnimationUpdate(ValueAnimator animation) { Float value = (Float) animation.getAnimatedValue(); if (value == 100) { mValueAnimator.cancel(); mValueAnimator = null; } mProgressPercent = value / 100; postInvalidate(); } }); } private int dp2px(int dp) { float density = getContext().getResources().getDisplayMetrics().density; return (int) (dp * density); } } 複製代碼