自定義view—儀錶盤–kotlin

  • 2021 年 10 月 14 日
  • 筆記

我們知道一個自定義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方法將角度轉換為弧度

看下運行結果