這可能是第二好的自定義 View 教程之繪製

nanchen2251發表於2017-11-08

面試系列 不繼續了嗎?

知道我的人都知道,之前我寫了這個 面試系列宣言,如今好像一直都沒有連載,而是隔三差五地來一篇,其實也是因為筆者也能力有限,構思一篇文章需要足夠的時間去印證其準確性,而之前的部分就因為印證不夠造成了勘誤。

值得注意的是,本系列不會停止的。面試的很多知識點在於平時的積累,但自定義 View 這個東西,就得牢牢掌握了。自定義 View 將分為幾期,本期我們只講繪製。

為什麼我們要學自定義 View?

大多數時候,我們都可以採用官方自帶或者 GitHub 上的三方開源庫實現各種各樣炫酷的效果。但,需求卻是五花八門的,你永遠無法改變設計師們的想象力和創造力。而我們要做的,就是把他們的想象力和創造力變成現實。

圖片來自扔物線
圖片來自扔物線

這期怎麼變成第二好了?

對,我沒有寫錯,本期自定義 View 教程再也不是最好的了,因為這期基本是 HenCoder 的濃縮總結版。

HenCoder,給高階 Android 工程師的進階手冊 ,筆者也是一直在像追劇一樣的追。好像這裡確實有了給我凱哥打廣告的嫌疑,但把好東西,分享給大家,才是最最重要的。

筆者也是七進七出自定義 View,確實是看了不少教程和書籍,都沒有一個很好的自定義 View 能力。而作為 Android 開發中必不可少的能(裝)力(逼)手段,也是一個很好的可以讓我們在面試以及開發中脫穎而出。

廢話不能太多,我要開始啦!

自定義 View 可以簡單的分為三步,繪製、佈局、觸控反饋。本期,我們首先講繪製。

自定義 View 繪製的重中之重

自定義的繪製就是重寫繪製方法,其中最常用的就是 onDraw()。(當然有其它的,後面會提及,這裡先賣個關子。)而繪製的關鍵就是 Canvas 的使用:

  • Canvas 的繪製類方法:drawXXX() (關鍵引數:Paint)
  • Canvas 的輔助類方法:範圍裁切和幾何變換。

一切的開始:onDraw()

自定義繪製的上手非常容易:提前建立好 Paint 物件,重寫 onDraw(),把繪製程式碼寫在 onDraw() 裡面,就是自定義繪製最基本的實現。大概就像這樣:

Paint paint = new Paint();

@Override
protected void onDraw(Canvas canvas) {  
    super.onDraw(canvas);

    // 繪製一個圓
    canvas.drawCircle(300, 300, 200, paint);
}複製程式碼

就這麼簡單。所以關於 onDraw() 其實沒什麼好說的,一個很普通的方法重寫,唯一需要注意的是別漏寫了 super.onDraw()。你可能會點選進去檢視到 super.onDraw() 其實是一個空實現,那可能只是因為你繼承的是 View 吧,你繼承 View 的其它子類試試?

Canvas.drawXXX() 系列方法的使用

Canvas 下面的 drawXXX() 系列的方法真沒啥好講的,你想畫什麼圖形直接畫就好了。而引數其實也給的非常的明瞭。你一定要全部瞭解學習的話,直接可以去看官方文件或者凱哥的 自定義View 1-1

  • 填充顏色:Canvas.drawColor(@ColorInt int color)
  • 畫圓:drawCircle(float centerX, float centerY, float radius, Paint paint)
  • 畫矩形:drawRect(float left, float top, float right, float bottom, Paint paint)
  • 畫點:drawPoint(float x, float y, Paint paint)
  • 批量畫點:drawPoints(float[] pts, int offset, int count, Paint paint) / drawPoints(float[] pts, Paint paint)
  • 畫橢圓:drawOval(float left, float top, float right, float bottom, Paint paint)
  • 畫線:drawLine(float startX, float startY, float stopX, float stopY, Paint paint)
  • 畫弧線或者扇形:drawArc(float left, float top, float right, float bottom, float startAngle, float sweepAngle, boolean useCenter, Paint paint)
  • 畫自定義圖形:drawPath(Path path, Paint paint)
  • 畫 Bitmap:drawBitmap(Bitmap bitmap, float left, float top, Paint paint)
  • 畫文字:drawText(String text, float x, float y, Paint paint)

其中可以看到有不少的座標值引數,你只需要明白的一點是,在 Android 的繪製中,座標系是這樣的。

圖片來自扔物線
圖片來自扔物線

值得注意的是:

  • 在畫弧線或者扇形中的角度 angle,x 軸正方向為 0°,順時針方向為正角度,逆時針為負角度。
  • 畫弧線或者扇形中的 sweepAngle 引數,代表的是繪製的角度,不要被其它方法誤導成了以為是繪製結束時候的角度,官方為何在這裡做了個變換,其實我也不知道。
  • drawPath() 方法可能相對其它較難,但卻是自定義 View 實際應用中最多的。非常需要了解其三類方法。這裡直接摘抄凱哥的 自定義 View 1-1
  • drawBitmap() 方法中有個引數是 Bitmap,友情提示:Bitmap 可以通過 BitmapFactory.decodeXXX() 獲得。

Path 可以描述直線、二次曲線、三次曲線、圓、橢圓、弧形、矩形、圓角矩形。把這些圖形結合起來,就可以描述出很多複雜的圖形。Path 可以歸結為兩類方法:

  • 直接描述路徑,也可以分為兩組:
    • 新增子圖形:addXXX(), 此類方法在特定情況下幾個 Canvas.drawPath() 等同於 Canvas.drawXXX()
    • 畫直線或曲線:xxxTo(): 這一組和第一組 addXxx() 方法的區別在於,第一組是新增的完整封閉圖形(除了 addPath() ),而這一組新增的只是一條線。
  • 輔助設定或計算,因為應用場景很少,凱哥也只講了其中一個方法: Path.setFillType(Path.FillType ft) 設定填充方式

上面有比較多的提到 Paint 這個引數,實際上它是真的很好用,直接在下面講解。

Paint 的使用

Paint 真的很重要,在自定義繪製中充當關鍵角色:畫筆,所以我們自然可以為「畫筆」做很多操作,比如設定顏色、繪製模式、粗細等。

  • Paint.setStyle(Style style) 設定繪製模式
  • Paint.setColor(int color) 設定顏色
  • Paint.setStrokeWidth(float width) 設定線條寬度
  • Paint.setTextSize(float textSize) 設定文字大小
  • Paint.setAntiAlias(boolean aa) 設定抗鋸齒開關

嗯,對,抗鋸齒開關還可以直接在 Paint 初始化的時候直接作為構造引數:Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG)

Paint 的 API 大致可以分為 4 類:

  • 顏色
  • 效果
  • drawText() 相關
  • 初始化

凱哥專門拿了一期對 Paint 做了重點講解,依然在實際場景應該用處不大,所以需要的直接點選 這裡 跳轉。

如果你想先知道凱哥都講了什麼,我這裡也單獨給你總結一下:

首先是給 Paint 設定著色器。

  • Paint.setShader(Shader shader):設定著色器,實際上我們一般傳遞的引數不會直接傳遞 Shader,而會選擇直接傳遞它的子類,具體效果下面給出。
    • 線性漸變:LinearGradient(float x0, float y0, float x1, float y1, int color0, int color1,TileMode tile)
      圖片來自扔物線
      圖片來自扔物線
    • 輻射漸變:RadialGradient(float centerX, float centerY, float radius,
         int centerColor, int edgeColor, @NonNull TileMode tileMode)複製程式碼
      圖片來自扔物線
      圖片來自扔物線
    • 掃描漸變:SweepGradient(float cx, float cy, int color0, int color1)
      圖片來自扔物線
      圖片來自扔物線

      還有很多,就不一一給圖了。
    • BitmapShader(@NonNull Bitmap bitmap, TileMode tileX, TileMode tileY)
    • 混合著色:ComposeShader(Shader shaderA, Shader shaderB, PorterDuff.Mode mode)

其中需要注意的是:

  • Paint.setShader() 優先順序高於 Paint.setColor() 系列方法。
  • 最後一個 tile 引數,代表的是斷點範圍之外的著色規則。它是一個列舉型別,有三種引數。
    • CLAMP : 直譯是「夾子模式」,會在端點之外延續端點處的顏色。
    • MIRROR : 映象模式。
    • REPEAT : 重複模式。

其次是設定顏色過濾

設定顏色過濾可以採用 Paint.setColorFilter(ColorFilter colorFilter) 方法。它的名字已經足夠解釋它的作用:為繪製設定顏色過濾。顏色過濾的意思,就是為繪製的內容設定一個統一的過濾策略,然後 Canvas.drawXXX() 方法會對每個畫素都進行過濾後再繪製出來。

這個其實貌似在拍照或者照片整理類應用上用的比較多,其它方面貌似我還很少遇到過,GitHub 上的庫 StyleImageView 詮釋的很棒。

再其它也就沒啥好說的,感興趣直接去看 HenCoder

這裡可以重點說一下:Paint.setStrokeCap(Paint.Cap cap),設定線頭的形狀。線頭形狀有三種:BUTT 平頭、ROUND 圓頭、SQUARE 方頭。預設為 BUTT

圖片來自扔物線
圖片來自扔物線

虛線是額外加的,虛線左邊是線的實際長度,虛線右邊是線頭。有了虛線作為輔助,可以清楚地看出 BUTT 和 SQUARE 的區別。

Canvas 的文字繪製

Canvas 的文字繪製方法有三個:

  • drawText()
  • drawTextRun()
  • drawTextOnPath()

我們大多數情況用不了那麼多,所以同樣這裡不做詳解,對於始終想追根到底的同學,同樣給你提供了 凱哥的連結

下面只對部分需要注意的重點總結一下。

drawText()

  • drawText(@NonNull String text, float x, float y, @NonNull Paint paint)
    其中的引數很簡單:text 是文字內容,x 和 y 是文字的座標。但需要注意:這個座標並不是文字的左上角,而是一個與左下角比較接近的位置。大概在這裡:
    圖片來自扔物線
    圖片來自扔物線

    而如果你像繪製其他內容一樣,在繪製文字的時候把座標填成 (0, 0),文字並不會顯示在 View 的左上角,而是會幾乎完全顯示在 View 的上方,到了 View 外部看不到的位置:
    canvas.drawText(text, 0, 0, paint);
    大概是這樣:
    圖片來自扔物線
    圖片來自扔物線

另外,Canvas.drawText() 只能繪製單行的文字,而不能換行。就算顯示不完,也會直接繪製到螢幕外面去。

那如果要換行,得 drawText() 很多次嗎?並沒有,還有一個 StaticLayout 可以完美達到我們的效果。對於詳細使用,這裡也不多提了。

drawTextRun()drawTextOnPath(),運用的可能並不多,這裡就不說了。

簡單提一下設定效果輔助類吧,這個可能直接就有用。

Paint 對文字繪製的輔助

  • 設定文字大小:Paint.setTextSize(float textSize)
  • 設定字型:Paint.setTypeface(Typeface typeface),其中的 Typeface 裡面涵蓋了相關字型。另外,還可以通過 Typeface.createFromAsset(AssetManager mgr, String path) 來設定自定義字型,其中 mgr 可以給 getResources().getAssets()path 給檔名字,需要把字型檔案 .ttf 放在工程的 res/assets 下,「assets」是新建的專用目錄。
  • 設定文字是否加粗:Paint.setFakeBoldText(boolean fakeBoldText)
  • 設定文字是否加刪除線:Paint.setStrikeThruText(boolean strikeThruText)
  • 設定文字是否加下劃線:Paint.setUnderlineText(boolean underlineText)
  • 設定字型傾斜度:Paint.setTextSkewX(float skewX) 「skewX」 向左傾斜為正。
  • 設定文字橫向放縮:Paint.setTextScaleX(float scaleX)
  • 設定字型間距,預設值為 0:Paint.setLetterSpacing(float letterSpacing) 這個不是行間距哦。
  • 設定文字對齊方式:Paint.setTextAlign(Paint.Align align),其中「align」有三個值:LEFTCENTERRIGHT,預設值是 LEFT
  • 設定繪製所使用的 Locale:Paint.setTextLocale(Locale locale) / Paint.setTextLocales(LocaleList locales)

實際上,這些方法基本都在我們 TextView 裡面的。

自定義 View 之範圍裁切

範圍裁切主要採用兩個方法:

  • clipRect()
  • clipPath()

clipRect() 很簡單,只需要傳遞和 RectF 一樣的引數即可。你可以除了裁剪矩形,還想做其它樣式的裁剪,可惜這裡只有通過 path 的方法了(我也很奇怪為啥沒有看到其它方法),再一次印證了 path 的重要性有木有。

值得注意的是:我們通常會在範圍裁切前後加上 Canvas.save()Canvas.restore() 來及時恢復繪製範圍。大概程式碼是這樣。

canvas.save();  
canvas.clipRect(left, top, right, bottom);  
canvas.drawBitmap(bitmap, x, y, paint);  
canvas.restore();複製程式碼

另一個值得注意的點是:一定是先做範圍裁切操作,再做 Canvas.drawXXX() 操作,順序放反的話你會發現毛效果都沒有。除了裁切,幾何變換也是如此。

幾何變換

幾何變換的使用大概分為三類:

  • 使用 Canvas 來做常見的二維變換;
  • 使用 Matrix 來做常見和不常見的二維變換;
  • 使用 Camera 來做三維變換

直接採用 Canvas 自帶方法進行二維變換

  • Canvas.translate(float dx, float dy)
    平移,其中,dx 和 dy 分別表示橫向和縱向的位移。
  • Canvas.rotate(float degrees, float px, float py)
    旋轉,其中 degrees 是旋轉角度,順時針為正向,pxpy 代表軸心座標。
  • Canvas.scale(float sx, float sy, float px, float py)
    放縮,其中 sx,sy 分別是橫向和縱向的放縮倍數,px 、py 為放縮的軸心,這裡千萬不要受到過載方法 Canvas.scale(float sx,float sy) 的影響。
  • skew(float sx, float sy)
    錯切。這裡的 sx 和 sy 分別是 x 方向和 y 方向的錯切係數。值得注意的是,這裡 sx 和 sy 值為 0 的時候代表自己的方向不錯切。

再次重申,需要先做了二維變換,再執行 「drawXXX」操作,重要的事情一定會說三遍。

二維變換的另一種方式 —— Matrix

Matrix 做常見變換的基本套路

  • 建立 Matrix 物件;
  • 呼叫 Matrix 的 pre/postTranslate/Rotate/Scale/Skew() 方法來設定幾何變換;
  • 使用 Canvas.setMatrix(matrix) 或 Canvas.concat(matrix) 來把幾何變換應用到 Canvas。
Matrix matrix = new Matrix();

...

matrix.reset();  
matrix.postTranslate();  
matrix.postRotate();

canvas.save();  
canvas.concat(matrix);  
canvas.drawBitmap(bitmap, x, y, paint);  
canvas.restore();複製程式碼

把 Matrix 應用到 Canvas 有兩個方法: Canvas.setMatrix(matrix)Canvas.concat(matrix)

  • Canvas.setMatrix(matrix):用 Matrix 直接替換 Canvas 當前的變換矩陣,即拋棄 Canvas 當前的變換,改用 Matrix 的變換(注:根據凱哥收到的反饋,不同的系統中 setMatrix(matrix) 的行為可能不一致,所以還是儘量用 concat(matrix) 吧);
  • Canvas.concat(matrix):用 Canvas 當前的變換矩陣和 Matrix 相乘,即基於 Canvas 當前的變換,疊加上 Matrix 中的變換。

其中需要注意的是:當多個 Matrix 需要用到的時候,你並不需要初始化多個 Matrix,而可以直接通過呼叫 Matrix.reset()Matrix 進行重置。

對於採用 Matrix 來實現不規則變換以及採用 Camera 實現三維變換這裡也就不多說了,實際遇到的時候,你也可以 點選這裡 複習一下呀。

精彩的繪製順序

前面講了一大堆繪製方法,以及範圍裁切和變換,我們這裡再說說繪製順序。

Android 裡面的繪製都是按順序的,先繪製的內容會被後繪製的蓋住。比如你在重疊的位置先畫圓再畫方,和先畫方再畫圓所呈現出來的結果肯定是不同的:

圖片來自扔物線
圖片來自扔物線

到底放在 super.onDraw() 上面還是下面?

通常如果我們繼承的是 View 的話,super.onDraw() 只是一個空實現,所以它的位置放在哪兒都沒事,甚至直接不要也沒事,但反正加上也沒啥影響,儘量還是加上吧。

由於 Android 的繪製順序性,當你繼承自已經有繪製的其他 View(比如 TextView)的時候,放在 super.onDraw() 上面就意味著繪製程式碼會被控制元件的原內容蓋住。

dispatchDraw():繪製子 View 的方法

還記得我上面賣的關子嗎?自定義繪製其實不止 onDraw() 一個方法。onDraw() 只是負責自身主體內容繪製的。而有的時候,你想要的遮蓋關係無法通過 onDraw() 來實現,而是需要通過別的繪製方法。

凱哥這塊真的寫的是太有意思了,所以我也是直接 copy 了過來。

例如,你繼承了一個 LinearLayout,重寫了它的 onDraw() 方法,在 super.onDraw() 中插入了你自己的繪製程式碼,使它能夠在內部繪製一些斑點作為點綴:

public class SpottedLinearLayout extends LinearLayout {  
    ...

    protected void onDraw(Canvas canvas) {
       super.onDraw(canvas);

       ... // 繪製斑點
    }
}複製程式碼

圖片來自扔物線
圖片來自扔物線

看起來確實沒有問題,但是你會發現,當你新增了子 View 之後,你的斑點不見了:
圖片來自扔物線
圖片來自扔物線

造成這種情況的原因是 Android 的繪製順序:在繪製過程中,每一個 ViewGroup 會先呼叫自己的 onDraw() 來繪製完自己的主體之後再去繪製它的子 View。對於上面這個例子來說,就是你的 LinearLayout 會在繪製完斑點後再去繪製它的子 View。那麼在子 View 繪製完成之後,先前繪製的斑點就被子 View 蓋住了。

具體來講,這裡說的「繪製子 View」是通過另一個繪製方法的呼叫來發生的,這個繪製方法叫做:dispatchDraw()。也就是說,在繪製過程中,每個 View 和 ViewGroup 都會先呼叫 onDraw() 方法來繪製主體,再呼叫 dispatchDraw() 方法來繪製子 View。

注:雖然 View 和 ViewGroup 都有 dispatchDraw() 方法,不過由於 View 是沒有子 View 的,所以一般來說 dispatchDraw() 這個方法只對 ViewGroup(以及它的子類)有意義。

圖片來自扔物線
圖片來自扔物線

回到剛才的問題:怎樣才能讓 LinearLayout 的繪製內容蓋住子 View 呢?只要讓它的繪製程式碼在子 View 的繪製之後再執行就好了。所以直接執行在 super.dispatchDraw() 的下面即可。

簡單總結一下繪製順序

凱哥確實強勢,在文章的最後,直接貼圖,不能再清晰了,所以我也是直接跳過了其中 N 個環節,直接上圖。

圖片來自扔物線
圖片來自扔物線

圖片來自扔物線
圖片來自扔物線

注意:

  • 在 ViewGroup 的子類中重寫除 dispatchDraw() 以外的繪製方法時,可能需要呼叫 setWillNotDraw(false)
  • 在重寫的方法有多個選擇時,優先選擇 onDraw()

寫在最後

本期的自定義 View 之繪製就到這裡結束了,強烈推薦 點選連結 跟著凱哥操,不得挨飛刀。

做不完的開源,寫不完的矯情。歡迎掃描下方二維碼或者公眾號搜尋「nanchen」關注我的微信公眾號,目前多運營 Android ,儘自己所能為你提升。如果你喜歡,為我點贊分享吧~
nanchen
nanchen

相關文章