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() 会从栈顶取出一个状态,赋给当前画布。

运行结果:

相关推荐
黄林晴2 小时前
如何判断手机是否是纯血鸿蒙系统
android
火柴就是我3 小时前
flutter 之真手势冲突处理
android·flutter
法的空间3 小时前
Flutter JsonToDart 支持 JsonSchema
android·flutter·ios
循环不息优化不止3 小时前
深入解析安卓 Handle 机制
android
恋猫de小郭3 小时前
Android 将强制应用使用主题图标,你怎么看?
android·前端·flutter
jctech3 小时前
这才是2025年的插件化!ComboLite 2.0:为Compose开发者带来极致“爽”感
android·开源
用户2018792831673 小时前
为何Handler的postDelayed不适合精准定时任务?
android
叽哥4 小时前
Kotlin学习第 8 课:Kotlin 进阶特性:简化代码与提升效率
android·java·kotlin
Cui晨4 小时前
Android RecyclerView展示List<View> Adapter的数据源使用View
android
氦客4 小时前
Android Doze低电耗休眠模式 与 WorkManager
android·suspend·休眠模式·workmanager·doze·低功耗模式·state_doze