面試系列 不繼續了嗎?
知道我的人都知道,之前我寫了這個 面試系列宣言,如今好像一直都沒有連載,而是隔三差五地來一篇,其實也是因為筆者也能力有限,構思一篇文章需要足夠的時間去印證其準確性,而之前的部分就因為印證不夠造成了勘誤。
值得注意的是,本系列不會停止的。面試的很多知識點在於平時的積累,但自定義 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」有三個值:LEFT
、CENTER
和RIGHT
,預設值是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
是旋轉角度,順時針為正向,px
和py
代表軸心座標。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 ,儘自己所能為你提升。如果你喜歡,為我點贊分享吧~