01.2 精通自定义 View 之绘图基础——路径

返回自定义 View 目录

1.2.1 概述

画笔所画出来的一段不间断的曲线就是路径。在 Android 中,Path 类就代表路径。在 Canvas 中绘制路径的方法如下:

1
void drawPath(Path path, Paint paint)

1.2.2 直线路径

画一条直线路径,一般涉及下面三个函数:

1
2
3
4
5
6
7
// (x1,y1)是直线的起始点,即将直线路径的绘制点定在(x1,y1)位置
void moveTo(float x1, float y1)
// (x2,y2)是直线的终点,又是下一次绘制直线路径的起始点;lineTo()函数可以一直使用。
void lineTo(float x2, float y2)
// 如果连续画了几条直线,但没有形成闭环,
// 那么调用 close()函数会将路径首尾点连接起来,形成闭环。
void close()

示例:
闭环 & 不闭环三角形

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 初始化
mPaint = new Paint();
mPaint.setColor(Color.RED);
mPaint.setStyle(Paint.Style.STROKE);
mPaint.setStrokeWidth(5);
mPath = new Path();
// 闭环
mPath.moveTo(20, 20);
mPath.lineTo(20, 120);
mPath.lineTo(300, 120);
mPath.close();
canvas.drawPath(mPath, mPaint);
// 不闭环
mPath.moveTo(320, 20);
mPath.lineTo(320, 120);
mPath.lineTo(600, 120);
canvas.drawPath(mPath, mPaint);

闭合三角形:先沿逆时针方向画了两条直线,分别是从(20, 20)到(20, 120)和从(20, 120)到(300, 120),然后利用 path.close()函数将路径闭合,路径的终点(300,120)就会自行向路径的起始点 (20,20)画一条闭合线,所以最终我们看到的是一个路径闭合的三角形。

1.2.3 弧线路径

1
void arcTo(RectF oval, float startAngle, float sweepAngle)

这是一个画弧线路径的方法,弧线是从椭圆上截取的一部分。
参数:

  • RectF oval:生成椭圆的矩形。
  • float startAngle:弧开始的角度,以 X 轴正方向为 0°。
  • float sweepAngle:弧持续的角度。

示例:
效果图

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 初始化
mPaint = new Paint();
mPaint.setStyle(Paint.Style.STROKE);
mPaint.setStrokeWidth(5);
mRect = new RectF(100, 20, 200, 100);
mPath = new Path();
mPath.moveTo(20, 20);
mPath.arcTo(mRect, 0, 90);
// onDraw
mPaint.setColor(Color.LTGRAY);
canvas.drawRect(mRect, mPaint);
mPaint.setColor(Color.RED);
canvas.drawPath(mPath, mPaint);

上述示例中弧最终还是会和起始点(20,20)连接起来。因为在默认情况下路径都是连贯的,除非以下两种情况:

  • 调用 addXXX 系列函数(参见 1.2.4 节),将直接添加固定形状的路径。
  • 调用 moveTo()函数改变绘制起始位置。

如果不想连接起来,需要使用 Path 类提供的另外两个重载方法。

1
2
3
void arcTo(float left, float top, float right, float bottom,
float startAngle, float sweepAngle, boolean forceMoveTo)
void arcTo(RectF oval, float startAngle, float sweepAngle, boolean forceMoveTo)

参数 boolean forceMoveTo 的含义是是否强制将弧的起始点作为绘制起始位置。

将上面的代码稍加改造:

1
2
// mPath.arcTo(mRect, 0, 90);
mPath.arcTo(mRect, 0, 90, true);

效果如下:

1.2.4 addXXX系列函数

路径一般都是连贯的,而 addXXX 系列函数可以让我们直接往 Path 中添加一些曲线,而不必考虑连贯性。

示例:

1
2
3
4
5
6
7
8
9
10
11
12
mPaint = new Paint();
mPaint.setColor(Color.RED);
mPaint.setStyle(Paint.Style.STROKE);
mPaint.setStrokeWidth(5);
mPath = new Path();
mPath.moveTo(40, 40);
mPath.lineTo(100, 100);
mRect = new RectF(100, 100, 200, 200);
mPath.addArc(mRect, 0, 90);
canvas.drawPath(mPath, mPaint);

先绘制了从(40,40)到(100,100)的线段,但是在我们往路径中添加了一条弧线之后,弧线并没有与线段连接。除了 addArc()函数,Path 类还提供了一系列的 add 函数:

1.添加矩形路径

1
2
3
void addRect(float left, float top, float right, float bottom,
Path.Direction dir)
void addRect(RectF rect, Path.Direction dir)

这里 Path 类创建矩形路径的参数与 Canvas 绘制矩形的参数差不多,唯一不同的是增加了 Path.Direction 参数。Path.Direction 参数有两个值。

  • Path.Direction.CCW:是 counter-clockwise 的缩写,指创建逆时针方向的矩形路径。
  • Path.Direction.CW:是 clockwise 的缩写,指创建顺时针方向的矩形路径。

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
public TestView(Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
mPaint = new Paint();
mPaint.setColor(Color.LTGRAY);
mPaint.setStyle(Paint.Style.STROKE);
mPaint.setStrokeWidth(5);
mTextPaint = new Paint();
mTextPaint.setColor(Color.BLUE);
mTextPaint.setTextSize(35);
mStr = "苦心人天不负,有志者事竟成";
// 逆时针路径
mCCWPath = new Path();
RectF rect1 = new RectF(50, 50, 240, 200);
mCCWPath.addRect(rect1, Path.Direction.CCW);
// 顺时针路径
mCWPath = new Path();
RectF rect2 = new RectF(290, 50, 480, 200);
mCWPath.addRect(rect2, Path.Direction.CW);
}
@Override
protected void onDraw(Canvas canvas) {
canvas.drawPath(mCCWPath, mPaint);
canvas.drawPath(mCWPath, mPaint);
canvas.drawTextOnPath(mStr, mCCWPath, 0, 18, mTextPaint);
canvas.drawTextOnPath(mStr, mCWPath, 0, 18, mTextPaint);
}

文字是可以依据路径排版的,文字的行走方向依据的就是路径的生成方向。

2.添加圆角矩形路径

1
2
void addRoundRect(RectF rect, float[] radii, Path.Direction dir)
void addRoundRect(RectF rect, float rx, float ry, Path.Direction dir)

矩形的圆角都是利用椭圆生成的。参数:

  • RectF rect:是当前所构造路径的矩形。
  • Path.Direction dir:依然是指路径的生成方向,当然只对依据路径布局的文字有用。
  • float[] radii:必须传入 8 个数值,分 4 组,分别对应每个角所使用的椭圆的横轴半径和纵轴半径。如{x1,y1,x2,y2,x3,y3,x4,y4},其中,x1,y1 对应第一个角(左上角)的用来生成圆角的椭圆的横轴半径和纵轴半径,其他类推……
  • float rx:生成统一的圆角的椭圆的横轴半径。
  • float ry:生成统一的圆角的椭圆的纵轴半径。

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
public class TestView extends View {
private Paint mPaint;
private Path mCCWPath, mCWPath;
public TestView(Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
mPaint = new Paint();
mPaint.setColor(Color.RED);
mPaint.setStyle(Paint.Style.STROKE);
mPaint.setAntiAlias(true);
mPaint.setStrokeWidth(5);
// 逆时针路径
mCCWPath = new Path();
RectF rect1 = new RectF(50, 50, 240, 200);
mCCWPath.addRoundRect(rect1, 10, 15, Path.Direction.CCW);
// 顺时针路径
mCWPath = new Path();
RectF rect2 = new RectF(290, 50, 480, 200);
float[] radii = {10,15,20,25,30,35,40,45};
mCWPath.addRoundRect(rect2, radii, Path.Direction.CW);
}
@Override
protected void onDraw(Canvas canvas) {
canvas.drawPath(mCCWPath, mPaint);
canvas.drawPath(mCWPath, mPaint);
}
}

3.添加圆形路径

1
void addCircle(float x, float y, float radius, Path.Direction dir)

参数:

  • float x:圆心 X 轴坐标。
  • float y:圆心 Y 轴坐标。
  • float radius:圆半径。

示例:

1
2
3
4
mPath = new Path();
mPath.addCircle(100, 100, 50, Path.Direction.CCW);
canvas.drawPath(mPath, mPaint);

4.添加椭圆路径

1
void addOval(RectF oval, Path.Direction dir)

参数:

  • RectF oval:生成椭圆的矩形。
  • Path.Direction:路径的生成方向。

示例:

1
2
3
4
mPath = new Path();
mPath.addOval(new RectF(100, 100, 300, 200), Path.Direction.CCW);
canvas.drawPath(mPath, mPaint);

5.添加弧形路径

1
2
3
void addArc(float left, float top, float right, float bottom,
float startAngle, float sweepAngle)
void addArc(RectF oval, float startAngle, float sweepAngle)

参数:

  • RectF oval:弧是椭圆的一部分,这个参数就是生成椭圆的矩形。
  • float startAngle:弧开始的角度,以 X 轴正方向为 0°。
  • float sweepAngel:弧持续的角度。

示例:

1
2
mPath.addArc(new RectF(100, 100, 300, 200), 0, 180);
canvas.drawPath(mPath, mPaint);

1.2.5 填充模式

Path 的填充模式与 Paint 的填充模式不同。Path 的填充模式是指填充 Path 的哪部分。Path.FillType 表示 Path 的填充模式,它有 4 个枚举值。

  • FillType.WINDING:默认值,当两个图形相交时,取相交和自身部分显示。
  • FillType.EVEN_ODD:取 path 所在并不相交的区域。
  • FillType.INVERSE_WINDING:取 path 的外部区域。
  • FillType.INVERSE_EVEN_ODD:取 path 的外部和相交区域。

Inverse 就是取反的意思,所以 FillType.INVERSE_WINDING 就是取 FillType.WINDING 的相反部分;同理,FillType.INVERSE_EVEN_ODD 就是取 FillType.EVEN_ODD 的相反部分。

示例:

1
2
3
4
5
6
7
8
9
10
11
mPaint = new Paint();
mPaint.setColor(Color.RED);
mPaint.setStyle(Paint.Style.FILL);
mPaint.setAntiAlias(true);
mPath = new Path();
mPath.addRect(100, 100, 300, 300, Path.Direction.CW);
mPath.addCircle(300, 300, 100, Path.Direction.CW);
mPath.setFillType(Path.FillType.INVERSE_EVEN_ODD);
canvas.drawPath(mPath, mPaint);

1.2.6 重置路径

1.概述

当我们需要重绘一条全新的路径时,Android 开发人员为了重复利用空间,允许我们重置路径对象。路径对象一旦被重置,其中保存的所有路径都将被清空,这样我们就不需要重新定义一个路径对象了。重新定义路径对象的问题在于老对象的回收和新对象的内存分配,当然这些过程都是会消耗手机性能的。

系统提供了两个重置路径的方法,分别是:

1
2
void reset();
void rewind();

这两个函数的共同点是都会清空内部所保存的所有路径,但二者也有区别。

  • rewind()函数会清除 FillType 及所有的直线、曲线、点的数据等,但是会保留数据结构。 这样可以实现快速重用,提高一定的性能。例如,重复绘制一类线段,它们的点的数量都相等,那么使用 rewind()函数可以保留装载点数据的数据结构,效率会更高。一定要注意的是,只有在重复绘制相同的路径时,这些数据结构才是可以复用的。
  • reset()函数类似于新建一个路径对象,它的所有数据空间都会被回收并重新分配,但不会清除 FillType。

2.reset()与 FillType

1
2
3
4
5
mPath.setFillType(Path.FillType.INVERSE_WINDING);
mPath.reset();
mPath.addCircle(100, 100, 50, Path.Direction.CW);
canvas.drawPath(mPath, mPaint);

效果如下:
reset 不清除 FillType

把 reset()改成 rewind()。效果如下:
rewind 清除 FillType

1.2.7 示例:蜘蛛网状图

网游职业分析图

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
package com.xxt.xtest;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.Path;
import android.util.AttributeSet;
import android.view.View;
import androidx.annotation.Nullable;
public class SpiderView extends View {
private Paint mRadarPaint; // 蜘蛛网
private Paint mRadarLinePaint; // 蜘蛛网辐射的六根线
private Paint mValuePaint; // 数据
private float radius; // 网格最大半径
private int centerX; // 中心 X
private int centerY; // 中心 Y
private Path mPath;
private int mRadarPaintColor = 0xFF0099CC; // 网格默认颜色
private int count = 6; // 多边形,默认值为 6
private double angle = 2*Math.PI / count; // 角度,值为 2π / count,默认
private double[] data = {2,3,1,3,4,3}; // 数据
private int maxValue = 4; // 最大值
public SpiderView(Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
init();
}
private void init() {
mRadarPaint = generatePaint(mRadarPaintColor, Paint.Style.FILL);
mRadarLinePaint = generatePaint(Color.WHITE, Paint.Style.STROKE);
mValuePaint = generatePaint(0xAFFF0000, Paint.Style.FILL);
mPath = new Path();
}
private Paint generatePaint(int color, Paint.Style style) {
Paint paint = new Paint();
paint.setColor(color);
paint.setStyle(style);
paint.setAntiAlias(true);
return paint;
}
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
// 获得布局中心
centerX = w / 2;
centerY = h / 2;
radius = Math.min(w, h) / 2f * 0.8f;
postInvalidate();
super.onSizeChanged(w, h, oldw, oldh);
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
// 绘制蜘蛛网格
drawPolygon(canvas);
// 绘制中线
drawLines(canvas);
// 画数据图
drawRegion(canvas);
}
private void drawPolygon(Canvas canvas) {
float r = radius / maxValue; // r是蜘蛛丝之间的间距
for (int i = 1; i <= maxValue; i++) { // 中心点不用绘制
float curR = r * i; // 当前半径
mPath.reset();
for (int j = 0; j < count; j++) {
if (j == 0) {
mPath.moveTo(centerX + curR, centerY);
} else {
// 根据半径,计算出蜘蛛丝上每个点的坐标
float x = (float) (centerX + curR * Math.cos(angle * j));
float y = (float) (centerY + curR * Math.sin(angle * j));
mPath.lineTo(x, y);
}
}
mPath.close(); // 闭合路径
mRadarPaint.setAlpha(getRadarPaintColor(i));
canvas.drawPath(mPath, mRadarPaint);
}
}
private void drawLines(Canvas canvas) {
for (int i = 0; i < count; i++) {
mPath.reset();
mPath.moveTo(centerX, centerY);
float x = (float) (centerX + radius * Math.cos(angle * i));
float y = (float) (centerY + radius * Math.sin(angle * i));
mPath.lineTo(x, y);
canvas.drawPath(mPath, mRadarLinePaint);
}
}
private void drawRegion(Canvas canvas) {
mPath.reset();
for (int i = 0; i < count; i++) {
double percent = data[i] / maxValue;
float x = (float) (centerX + radius * Math.cos(angle * i) * percent);
float y = (float) (centerY + radius * Math.sin(angle * i) * percent);
if (i == 0) {
mPath.moveTo(x, centerY);
} else {
mPath.lineTo(x, y);
}
}
canvas.drawPath(mPath, mValuePaint);
}
/**
* 由内到外,增加透明度
* @param i 第几个网格,从中心点算起
* @return int alpha 值
*/
private int getRadarPaintColor(int i) {
if (i > count || i < 1) {
return 0xFF;
}
int alpha = Color.alpha(mRadarPaintColor);
int colorStep = alpha / (maxValue - 1) - 10;
alpha = alpha - colorStep * (i - 1);
return alpha;
}
}