Android 自定义 View:从绘制基础到实战仪表盘与饼图

绘制的基本要素

  • onDraw(Canvas): 我们通常会通过重写 onDraw() 方法来实现自定义绘制,在方法内部进行绘制。

  • canvas: Canvas: Canvas 是实际用于绘制的工具。绘制时,会调用 Canvas 的各种方法。

  • paint: Paint: Paint 是用于调整绘制风格的,比如颜色、线条粗细等。

再来看看坐标系,它和数学中的坐标系不同,y 轴上下颠倒了。

另外,由于 y 轴向下了,角度的增长方向变为了顺时针。现在,0 度角还是在 x 轴正方向,但 45 度角会指向右下方。

最后,绘制时的所有尺寸单位 都是 px(像素),而不是 dp(密度无关像素)。

因为到了绘制阶段,我们直接与屏幕交互。而 dp 的作用是在不同屏幕密度的设备上,保证 UI 布局和元素尺寸的显示一致性。dp 在实际运行时,会转换为 px,转换的公式是:px = dp * (dpi / 160)。例如,在 240dpi 的屏幕上,1dp 就等于 1.5 个像素。

这个转换过程会自动由安卓系统完成,如果你要在 onDraw() 方法中使用 dp 作为单位,需要自己手动转换为像素。

了解了这些,我们来看几个例子。

简单示例

先创建一个 TestView 类,继承自 View,并重写 onDraw() 方法。

kotlin 复制代码
class TestView(context: Context?, attrs: AttributeSet?) : View(context, attrs) {
    override fun onDraw(canvas: Canvas) {
        super.onDraw(canvas)
    }
}

然后在 activity_main 布局中,使用这个自定义的控件。

xml 复制代码
<?xml version="1.0" encoding="utf-8"?>
<com.example.customviewfirst.TestView xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content" />

画线

先来画线,我们调用 Canvas.drawLine() 方法来绘制。

kotlin 复制代码
public void drawLine(float startX, float startY, float stopX, float stopY,
        @NonNull Paint paint) {
    super.drawLine(startX, startY, stopX, stopY, paint);
}

一条直线,可以由两个点来确定,所以需要填入两个点的横坐标和纵坐标。另外,该方法还需要传入 Paint 对象。所以我们需要创建一个该对象。

kotlin 复制代码
class TestView(context: Context?, attrs: AttributeSet?) : View(context, attrs) {
    
    private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
    
    override fun onDraw(canvas: Canvas) {
        canvas.drawLine(200f, 200f,400f, 400f, paint)
    }
}

传入的 Paint.ANTI_ALIAS_FLAG抗锯齿的标志,让绘制内容自动抗锯齿。一般情况下都会开启,让显示更平滑。

运行一下,结果如图:

可以看到,我们成功在屏幕上绘制了一条直线。

画圆

我们再来画圆,调用 Canvas.drawCircle() 方法来绘制。

kotlin 复制代码
public void drawCircle(float cx, float cy, float radius, @NonNull Paint paint) {
    super.drawCircle(cx, cy, radius, paint);
}

画圆分别需要填入圆心横、纵坐标,半径,以及 Paint 对象。代码可以这样写:

kotlin 复制代码
companion object {
    const val RADIUS = 100f
}

override fun onDraw(canvas: Canvas) {
    canvas.drawCircle(
        width / 2f,
        height / 2f,
        TypedValue.applyDimension(
            TypedValue.COMPLEX_UNIT_DIP, // 单位
            RADIUS, // 值
            resources.displayMetrics 
        ),
        paint
    )
}

此时半径的单位是 dp,我们将其转为了像素,然后填入了方法参数中。其中 TypedValue.applyDimension 可用于将各种单位的值转换成像素值。

运行结果为:

我们可以将 dppx 的代码,抽取成一个方法:

kotlin 复制代码
class Utils {
    companion object {
        fun dp2Px(value: Float, view: View) = TypedValue.applyDimension(
            TypedValue.COMPLEX_UNIT_DIP,
            value,
            view.resources.displayMetrics
        )
    }
}

TestView 中调用时,只需这样:Utils.dp2Px(RADIUS, this)

不过 Resources 对象,也可以通过 Context 对象来获取,我们可以改为这样:

kotlin 复制代码
fun dp2Px(value: Float, context: Context) = TypedValue.applyDimension(
    TypedValue.COMPLEX_UNIT_DIP,
    value,
    context.resources.displayMetrics
)

让方法更加通用,无需传入一个 View 对象。但你还是会发现,这只是个转换单位的方法,为什么需要 Context 参数。

这个 Context 上下文中包含了软件的各种信息,比如 colors.xml 文件中定义的颜色值,strings.xml 文件中定义的字符串值,也包括了该方法需要的系统像素密度。

但这些配置信息我们都不需要,所以可以使用一个 Resources.getSystem() 方法来获取 Resources 对象,这样 dp2Px() 方法就可以去掉 context 参数了:

kotlin 复制代码
fun dp2Px(value: Float) = TypedValue.applyDimension(
    TypedValue.COMPLEX_UNIT_DIP,
    value,
    Resources.getSystem().displayMetrics
)

调用时,只需这样:Utils.dp2Px(RADIUS)Resources.getSystem() 方法只能拿到系统相关的上下文,不过对于我们来说,足够了。

另外,我们还可以对 dp2Px() 方法,将其定义为 kotlin 的扩展属性。创建 Extensions.kt 文件,代码如下:

kotlin 复制代码
val Float.px
    get() = TypedValue.applyDimension(
        TypedValue.COMPLEX_UNIT_DIP,
        this,
        Resources.getSystem().displayMetrics
    )

现在,我们需要转换 dp 到 px 时,只需这样:RADIUS.px

绘制路径

接着看看绘制路径,通过 drawPath() 方法来绘制一个圆。

kotlin 复制代码
public void drawPath(@NonNull Path path, @NonNull Paint paint) {
    super.drawPath(path, paint);
}

我们需要传入一个 Path 对象,并且对其进行配置。配置的地方需要放在 View 尺寸改变的时候,因为这时,Path 可能改变。

代码如下:

kotlin 复制代码
class TestView(context: Context?, attrs: AttributeSet?) : View(context, attrs) {

    private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
    private val path = Path()

    companion object {
        const val RADIUS = 100f
    }
    
    override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
        // 重置路径
        path.reset()
        // 创建圆形路径
        path.addCircle(width / 2f, height / 2f, RADIUS.px, Path.Direction.CW)
    }

    override fun onDraw(canvas: Canvas) {
        canvas.drawPath(path, paint)
    }
    
}

运行结果:

其中 addCircle() 方法的最后一个参数是绘制的方向,有两个值可选:

java 复制代码
// Path.java
public enum Direction {
    /** clockwise */ // 顺时针方向
    CW  (0),    
    /** counter-clockwise */ // 逆时针方向
    CCW (1);    
    ...
}

它的作用并不在于绘制单个图形的时候,而是用于当多个图像相交时,判断相交部分填充还是留空,也就是相交的部分属于内部还是外部。

比如我们再绘制一个圆,运行发现并没有绘制其相交部分。

kotlin 复制代码
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
    // 重置路径
    path.reset()
    // 创建圆形路径
    path.addCircle(width / 2f, height / 2f, RADIUS.px, Path.Direction.CW)
    path.addCircle(width / 2f, height / 2f + RADIUS.px, RADIUS.px, Path.Direction.CCW)
}

中间这部分是否填充,是由填充规则决定的。填充规则有两种,分别是 WINDINGEVEN_ODD,默认是 WINDING

INVERSE_WINDINGINVERSE_EVEN_ODD 只是反着的 WINDINGEVEN_ODD

java 复制代码
// Path.java
public enum FillType {
    // these must match the values in SkPath.h
    /**
     * Specifies that "inside" is computed by a non-zero sum of signed
     * edge crossings.
     */
    WINDING         (0),
    /**
     * Specifies that "inside" is computed by an odd number of edge
     * crossings.
     */
    EVEN_ODD        (1),
    /**
     * Same as {@link #WINDING}, but draws outside of the path, rather than inside.
     */
    INVERSE_WINDING (2),
    /**
     * Same as {@link #EVEN_ODD}, but draws outside of the path, rather than inside.
     */
    INVERSE_EVEN_ODD(3);
}

为什么刚刚两个圆相交的部分是留空呢?

因为在默认的填充类型下,程序是这样判断的:在封闭图形内,任意找一点,向外射出一条任意方向的射线,假设点的绘制方向为逆时针就加 1,顺时针就减 1。如果该射线所有相交的点的和为零(相切不算)就为外部,留空;否则都算作内部,填充。

当然点的绘制方向为逆时针减 1,顺时针加 1 也行。

就拿刚刚的例子来说:

我们可以很轻松判断,从中间这部分往外射出的线,会先遇到一个逆时针绘制的点(+1),再遇到一个顺时针绘制的点(-1),其值和为零,所以是留空。

注意:不管方向如何,每个方向的值都是一致的,所以判断时,尽量找简单的方向。

WINDING 填充规则下,你需要注意每个路径的方向,这样才能控制每个部分是否填充。不过当你需要镂空的图形时,我们会使用 EVEN_ODD 填充规则。

在这种规则下,并不关心每条路径的绘制方向。而是每相交一个点,就 +1。如果最终结果是偶数,就是外部(留空);是奇数,就是内部(填充)。

比如现在的路径是这样的:

kotlin 复制代码
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
    // 重置路径
    path.reset()
    // 创建圆形路径
    path.addCircle(width / 2f, height / 2f + RADIUS.px, RADIUS.px, Path.Direction.CW)
    path.addCircle(
        width / 2f - RADIUS.px / 2f,
        height / 2f,
        RADIUS.px,
        Path.Direction.CW
    )
    path.addCircle(
        width / 2f + RADIUS.px / 2f,
        height / 2f,
        RADIUS.px,
        Path.Direction.CW
    )
    path.fillType = Path.FillType.EVEN_ODD
}

其运行结果为:

以中心部分为例,它射出的线共相交了三个点(奇数),所以是填充。

现在我们知道了这两种填充规则,实际中应该使用哪种?

其实这不是由我们决定的,而是设计师决定的。设计师给我设计图时,会带有该图的详细规则,我们只需根据这个规则来完成即可。

测量路径

我们可以使用 PathMeasure 来测量 Path,先来创建它。

kotlin 复制代码
private lateinit var pathMeasure: PathMeasure

override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
    ...
    // 在 path 对象配置好之后
    pathMeasure = PathMeasure(path, false)
}

其中构造函数的第二个参数 forceClosed,表示是否强制闭合路径,比如下图中分别是一条路径与它的闭合形式。

我们通常会调用 PathMeasuregetLength() 方法,来获取路径的长度。调用 getPosTan() 方法,获取路径上某一点的位置信息以及切线方向。

java 复制代码
public boolean getPosTan(float distance, float pos[], float tan[]) {
    if (pos != null && pos.length < 2 ||
        tan != null && tan.length < 2) {
        throw new ArrayIndexOutOfBoundsException();
    }
    return native_getPosTan(native_instance, distance, pos, tan);
}

其中 distance 表示此点沿路径的距离,pos 为返回的坐标信息,tan 为返回的切线向量。

图形的位置和尺寸测量

现在我们来看图像的位置和尺寸测量。

仪表盘

画一个仪表盘,将当前的 TestView 重命名为 DashboardView

先来绘制最外面的圆弧,我们通过 Path 来绘制:

kotlin 复制代码
class DashboardView(context: Context?, attrs: AttributeSet?) : View(context, attrs) {

    private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
        // 画线宽度
        strokeWidth = STROKE_WIDTH.px

        // 设置为画线样式
        style = Paint.Style.STROKE
    }
    private val path = Path()

    companion object {
        // 仪表盘的半径
        const val RADIUS = 150f

        // 仪表盘开口角度
        const val OPEN_ANGLE = 120f

        // 仪表盘的宽度
        const val STROKE_WIDTH = 3f
    }

    override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
        path.reset()
        // 创建弧线
        path.addArc(
            width / 2f - RADIUS.px,
            height / 2f - RADIUS.px,
            width / 2f + RADIUS.px,
            height / 2f + RADIUS.px,
            90f + OPEN_ANGLE / 2f, // 起始角度
            360f - OPEN_ANGLE // 绘制扫过的角度,也是仪表盘的旋转角度
        )
    }

    override fun onDraw(canvas: Canvas) {
        // 画仪表盘最外圈
        canvas.drawPath(path, paint)
    }

}

运行结果:

再来绘制仪表盘的刻度,我们可以通过 PathEffect 给路径设置效果来完成,选择其中的 PathDashPathEffect 虚线效果即可。

设置路径效果会替换路径的绘制效果,相当于使用路径效果来绘制路径,所以我们需要再添加一条路径。

不过,在绘制每一个刻度之前。我们先来看看在虚线效果中,每个虚线的绘制坐标系:

每个虚线的 x 轴方向是沿弧线切线的方向,y 轴是指向弧心的方向。知道了这个,我们就知道刻度,只需往 y 轴方向绘制一个矩形就可以完成。

代码如下:

diff 复制代码
class DashboardView(context: Context?, attrs: AttributeSet?) : View(context, attrs) {

    private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
        // 画线宽度
        strokeWidth = STROKE_WIDTH.px

        // 设置为画线样式
        style = Paint.Style.STROKE
    }
    private val path = Path()

+    // 每个刻度的路径
+    private val dash = Path().apply {
+        addRect(0f, 0f, DASH_WIDTH.px, DASH_LENGTH.px, Path.Direction.CW)
+    }
+
+    // 刻度的路径效果
+    private lateinit var pathEffect: PathDashPathEffect

    companion object {
        // 仪表盘的半径
        const val RADIUS = 150f

        // 仪表盘开口角度
        const val OPEN_ANGLE = 120f

        // 仪表盘的宽度
        const val STROKE_WIDTH = 3f

+        // 刻度的宽度
+        const val DASH_WIDTH = 2f
+
+        // 刻度的长度
+        const val DASH_LENGTH = 10f
    }

    override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
        path.reset()
        // 创建弧线
        path.addArc(
            width / 2f - RADIUS.px,
            height / 2f - RADIUS.px,
            width / 2f + RADIUS.px,
            height / 2f + RADIUS.px,
            90f + OPEN_ANGLE / 2f, // 起始角度
            360f - OPEN_ANGLE // 绘制扫过的角度,也是仪表盘的旋转角度
        )
        
+        // 安卓将 phase 参数和 advance 参数搞反了,所以需要对调位置
+        pathEffect = PathDashPathEffect(
+            dash, // 刻度路径
+            50f, // phase 为刻度的间隔
+            0f, // advance 为第一个刻度的偏移量
+            PathDashPathEffect.Style.ROTATE // 刻度的样式
+        )
    }

    override fun onDraw(canvas: Canvas) {
        // 画仪表盘最外圈
        canvas.drawPath(path, paint)

+        // 画刻度
+        paint.pathEffect = pathEffect
+        canvas.drawPath(path, paint)
+        paint.pathEffect = null
+    }

}

其中在绘制完刻度后,我们将 paint.pathEffect 的值置为了 null。这是因为我们对 paint 的设置会一直保持,直到再次修改,所以为了避免当前设置的路径效果影响到后续的绘制,才这么做的。

运行效果:

现在还没完,因为有时刻度需要固定个数。所以我们需要改变刻度的间隔,间隔使用 PathMeasure 来完成。

diff 复制代码
class DashboardView(context: Context?, attrs: AttributeSet?) : View(context, attrs) {

    private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
        // 画线宽度
        strokeWidth = STROKE_WIDTH.px

        // 设置为画线样式
        style = Paint.Style.STROKE
    }
    private val path = Path()

+    private lateinit var pathMeasure: PathMeasure

    // 每个刻度的路径
    private val dash = Path().apply {
+        addRect(0f, 0f, DASH_WIDTH.px, DASH_LENGTH.px, Path.Direction.CW)
    }

    // 刻度的路径效果
    private lateinit var pathEffect: PathDashPathEffect

    companion object {
        // 仪表盘的半径
        const val RADIUS = 150f

        // 仪表盘开口角度
        const val OPEN_ANGLE = 120f

        // 仪表盘的宽度
        const val STROKE_WIDTH = 3f

        // 刻度的宽度
        const val DASH_WIDTH = 2f

        // 刻度的长度
        const val DASH_LENGTH = 10f

+        // 刻度的数量
+        const val DASH_COUNT = 20
    }

    override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
        path.reset()
        // 创建弧线
        path.addArc(
            width / 2f - RADIUS.px,
            height / 2f - RADIUS.px,
            width / 2f + RADIUS.px,
            height / 2f + RADIUS.px,
            90f + OPEN_ANGLE / 2f, // 起始角度
            360f - OPEN_ANGLE // 绘制扫过的角度,也是仪表盘的旋转角度
        )

+        pathMeasure = PathMeasure(path, false)

        // 安卓将 phase 参数和 advance 参数搞反了,所以需要对调位置
+        val advance = (pathMeasure.length - DASH_WIDTH.px) / DASH_COUNT
        pathEffect = PathDashPathEffect(
            dash, // 刻度路径
+            advance, // phase 为刻度的间隔
            0f, // advance 为第一个刻度的偏移量
            PathDashPathEffect.Style.ROTATE // 刻度的样式
        )
    }

    override fun onDraw(canvas: Canvas) {
        // 画仪表盘最外圈
        canvas.drawPath(path, paint)

        // 画刻度
        paint.pathEffect = pathEffect
        canvas.drawPath(path, paint)
        paint.pathEffect = null
    }

}

现在的效果:

最后,我们来绘制指针,通过 drawLine 来完成。

kotlin 复制代码
class DashboardView(context: Context?, attrs: AttributeSet?) : View(context, attrs) {
    ...

    companion object {
        ...

        // 指针的长度
        const val POINTER_LENGTH = 120f
    }

    override fun onDraw(canvas: Canvas) {
        ...

        // 画指针
        // 当前刻度值
        val scaleValue = 10
        // 刻度值对应的角度
        val angle = OPEN_ANGLE / 2f + 90f + (360 - OPEN_ANGLE) / DASH_COUNT * scaleValue
        // 角度对应的弧度
        val radian = Math.toRadians(angle.toDouble()).toFloat()
        canvas.drawLine(
            width / 2f,
            height / 2f,
            width / 2f + POINTER_LENGTH.px * cos(radian),
            height / 2f + POINTER_LENGTH.px * sin(radian),
            paint
        )
    }

}

其中用到了正弦、余弦三角函数来确定指针的结束位置。

运行结果:

饼图

画一个饼图,它在数据统计中很常用。

新建 PieView,继承自 View,代码如下:

kotlin 复制代码
class PieView(context: Context?, attrs: AttributeSet?) : View(context, attrs) {

}

然后在 activity_main 布局中使用这个 PieView

xml 复制代码
<?xml version="1.0" encoding="utf-8"?>
<com.example.customviewfirst.PieView xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content" />

饼图有好几个扇形组成,我们可以通过 drawArc 方法来完成,代码如下:

kotlin 复制代码
class PieView(context: Context?, attrs: AttributeSet?) : View(context, attrs) {

    private val paint = Paint()

    companion object {
        const val RADIUS = 150f

        // 4个扇形角度
        val ANGLES = floatArrayOf(60f, 90f, 120f, 90f)

        // 4个扇形颜色
        val COLORS = intArrayOf(
            Color.parseColor("#2196F3"),
            Color.parseColor("#F44336"),
            Color.parseColor("#FFEB3B"),
            Color.parseColor("#E91E63")
        )
    }

    override fun onDraw(canvas: Canvas) {
        var startAngle = 0f
        // 绘制扇形
        for (i in ANGLES.indices) {
            paint.color = COLORS[i]
            canvas.drawArc(
                width / 2f - RADIUS.px,
                height / 2 - RADIUS.px,
                width / 2 + RADIUS.px,
                height / 2 + RADIUS.px,
                startAngle,
                ANGLES[i],
                true,
                paint
            )
            startAngle += ANGLES[i]
        }
    }
}

运行结果:

另外,饼状图有个常见的功能,就是将饼单独移出。我们可以通过设置单次绘制的偏移来完成,但更方便的是让 Canvas 进行偏移。

diff 复制代码
override fun onDraw(canvas: Canvas) {
    var startAngle = 0f
    // 绘制扇形
    for (i in ANGLES.indices) {
+        // 保存画布
+        canvas.save()
+        if (i == 2) {
+            // 偏移量
+            val offset = 20f.px
+            // 移动画布
+            canvas.translate(
+                offset * cos(
+                    Math.toRadians(startAngle + (ANGLES[i] / 2f).toDouble()).toFloat()
+                ),
+                offset * sin(
+                    Math.toRadians(startAngle + (ANGLES[i] / 2f).toDouble()).toFloat()
+                )
+            )
        }

        paint.color = COLORS[i]
        canvas.drawArc(
            width / 2f - RADIUS.px,
            height / 2 - RADIUS.px,
            width / 2 + RADIUS.px,
            height / 2 + RADIUS.px,
            startAngle,
            ANGLES[i],
            true,
            paint
        )
+        // 恢复画布
+        canvas.restore()
        startAngle += ANGLES[i]
    }
}

其中我们使用了 canvas.save()canvas.restore() 这一对方法,包裹了对扇形的 translate(平移)操作,这样能够不让当前变换影响到后续的绘制。

canvas.save() 会将当前画布的所有状态压入栈中,canvas.restore() 会从栈顶取出一个状态,赋给当前画布。

运行结果:

相关推荐
jiunian_cn5 小时前
【Linux】线程
android·linux·运维·c语言·c++·后端
Frank_HarmonyOS13 小时前
Android MVVM(Model-View-ViewModel)架构
android·架构
新子y17 小时前
【操作记录】我的 MNN Android LLM 编译学习笔记记录(一)
android·学习·mnn
lincats19 小时前
一步一步学习使用FireMonkey动画(1) 使用动画组件为窗体添加动态效果
android·ide·delphi·livebindings·delphi 12.3·firemonkey
想想吴20 小时前
Android.bp 基础
android·安卓·android.bp
写点啥呢1 天前
Android为ijkplayer设置音频发音类型usage
android·音视频·usage·mediaplayer·jikplayer
coder_pig1 天前
🤡 公司Android老项目升级踩坑小记
android·flutter·gradle
死就死在补习班1 天前
Android系统源码分析Input - InputReader读取事件
android
死就死在补习班1 天前
Android系统源码分析Input - InputChannel通信
android