我們知道一個自定義view一般來說需要繼承view或者viewGroup並實現onMeasure, onLayout, onDraw方法。 其中onMeasure用於測量計算該控件的寬高, onLayout用來確定控件的擺放位置,onDraw執行具體的繪制動作。
今天主要學習onDraw
先看下demo效果

在正式開始之前, 我們先要了解一些基本知識
1, 坐標系

2, 像素(px)與dp
繪制過程中所有的尺寸單位都是px
通常我們在xml中用dp或者sp來表示距離或者字體大小, 這是為了自動適配各種不同的分辨率,在實際運行時, Android系統會根據不同手機的屏幕密度 幫助我們把dp轉成px
但是到了繪制階段,就已經是在和屏幕對話了,是實際執行階段的代碼,這發生在android系統幫我們轉換px之后, 所以繪制過程中我們只能用px
那么用px的話,如何保證我們畫出來的圖形在不同分辨率的手機上都能顯示大致相同的大小呢?
android為我們提供了一個方法來完成像素的轉換
1 public static float applyDimension(int unit, float value, 2 DisplayMetrics metrics) 3 { 4 switch (unit) { 5 case COMPLEX_UNIT_PX: 6 return value; 7 case COMPLEX_UNIT_DIP: 8 return value * metrics.density; 9 ...... 10 }
那么我們就可以定義一個擴展函數來完成這個轉換,如
1 val Float.toPx 2 get() = TypedValue.applyDimension( 3 TypedValue.COMPLEX_UNIT_DIP, 4 this, 5 Resources.getSystem().displayMetrics)
這里的Resources.getSystem().displayMetrics獲取的就是當前手機系統的displayMetrics
1 /** 2 * Return the current display metrics that are in effect for this resource object. 3 * The returned object should be treated as read-only. 4 */ 5 public DisplayMetrics getDisplayMetrics() { 6 return mResourcesImpl.getDisplayMetrics(); 7 }
3,paint 油漆
在Kotlin中, 我們可以通過 val paint = Paint()來獲取一個paint對象
1 /** 2 * Create a new paint with default settings. 3 */ 4 public Paint() { 5 this(0); 6 }
但是實際應用中, 我們通常會傳入一個flag叫做ANTI_ALIAS_FLAG , 它的作用是允許抗鋸齒, 讓我們畫出來的圖形更加圓滑
1 /** 2 * Paint flag that enables antialiasing when drawing. 3 * 4 * <p>Enabling this flag will cause all draw operations that support 5 * antialiasing to use it.</p> 6 * 7 * @see #Paint(int) 8 * @see #setFlags(int) 9 */ 10 public static final int ANTI_ALIAS_FLAG = 0x01;
4, canvas 畫布
我們知道在onDraw方法中,會傳入一個canvas對象, canvas有很多方法可以幫我們進行繪制的動作
如 drawLine, drawArc, drawCircle, drwaRect, drawText, drawPoint等等
如我們要畫一條直線
class DashBoardView(context: Context, attributes: AttributeSet) : View(context,attributes){ private val paint = Paint(ANTI_ALIAS_FLAG) override fun onDraw(canvas: Canvas) { canvas.drawLine(100f.toPx, 100f.toPx,200f.toPx,200f.toPx, paint) } }

5, path 路徑
比如我們想畫一個圓, 除了直接調用canvas.drawCircle()方法之外,還有一種方法是
先調用path.addCircle()定義一個圓的路徑, 然后再調用canvas.drawPath()方法來完成繪制,如:
1 class DashBoardView(context: Context, attributes: AttributeSet) : View(context,attributes){ 2 3 private val paint = Paint(ANTI_ALIAS_FLAG) 4 private val path = Path() 5 6 override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { 7 path.reset() 8 path.addCircle(width/2f, height/2f, 100f.toPx, Path.Direction.CCW) 9 } 10 11 override fun onDraw(canvas: Canvas) { 12 canvas.drawPath(path, paint) 13 } 14 }
注意, 不要在onDraw方法里執行對象創建的工作,因為onDraw會被頻繁調用
對path的初始化應該放在onSizeChanged方法里, 當size改變時(比如父容器發生變化),應該對path進行reset
另外我們看到path方法里傳入了一個direction參數,表示繪制的方向。 該參數有兩種取值 Path.Direction.CW表示順時針(clockwise) , Path.Direction.CCW表示逆時針(counter-clockwise) , 其作用是當繪制多個圖形時,與fillType一起決定圖形相交的部分是填充還是縷空。
我們再畫一個和圓相交的矩形來演示一下
1 //定義圓的半徑 2 val RADIUS = 100f.toPx 3 class DashBoardView(context: Context, attributes: AttributeSet) : View(context,attributes){ 4 5 private val paint = Paint(ANTI_ALIAS_FLAG) 6 private val path = Path() 7 8 override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { 9 path.reset() 10 path.addCircle(width/2f, height/2f, RADIUS, Path.Direction.CCW) 11 path.addRect(width/2f- RADIUS, height/2f, width/2f+ RADIUS, height/2f+2* RADIUS, Path.Direction.CCW) 12 } 13 14 override fun onDraw(canvas: Canvas) { 15 canvas.drawPath(path, paint) 16 } 17 }
當圓和矩形都是逆時針來畫時,我們看到相交的部分被填充了

現在我們把矩形的path方向改為順時針
1 path.addRect(width/2f- RADIUS, height/2f, width/2f+ RADIUS, height/2f+2* RADIUS, Path.Direction.CW)

可以看到相交的部分被縷空。 上文中我們說方向是和fillType一起決定是否縷空相交部分, 當我們沒有去設置fillType時,path的默認fillType是 FillType.WINDING,
path里定義了四種fillType,
1 static final FillType[] sFillTypeArray = { 2 FillType.WINDING, 3 FillType.EVEN_ODD, 4 FillType.INVERSE_WINDING, 5 FillType.INVERSE_EVEN_ODD 6 };
WINDING模式會根據direction來判斷是否填充,方向相同則填充,不同則縷空 。 EVEN_ODD則是不考慮方向,相交部分一律縷空。 另外兩種分別是這兩種的反向填充情況,如下圖

好,啰嗦完了,我們進入正題
一個簡單的儀表盤包括弧, 刻度, 指針,
1) 那么第一步我們先來畫狐
1 canvas.drawArc(width/2f- RADIUS, 2 height/2f- RADIUS, 3 width/2f+ RADIUS, 4 height/2f + RADIUS, 5 ?, 6 ?, 7 false, 8 paint)
該方法傳入的前四個值分別為left, top, right, bottom, 就是根據這些來確定圓(這里也可以理解為矩形)的位置
useCenter 的意思就是是否要讓你畫出來的弧閉合
startAngle和sweepAngle表示該弧的起始角度和掃描角度, 這個角度怎么計算呢?

畫上坐標系,看圖就明白了, 假設弧的開口角度是120, 那么起始角度就是90+120/2,
掃描角度是指弧形掃過的角度,顯然,它等於360-開口角度
傳入角度之后我們得到這樣的效果

我們看到,現在畫出來的弧內部都被填充了, 我們修改下paint, 讓它畫線條

這里就顯示了useCenter的作用, 為true時它自動以圓心為中點幫我們加了兩條線,把弧閉合了
我們把它改成false, 現在就得到了想要的弧

2) 第二步, 我們開始畫刻度
這里我們需要了解另一個方法
paint.pathEffect = PathDashPathEffect()
1 /** 2 * Dash the drawn path by stamping it with the specified shape. This only 3 * applies to drawings when the paint's style is STROKE or STROKE_AND_FILL. 4 * If the paint's style is FILL, then this effect is ignored. The paint's 5 * strokeWidth does not affect the results. 6 * @param shape The path to stamp along 7 * @param advance spacing between each stamp of shape 8 * @param phase amount to offset before the first shape is stamped 9 * @param style how to transform the shape at each position as it is stamped 10 */ 11 public PathDashPathEffect(Path shape, float advance, float phase, 12 Style style) { 13 native_instance = nativeCreate(shape.readOnlyNI(), advance, phase, 14 style.native_style); 15 }
paint.pathEffect就是設置path的效果,
PathDashPathEffect就是我們用path來畫虛線, 上面方法中的參數 advance表示虛線每個點之間的距離,表示一共要畫多少個點phase
了解上面方法之后,我們就能想到,可以把每個刻度當成一個小矩形, 然后沿着第一步得到的弧, 用小矩形來畫一條虛線
那么每個矩形的位置如何確定呢?
我們先確定矩形的長寬,如
1 val DASH_WIDTH = 3f.toPx 2 val DASH_HEIGHT = 10f.toPx
因為畫矩形的Path每次的起點都在弧上,所以我們以該起點為坐標原點,畫上坐標系

結合坐標系,我們現在就很容易得到:
dashPath.addRect(0f, 0f, DASH_WIDTH, DASH_HEIGHT, Path.Direction.CCW )
有了小矩形, 我們再來看PathDashPathEffect(Path shape, float advance, float phase, Style style) 的第二個參數,間隔
間隔是需要計算的, 比如我們要畫20個刻度, 那么間隔就是弧的總長度除以20, 那么弧的總長度怎么得到呢?
android為我們提供了pathMeasure
所以現在我們改用path來畫弧
1 //畫弧的path 2 private val arcPath = Path() 3 4 arcPath.addArc(width/2f- RADIUS, 5 height/2f- RADIUS, 6 width/2f+ RADIUS, 7 height/2f + RADIUS, 8 90f+ OPEN_ANGLE/2f, 9 360f- OPEN_ANGLE)
那么就可以得到弧的長度
val pathMeasure = PathMeasure(arcPath, false) val length = pathMeasure.length
那么(length-DASH_WIDTH)/20 就等於刻度間距 這里減去DASH_WIDTH是因為: 20個間隔其實是21個刻度
所以完整代碼如下
1 //定義圓的半徑 2 val RADIUS = 150f.toPx 3 //定義儀表盤的開口角度 4 const val OPEN_ANGLE = 120 5 //定義矩形的寬高 6 val DASH_WIDTH = 2f.toPx 7 val DASH_HEIGHT = 10f.toPx 8 class DashBoardView(context: Context, attributes: AttributeSet) : View(context,attributes){ 9 10 private val paint = Paint(ANTI_ALIAS_FLAG) 11 //小矩形的path 12 private val dashPath = Path() 13 //畫弧的path 14 private val arcPath = Path() 15 // 16 lateinit var pathEffect: PathDashPathEffect 17 18 init { 19 paint.strokeWidth = 3f.toPx 20 paint.style = Paint.Style.STROKE 21 dashPath.addRect(0f, 0f, DASH_WIDTH, DASH_HEIGHT, Path.Direction.CCW ) 22 } 23 24 override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { 25 arcPath.reset() 26 arcPath.addArc(width/2f- RADIUS, 27 height/2f- RADIUS, 28 width/2f+ RADIUS, 29 height/2f + RADIUS, 30 90f+ OPEN_ANGLE/2f, 31 360f- OPEN_ANGLE) 32 val pathMeasure = PathMeasure(arcPath, false) 33 val length = pathMeasure.length 34 pathEffect = PathDashPathEffect(dashPath, (pathMeasure.length - DASH_WIDTH)/20f, 0f,PathDashPathEffect.Style.ROTATE) 35 } 36 37 override fun onDraw(canvas: Canvas) { 38 //先畫一條弧 39 canvas.drawPath(arcPath, paint) 40 //再畫虛線(刻度) 41 paint.pathEffect = pathEffect 42 canvas.drawPath(arcPath, paint) 43 paint.pathEffect = null 44 } 45 }
運行結果:

3)現在進行第三步, 畫儀表指針
儀表指針好像很簡單, 畫一條線就行

嗯。。。。線的起點我們是知道的, 可是。。。終點怎么算呢

如圖, 指針長度是已定的, 角度也可以得到, 那么根據三角定理就可以算出a和b的值, 即終點位置

上面看到是銳角的情況, 事實上同樣的公式也適用於鈍角。這里不明白的可以復習下數學啊
所以對長度為length,角度為angle的儀表指針, 它的終點坐標就是 (length*cos(angle), length*sin(angle))
那么下一個問題,角度怎么計算呢?

如圖, 第三個刻度的角度就等於(360-OPEN_ANGLE)*20/3 + 90+ OPEN_ANGLE/2
1 //畫指針 2 canvas.drawLine(width/2f, height/2f, 3 (width/2f+ LENGTH* cos(markToRadians(3))).toFloat(), 4 (height/2f + LENGTH* sin(markToRadians(3))).toFloat(), 5 paint) 6 7 8 private fun markToRadians(mark: Int): Double { 9 return Math.toRadians(((360f-OPEN_ANGLE)/20*mark + 90f+ OPEN_ANGLE/2f).toDouble()) 10 }
注意這里的cos(), sin()以及toRadians()方法
1 /** Computes the cosine of the angle [x] given in radians. 2 * 3 * Special cases: 4 * - `cos(NaN|+Inf|-Inf)` is `NaN` 5 */ 6 @SinceKotlin("1.2") 7 @InlineOnly 8 public actual inline fun cos(x: Double): Double = nativeMath.cos(x)
cos()/sin()方法接收的角度參數是 given in radians--- 弧度
所以我們需要調用 Math.toRadians方法將角度轉換為弧度
完整代碼如下
1 //定義圓的半徑 2 val RADIUS = 150f.toPx 3 //定義儀表盤的開口角度 4 const val OPEN_ANGLE = 120 5 //定義矩形的寬高 6 val DASH_WIDTH = 2f.toPx 7 val DASH_HEIGHT = 10f.toPx 8 //另一層刻度的矩形 9 val LARGE_DASH_WIDTH = 4f.toPx 10 val LARGE_DASH_HEIGHT = 20f.toPx 11 //定義指針的長度 12 val LENGTH = 100f.toPx 13 class DashBoardView(context: Context, attributes: AttributeSet) : View(context,attributes){ 14 15 private val paint = Paint(ANTI_ALIAS_FLAG) 16 //小矩形的path 17 private val dashPath = Path() 18 //大矩形的path 19 private val largeDashPath = Path() 20 //畫弧的path 21 private val arcPath = Path() 22 // 23 lateinit var pathEffect: PathDashPathEffect 24 lateinit var largePathEffect: PathDashPathEffect 25 26 init { 27 paint.strokeWidth = 3f.toPx 28 paint.style = Paint.Style.STROKE 29 dashPath.addRect(0f, 0f, DASH_WIDTH, DASH_HEIGHT, Path.Direction.CCW ) 30 largeDashPath.addRect(0f, 0f, LARGE_DASH_WIDTH, LARGE_DASH_HEIGHT,Path.Direction.CCW) 31 } 32 33 override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { 34 arcPath.reset() 35 arcPath.addArc(width/2f- RADIUS, 36 height/2f- RADIUS, 37 width/2f+ RADIUS, 38 height/2f + RADIUS, 39 90f+ OPEN_ANGLE/2f, 40 360f- OPEN_ANGLE) 41 val pathMeasure = PathMeasure(arcPath, false) 42 pathEffect = PathDashPathEffect(dashPath, (pathMeasure.length - DASH_WIDTH)/20f, 0f,PathDashPathEffect.Style.ROTATE) 43 largePathEffect = PathDashPathEffect(largeDashPath, (pathMeasure.length - DASH_WIDTH)/10f, 0f,PathDashPathEffect.Style.ROTATE) 44 45 } 46 47 override fun onDraw(canvas: Canvas) { 48 //先畫一條弧 49 canvas.drawPath(arcPath, paint) 50 //再畫虛線(刻度) 51 paint.pathEffect = pathEffect 52 canvas.drawPath(arcPath, paint) 53 //畫另一層刻度 54 paint.pathEffect = largePathEffect 55 paint.color = Color.parseColor("#FF03DAC5") 56 canvas.drawPath(arcPath, paint) 57 paint.pathEffect = null 58 //畫指針 59 paint.color = Color.BLACK 60 canvas.drawLine(width/2f, height/2f, 61 (width/2f+ LENGTH* cos(markToRadians(15))).toFloat(), 62 (height/2f + LENGTH* sin(markToRadians(15))).toFloat(), 63 paint) 64 } 65 66 private fun markToRadians(mark: Int): Double { 67 return Math.toRadians(((360f-OPEN_ANGLE)/20*mark + 90f+ OPEN_ANGLE/2f).toDouble()) 68 } 69 }
1 <?xml version="1.0" encoding="utf-8"?> 2 <com.example.mytest.view.DashBoardView xmlns:android="http://schemas.android.com/apk/res/android" 3 xmlns:tools="http://schemas.android.com/tools" 4 android:layout_width="match_parent" 5 android:layout_height="match_parent" 6 tools:context=".ui.dashboard.DashboardFragment"> 7 8 </com.example.mytest.view.DashBoardView>
