自定义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方法将角度转换为弧度

看下运行结果