绘制的基本要素
-
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
可用于将各种单位的值转换成像素值。
运行结果为:
我们可以将 dp
转 px
的代码,抽取成一个方法:
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)
}

中间这部分是否填充,是由填充规则决定的。填充规则有两种,分别是 WINDING
和 EVEN_ODD
,默认是 WINDING
。
INVERSE_WINDING
和INVERSE_EVEN_ODD
只是反着的WINDING
和EVEN_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
,表示是否强制闭合路径,比如下图中分别是一条路径与它的闭合形式。
我们通常会调用 PathMeasure
的 getLength()
方法,来获取路径的长度。调用 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()
会从栈顶取出一个状态,赋给当前画布。
运行结果: