路径动画是一种非常有用的动画实现方式,使用SVG可以很容易地实现路径动画效果,不过SVG要求的版本比较高很多应用目前还无法完全支持。Android系统中提供的PathMeasure工具类能够计算路径的长度,根据提供的路径区间获取路径的某一部分,还可以获取路径中的任意一点的位置和切线角度,下面就通过简单示例学习这个强大的工具。
方法名 | 意义 |
---|---|
PathMeasure() | 创建一个空的PathMeasure |
PathMeasure(Path path, boolean forceClosed) | 创建 PathMeasure 并关联一个指定的Path,需要注意这个Path一定要初始化过,否则只会测量空的Path对象,forceCloase是否将指定的Path闭合,只影响PathMeasure计算不会对原始Path产生任何影响 |
setPath(Path path, boolean forceClosed) | 关联一个Path,forceCloase是否将指定的Path闭合,只影响PathMeasure计算不会对原始Path产生任何影响 |
isClosed() | 路径Path是否闭合 |
getLength() | 获取Path的长度,返回值是一个float类型的数字 |
nextContour() | 如果Path里包含多个不相连的路径,跳转到下一个轮廓,可以通过这个方法遍历Path中所有独立路径 |
getSegment(float startD, float stopD, Path dst, boolean startWithMoveTo) | 截取片段 |
getPosTan(float distance, float[] pos, float[] tan) | 获取指定长度的位置坐标及该点切线值 |
getMatrix(float distance, Matrix matrix, int flags) | 获取指定长度的位置坐标及该点Matrix |
CheckBox是一种常见的用户控件,每次选中的时候都会在最前面的空格内展示“✔“图形,而且这个图形是自己绘制出来的,现在就使用PathMeasure来简单的模拟实现这个对号的绘制动画效果。
public class PathView extends AppCompatImageView {
private Path mRight;
private Path mTmpPath;
private Paint mPaint;
private PathMeasure mPathMeasure;
private ValueAnimator mPathAnimator;
public PathView(Context context) {
this(context, null);
}
public PathView(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public PathView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
mPaint = new Paint();
mPaint.setColor(getResources().getColor(R.color.colorAccent));
mPaint.setStrokeWidth(5);
mPaint.setStyle(Paint.Style.STROKE);
mRight = new Path();
mTmpPath = new Path();
}
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
mRight.moveTo(0, h / 2);
mRight.lineTo(w / 4, h);
mRight.lineTo(w, 0);
mPathMeasure = new PathMeasure(mRight, false);
float length = mPathMeasure.getLength();
mPathAnimator = ValueAnimator.ofFloat(0f, length);
mPathAnimator.setDuration(1000);
mPathAnimator.setInterpolator(new LinearInterpolator());
mPathAnimator.setRepeatCount(ValueAnimator.INFINITE);
mPathAnimator.setRepeatMode(ValueAnimator.RESTART);
mPathAnimator.addUpdateListener(animation -> {
float current = (float) animation.getAnimatedValue();
mTmpPath.reset();
mPathMeasure.getSegment(0f, current, mTmpPath, true);
invalidate();
});
mPathAnimator.start();
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
mPaint.setStrokeWidth(5);
// 绘制外部边框
canvas.drawRect(4, 4, getMeasuredWidth() - 4, getMeasuredHeight() - 4, mPaint);
mPaint.setStrokeWidth(10);
// 绘制内部的“✔”
canvas.drawPath(mTmpPath, mPaint);
}
}
在onSizeChanged方法中得到对号的长度,并且创建从0到对号长度的属性动画,在动画执行过程中通过getSegment获取从0到current的路径片段,最后刷新控件绘制截取到的对号片段,这样就实现了对号绘制效果。
除了方框类型的CheckBox还有圆形的CheckBox控件,这时候不但内部的对号需要绘制,外部的圆形路径也需要绘制,在一个Path内部有两个不相互连接的路径直接使用PathMeasure操作的是第一个路径,这里就是圆形路径,为了能够继续绘制第二个对号路径需要调用nextContour跳转到下一条路径。
public class CirclePathView extends AppCompatImageView {
private Path mPath;
private Paint mPaint;
private PathMeasure mPathMeasure;
private ValueAnimator mPathAnimator;
private ValueAnimator mRightAnimator;
private Path mTmpPath;
private Path mRightPath;
public CirclePathView(Context context) {
this(context, null);
}
public CirclePathView(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public CirclePathView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
mPaint = new Paint();
mPaint.setColor(getResources().getColor(R.color.colorAccent));
mPaint.setStrokeWidth(10);
mPaint.setStyle(Paint.Style.STROKE);
mPath = new Path();
mTmpPath = new Path();
mRightPath = new Path();
}
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
float padding = CommonUtils.dp2px(4);
float rightPadding = padding + CommonUtils.dp2px(2);
// 添加圆形路径
mPath.addCircle(w / 2, h / 2, Math.min(w / 2, h / 2) - padding, Path.Direction.CCW);
// 添加对号路径
mPath.moveTo(rightPadding, h / 2);
mPath.lineTo(w / 3, h - rightPadding);
mPath.lineTo(w - rightPadding, rightPadding);
mPathMeasure = new PathMeasure(mPath, false);
float length = mPathMeasure.getLength();
mPathAnimator = ValueAnimator.ofFloat(0f, length);
mPathAnimator.setDuration(1000);
mPathAnimator.setInterpolator(new LinearInterpolator());
mPathAnimator.addUpdateListener(animation -> {
float current = (float) animation.getAnimatedValue();
mTmpPath.reset();
// 获取圆形的路径片段,重绘
mPathMeasure.getSegment(0f, current, mTmpPath, true);
invalidate();
});
mPathAnimator.addListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationEnd(Animator animation) {
// 圆形绘制完成之后,跳转到下一个路径,也就是对号路径
mPathMeasure.nextContour();
float length = mPathMeasure.getLength();
mRightAnimator = ValueAnimator.ofFloat(0f, length);
mRightAnimator.setDuration(1000);
mRightAnimator.setInterpolator(new LinearInterpolator());
mRightAnimator.addUpdateListener(anim -> {
float current = (float) anim.getAnimatedValue();
mRightPath.reset();
// 获取对号路径
mPathMeasure.getSegment(0f, current, mRightPath, true);
invalidate();
});
mRightAnimator.addListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationEnd(Animator animation) {
// 需要将对号路径清空
mRightPath.reset();
// 重新将mPath关联到PathMeasure,否则会使用之前的路径,
// 因为已经绘制了所有的路径,就会展示空白
mPathMeasure.setPath(mPath, false);
// 重新开始绘制
mPathAnimator.start();
}
});
mRightAnimator.start();
}
});
mPathAnimator.start();
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
// 绘制片段
canvas.drawPath(mTmpPath, mPaint);
canvas.drawPath(mRightPath, mPaint);
}
}
上面的多路径绘制就是使用nextContour跳转到下一条路径,继续执行单条路径的绘制,不过在重新开始绘制需要将PathMeasure和Path重新绑定,否则PathMeasure会保持之前的nextContour状态,无法重新绘制。
PathMeasure的getPosTan方法可以获取任意长度处的位置和切线角度值,可以在属性动画中得到当前长度的位置和角度,再调整图片对象的位置和角度,这样就好像图片对象正沿着路径做运动。
public class FlightPathView extends AppCompatImageView {
private Path mPath;
private Path mTmpPath;
private Paint mPaint;
private PathMeasure mPathMeasure;
private ValueAnimator mPathAnimator;
private Bitmap mFlight;
private float[] mPos;
private float[] mTan;
private Matrix mMatrix;
public FlightPathView(Context context) {
this(context, null);
}
public FlightPathView(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public FlightPathView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
mPaint = new Paint();
mPaint.setColor(getResources().getColor(R.color.colorAccent));
mPaint.setStrokeWidth(10);
mPaint.setStyle(Paint.Style.STROKE);
mPath = new Path();
mTmpPath = new Path();
mFlight = BitmapFactory.decodeResource(getResources(), R.drawable.flight_ic);
mPos = new float[2];
mTan = new float[2];
mMatrix = new Matrix();
}
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
// 绘制圆形路径
mPath.addCircle(w / 2, h / 2, Math.min(w / 2, h / 2) - CommonUtils.dp2px(20), Path.Direction.CCW);
mPathMeasure = new PathMeasure(mPath, false);
float length = mPathMeasure.getLength();
mPathAnimator = ValueAnimator.ofFloat(0f, length);
mPathAnimator.setDuration(2000);
mPathAnimator.setInterpolator(new LinearInterpolator());
mPathAnimator.setRepeatCount(ValueAnimator.INFINITE);
mPathAnimator.setRepeatMode(ValueAnimator.RESTART);
mPathAnimator.addUpdateListener(animation -> {
float current = (float) animation.getAnimatedValue();
mTmpPath.reset();
// 获取当前长度的片段
mPathMeasure.getSegment(0f, current, mTmpPath, true);
// 获取当前长度的位置和角度
mPathMeasure.getPosTan(current, mPos, mTan);
mMatrix.reset(); // 重置Matrix
float degrees = (float) (Math.atan2(mTan[1], mTan[0]) * 180.0 / Math.PI); // 计算图片旋转角度
mMatrix.postRotate((degrees + 45f), mFlight.getWidth() / 2, mFlight.getHeight() / 2); // 旋转图片
mMatrix.postTranslate(mPos[0] - mFlight.getWidth() / 2, mPos[1] - mFlight.getHeight() / 2); // 移动图片
invalidate();
});
mPathAnimator.start();
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
canvas.drawPath(mTmpPath, mPaint);
canvas.drawBitmap(mFlight, mMatrix, mPaint);
}
}
在onSizeChanged方法中首先通过getPosTan方法获取当前长度的位置和角度,位置是相对于控件坐标系的可以直接使用,mTan则需要通过反函数计算出弧度角再转换成角度值,由于飞机图片是45度旋转了所以后面要加上45度做调整。