05.1 精通自定义 View 之动画进阶——利用 PathMeasure 实现路径动画

返回自定义 View 目录

PathMeasure 类似一个计算器,可以计算出指定路径的一些信息,比如路径总长、指定长度所对应的坐标等。我们可以通过 PathMeasure 可以实现复杂的动画效果。

5.1.1 初始化

1
2
3
4
5
6
// 初始化方法一
public PathMeasure();
setPath(Path path, boolean forceClosed);
// 初始化方法二
public PathMeasure(Path path, boolean forceClosed);

参数 boolean forceClosed 表示Path 最终是否需要闭合,如果为 true,则不管关联的 Path 是否是闭合的,都会被闭合。但是 forceClosed 参数对绑定的 Path 不会产生任何影响,例如一个折线段的 Path,本身是没有闭合的,当 forceClosed 设置为 true 的时候,PathMeasure 计算的 Path 是闭合的,但 Path 绘制出来的是不会闭合的。forceClosed 参数只对 PathMeasure 的测量结果有影响,例如一个折线段的 Path,本身没有闭合,当 forceClosed 设置为 true 时,PathMeasure 的计算就会包含最后一段闭合的路径,与原来的 Path 不同。

示例:

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
public class TestView extends View {
private Paint mPaint;
private Path mPath;
private PathMeasure mPathMeasureFalse;
private PathMeasure mPathMeasureTrue;
public TestView(Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
mPaint = new Paint();
mPaint.setColor(Color.BLACK);
mPaint.setStyle(Paint.Style.STROKE);
mPaint.setStrokeWidth(8);
mPath = new Path();
mPath.moveTo(50, 50);
mPath.lineTo(50, 150);
mPath.lineTo(150, 150);
mPath.lineTo(150, 50);
mPathMeasureFalse = new PathMeasure(mPath, false);
mPathMeasureTrue = new PathMeasure(mPath, true);
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
Log.i("xian", "forceClosed=false---->" + mPathMeasureFalse.getLength());
Log.i("xian", "forceClosed=true----->" + mPathMeasureTrue.getLength());
canvas.drawPath(mPath, mPaint);
}
}

结果:

path 图

日志输出

从图中可以看到,我们创建的只是正方形的三条边,而日志打印结果表示:如果 forceClosed 为 false,则测量的是当前 Path 状态的长度;如果 forceClosed 为 true,则不论 Path 是否闭合,测量的都是 Path 的闭合长度。

5.1.2 简单函数使用

1. getLength()

1
2
// 获取一段路径的长度,不一定是整个 Path 的长度
public float getLength()

2. isClosed()

1
2
// 判断测量 Path 时是否计算闭合,返回值是 forceClosed
public boolean isClosed()

3. nextContour()

1
public boolean nextContour()

Path 可以由多条曲线构成,但不论是 getLength()、getSegment() 还是其他函数,都会只针对其中第一条线段进行计算。而 nextContour() 就是用于跳转到下一条曲线的函数。如果跳转成功,则返回 true;如果跳转失败,则返回 false。

示例:

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
public class TestView extends View {
private Paint mPaint;
private Path mPath1, mPath2, mPath3;
private PathMeasure mPathMeasure;
public TestView(Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
mPaint = new Paint();
mPaint.setColor(Color.BLACK);
mPaint.setStyle(Paint.Style.STROKE);
mPaint.setStrokeWidth(8);
mPath1 = new Path();
mPath1.addRect(-50, -50, 50, 50, Path.Direction.CW);
mPath2 = new Path();
mPath2.addRect(-100, -100, 100, 100, Path.Direction.CW);
mPath3 = new Path();
mPath3.addRect(-120, -120, 120, 120, Path.Direction.CW);
mPathMeasure = new PathMeasure();
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
canvas.translate(150, 150);
// 只绘画最大的区域
// mPath.addRect(-50, -50, 50, 50, Path.Direction.CW);
// mPath.addRect(-100, -100, 100, 100, Path.Direction.CW);
// mPath.addRect(-120, -120, 120, 120, Path.Direction.CW);
// canvas.drawPath(mPath, mPaint);
mPath1.addPath(mPath2);
mPath1.addPath(mPath3);
canvas.drawPath(mPath1, mPaint);
mPathMeasure.setPath(mPath1, false);
do {
float len = mPathMeasure.getLength();
Log.i("xian", "len=" + len);
} while (mPathMeasure.nextContour());
}
}

结果:

日志打印结果

通过这个例子可以得出以下结论:

  • nextContour() 函数得到的曲线的顺序与 Path 中添加的顺序相同。
  • getLength() 等函数针对的是当前线段,不是整个 Path。

5.1.3 getSegment() 函数

1
public boolean getSegment(float startD, float stopD, Path dst, boolean startWithMoveTo)

用于截取整个 Path 中的某个片段,通过参数 startD 与 stopD 来控制截取的长度,并将截取后的 Path 保存并添加(不是替换)到参数 dst 中。startWithMoveTo 表示起始点是否使用 moveTo 将路径的新起点移动到结果 Path 的起始点,通常设置为 true,以保证每次截取的 Path 都是正常的、完整的;如果设置为 false,则新增的片段会从上一次 Path 终点开始计算,这样可以保证截取的 Path 片段是连续的,但不一定时正常的。

注意:

  • 如果 startD、stopD 的数值不在取值范围 [0, getLength] 内,或者 startD == stopD,则返回值为 false,而且不会改变 dst 中的内容。
  • 使用 getSegment() 函数时需要禁用硬件加速功能。 setLayerType(LAYER_TYPE_SOFTWARE, null)。

示例一:用法举例

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
public class TestView extends View {
private Paint mPaint;
private Path mPath, mDst;
private PathMeasure mPathMeasure;
public TestView(Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
mPaint = new Paint();
mPaint.setColor(Color.BLACK);
mPaint.setStyle(Paint.Style.STROKE);
mPaint.setStrokeWidth(8);
mPath = new Path();
mPath.addRect(-50, -50, 50, 50, Path.Direction.CW);
mDst = new Path();
mPathMeasure = new PathMeasure(mPath, false);
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
canvas.translate(100, 100);
mPathMeasure.getSegment(0, 150, mDst, true);
canvas.drawPath(mDst, mPaint);
}
}

效果图如下:

结论一:路径截取是以路径的左上角为起始点开始的。

示例二:逆时针
将生成路径的方式指定为逆时针。

1
2
// mPath.addRect(-50, -50, 50, 50, Path.Direction.CW);
mPath.addRect(-50, -50, 50, 50, Path.Direction.CCW);

效果图如下:

结论二:路径的截取方向与路径的生成方向相同。

示例三:如果 dst 路径不为空

1
2
3
mDst = new Path();
mDst.lineTo(10, 100);
mPathMeasure = new PathMeasure(mPath, false);

效果图如下:

结论三:会将截取的 Path 片段添加到路径 dst 中,而不是替换 dst 中的内容。

示例四:如果 startWithMoveTo 参数为 false

1
2
// mPathMeasure.getSegment(0, 150, mDst, true);
mPathMeasure.getSegment(0, 150, mDst, false);

效果图如下:

结论四:如果 startWithMoveTo 为 true,则被截取出来的 Path 片段保持原状;如果 startWithMoveTo 为 false,则会截取出来的 Path 片段的起始点移动到 dst 的最后一个点,以保证 dst 路径的连续性。

示例:路径加载动画

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
public class TestView extends View {
private Paint mPaint;
private Path mCirclePath, mDstPath;
private PathMeasure mPathMeasure;
private float mCurAnimValue;
public TestView(Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
mPaint = new Paint();
mPaint.setColor(Color.BLACK);
mPaint.setStyle(Paint.Style.STROKE);
mPaint.setStrokeWidth(4);
mDstPath = new Path();
mCirclePath = new Path();
mCirclePath.addCircle(100, 100, 50, Path.Direction.CW);
mPathMeasure = new PathMeasure(mCirclePath, false);
ValueAnimator animator = ValueAnimator.ofFloat(0, 1);
animator.setRepeatCount(ValueAnimator.INFINITE);
animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
mCurAnimValue = (Float) animation.getAnimatedValue();
invalidate();
}
});
animator.setDuration(1000);
animator.start();
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
canvas.drawColor(Color.WHITE);
float stop = mPathMeasure.getLength() * mCurAnimValue;
mDstPath.reset();
mPathMeasure.getSegment(0, stop, mDstPath, true);
canvas.drawPath(mDstPath, mPaint);
}
}

上述示例中,在生成动画路径时,始终是从 0 位置开始的。如果我们稍微改变一下生成路径的起始点位置,就可以完成一个比较有意思的加载图动画,效果图如下所示:

修改代码如下:

1
2
3
4
5
6
7
8
9
10
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
canvas.drawColor(Color.WHITE);
float length = mPathMeasure.getLength();
float stop = length * mCurAnimValue;
float start = (float) (stop - ((0.5 - Math.abs(mCurAnimValue - 0.5)) * length));
mDstPath.reset();
mPathMeasure.getSegment(start, stop, mDstPath, true);
canvas.drawPath(mDstPath, mPaint);
}

5.1.4 getPosTan() 函数

1
boolean getPosTan(float distance, float[] pos, float[] tan)

用于得到路径上某一长度的位置以及该位置的正切值。参数:

  • float distance:距离 Path 起始点的长度,取值范围 0 ≤ distance ≤ getLength。
  • float[] pos:该点的坐标值。pos[0] 表示 x 坐标,pos[1] 表示 y 坐标。
  • float[] tan:该点的正切值。

半径为1的各点的坐标值

在 Math 类中,有两个求反切值的函数,即夹角 a 的值。

1
2
double atan(double d)
double atan2(double x, double y)

示例:飞机加载动画

动画原理

动画效果:

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
public class TestView extends View {
private Paint mPaint;
private Path mCirclePath, mDstPath;
private PathMeasure mPathMeasure;
private float mCurAnimValue;
private Bitmap mPlaneBmp;
private Matrix mMatrix;
private ValueAnimator mValueAnimator;
private float[] pos = new float[2];
private float[] tan = new float[2];
public TestView(Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
setLayerType(LAYER_TYPE_SOFTWARE, null);
mPlaneBmp = BitmapFactory.decodeResource(getResources(), R.drawable.plane);
mPaint = new Paint();
mPaint.setColor(Color.BLACK);
mPaint.setStyle(Paint.Style.STROKE);
mPaint.setStrokeWidth(4);
mDstPath = new Path();
mCirclePath = new Path();
mPathMeasure = new PathMeasure();
mMatrix = new Matrix();
mValueAnimator = ValueAnimator.ofFloat(0, 1);
mValueAnimator.setRepeatCount(ValueAnimator.INFINITE);
mValueAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
mCurAnimValue = (Float) animation.getAnimatedValue();
invalidate();
}
});
mValueAnimator.setDuration(2000);
mValueAnimator.setInterpolator(new AccelerateDecelerateInterpolator());
mValueAnimator.start();
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
int width = MeasureSpec.getSize(widthMeasureSpec);
int height = MeasureSpec.getSize(heightMeasureSpec);
int radius;
if (width >= height) {
radius = height / 2 - height / 8;
} else {
radius = width / 2 - width / 8;
}
// 先画圆的 path,但是这个圆只是用来计算
mCirclePath.addCircle(width / 2f, height / 2f, radius, Path.Direction.CW);
//计算圆的path的长度
mPathMeasure.setPath(mCirclePath, false);
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
// 绘制路径加载动画
float length = mPathMeasure.getLength();
float stop = length * mCurAnimValue;
float start = (float) (stop - ((0.5 - Math.abs(mCurAnimValue - 0.5)) * length));
mDstPath.reset();
mPathMeasure.getSegment(start, stop, mDstPath, true);
canvas.drawPath(mDstPath, mPaint);
// 旋转飞机图片并绘制
// 使用 getMatrix
// mPathMeasure.getMatrix(stop, mMatrix, PathMeasure.POSITION_MATRIX_FLAG|PathMeasure.TANGENT_MATRIX_FLAG);
// mMatrix.preTranslate(-mPlaneBmp.getWidth() / 2f,-mPlaneBmp.getHeight() / 2f);
// 使用 getPosTan
mPathMeasure.getPosTan(stop, pos, tan);
float degrees = (float) (Math.atan2(tan[1], tan[0]) * 180.0 / Math.PI);
float px = mPlaneBmp.getWidth() / 2f;
float py = mPlaneBmp.getHeight() / 2f;
mMatrix.reset();
mMatrix.postRotate(degrees, mPlaneBmp.getWidth() / 2f, mPlaneBmp.getHeight() / 2f);
mMatrix.postTranslate(pos[0] - px, pos[1] - py);
canvas.drawBitmap(mPlaneBmp, mMatrix, mPaint);
}
@Override
protected void onDetachedFromWindow() {
super.onDetachedFromWindow();
mValueAnimator.cancel();
mValueAnimator = null;
}
}

5.1.5 getMatrix() 函数

1
boolean getMatrix(float distance, Matrix matrix, int flags)

用于得到路径上某一长度的位置以及该位置的正切值的矩阵。

  • distance:距离 Path 起始点的长度。
  • matrix:根据flags 封装好的 matrix 会根据 flags 的设置而存入不同的内容。
  • flags:用于指定哪些内容会存入 matrix 中。flags 值有两个:PathMeasure.POSITION_MATRIX_FLAG 表示获取位置信息;PathMeasure.TANGENT_MATRIX_FLAG 表示获取切边信息,使得图片按 Path 旋转。可以只指定一个,也可以用“|”同时指定。

很明显,getMatrix() 函数只是 PathMeasure.getPosTan() 函数的另一种实现而已。如下更改飞机加载动画:

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
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
// 绘制路径加载动画
float length = mPathMeasure.getLength();
float stop = length * mCurAnimValue;
float start = (float) (stop - ((0.5 - Math.abs(mCurAnimValue - 0.5)) * length));
mDstPath.reset();
mPathMeasure.getSegment(start, stop, mDstPath, true);
canvas.drawPath(mDstPath, mPaint);
// 旋转飞机图片并绘制
// 使用 getMatrix
mMatrix.reset();
mPathMeasure.getMatrix(stop, mMatrix, PathMeasure.POSITION_MATRIX_FLAG|PathMeasure.TANGENT_MATRIX_FLAG);
mMatrix.preTranslate(-mPlaneBmp.getWidth() / 2f,-mPlaneBmp.getHeight() / 2f);
// 使用 getPosTan
// mPathMeasure.getPosTan(stop, pos, tan);
// float degrees = (float) (Math.atan2(tan[1], tan[0]) * 180.0 / Math.PI);
// float px = mPlaneBmp.getWidth() / 2f;
// float py = mPlaneBmp.getHeight() / 2f;
// mMatrix.postRotate(degrees, mPlaneBmp.getWidth() / 2f, mPlaneBmp.getHeight() / 2f);
// mMatrix.postTranslate(pos[0] - px, pos[1] - py);
canvas.drawBitmap(mPlaneBmp, mMatrix, mPaint);
}

5.1.6 示例:支付宝支付成功动画

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
public class TestView extends View {
private Paint mPaint;
private Path mCirclePath, mDstPath;
private PathMeasure mPathMeasure;
private float mCurAnimValue;
private ValueAnimator mValueAnimator;
private boolean mNext = false;
public TestView(Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
mPaint = new Paint();
mPaint.setColor(Color.BLACK);
mPaint.setStyle(Paint.Style.STROKE);
mPaint.setStrokeWidth(8);
mDstPath = new Path();
mCirclePath = new Path();
mPathMeasure = new PathMeasure();
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
mCirclePath.reset();
int width = MeasureSpec.getSize(widthMeasureSpec);
int height = MeasureSpec.getSize(heightMeasureSpec);
int centerX = width / 2;
int centerY = height / 2;
int radius = Math.min(centerX, centerY) / 2;
mCirclePath.addCircle(centerX, centerY, radius, Path.Direction.CW);
mCirclePath.moveTo(centerX - radius/2f, centerY); // 勾的起点
mCirclePath.lineTo(centerX,centerY + radius/2f); // 勾的拐点
mCirclePath.lineTo(centerX + radius/2f,centerY - radius/3f); // 勾的终点
mPathMeasure.setPath(mCirclePath,false);
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
if (mCurAnimValue < 1) {
float stop = mPathMeasure.getLength() * mCurAnimValue;
mPathMeasure.getSegment(0, stop, mDstPath, true);
} else {
if (!mNext) {
mNext = true;
mPathMeasure.getSegment(0, mPathMeasure.getLength(), mDstPath, true);
mPathMeasure.nextContour();
} else {
float stop = mPathMeasure.getLength() * (mCurAnimValue - 1);
mPathMeasure.getSegment(0, stop, mDstPath, true);
}
}
canvas.drawPath(mDstPath, mPaint);
}
@Override
protected void onDetachedFromWindow() {
super.onDetachedFromWindow();
if (mValueAnimator != null) {
mValueAnimator.cancel();
mValueAnimator = null;
}
}
public void startAnim() {
mValueAnimator = ValueAnimator.ofFloat(0, 2);
mValueAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
mCurAnimValue = (Float) animation.getAnimatedValue();
invalidate();
}
});
mValueAnimator.setDuration(2000);
mValueAnimator.start();
}
}

使用自定义控件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class MainActivity extends AppCompatActivity {
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.act_main);
findViewById(R.id.start_btn).setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
TestView tv = findViewById(R.id.test_view);
tv.startAnim();
}
});
}
}