概述
最近在研究Android的路径动画,恕我直言,PathMeasure是个神器,可以帮助我们绘制任意的路径,只要你能draw出来的Path,它都能绘制出来,你可能会问,既然drawPath都可以了,要它何用? PathMeasure的强大之处在于它能够通过根据起点和终点截取某一段路径进行绘制,可以理解为对Path片段的绘制,然你可能还是会说:
但如果再结合属性动画,那简直就可以为所欲为了。
突然想到之前遇到过的一些支付场景经常会有一个支付状态的展示动画,就是一直循环转圈然后最终打勾或者打叉的效果,感觉也可以用路径动画写一个,最终效果图如下:
需要定制的特性
1.绘制的颜色
2.线条的粗细
3.加载状态下每圈的速度
4.打勾或打叉的速度
实现思路
可以看到这个View主要由两部分组成,1.旋转加载中的动画。 2.展示结果的动画。可以分为各自的路径和动画器进行实现,一开始循环加载动画,直到传进结果才切换为结果动画,转圈动画可以用一条Circle路径结合ValueAnimator进行绘制,结果动画可以用move和line绘制出❌和✔,同样结合ValueAnimator进行绘制。PathMeasure是最关键的部分,需要通过它来截取路径的某一段,再通过属性动画不断刷新,达到路径动起来的效果。
1)设置硬编码
不设置硬编码的话,PathMeasure中的getSegment在将Path添加到dst数组中时会被导致一些错误,因此需要在初始化的时候设置一下,只针对该View的层级设置:
public PayLoadingView(Context context) {
this(context, null);
}
public PayLoadingView(Context context, @Nullable AttributeSet attrs) {
this(context, attrs, 0);
}
public PayLoadingView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
//设置硬编码
setLayerType(View.LAYER_TYPE_SOFTWARE, null);
}
2)测量大小,初始化路径
我们需要根据View的宽和高,来决定我们的圆圈范围和半径,以及所有图案的中心点。存在这样一种情况,在使用的时候传进来的View的宽和高不一致(即不是正方形),但由于我们最终的图案是正圆形,那肯定必须以宽和高之中比较小的那一个值除2,再减去线条的粗细来做为最终圆圈的半径:
mRadius:圆圈半径
mWidth mHeight:View宽高
mStrokeWidth:线条宽度
int mRadius = mWidth >= mHeight ? mHeight / 2 - mStrokeWidth : mWidth / 2 - mStrokeWidth;
得到半径就好办了,圆圈就直接以View中心为圆点,mRadius为半径绘制而成。绘制【成功状态】首先需要添加一个整圆,再由三个点组成一个折线,绘制失败同样是绘制一个整圆,再绘制两条以中心对称相交的直线,如图:
代码如下:
@Override
protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
super.onLayout(changed, left, top, right, bottom);
int mWidth = right - left;
int mHeight = bottom - top;
int mRadius = mWidth >= mHeight ? mHeight / 2 - mStrokeWidth : mWidth / 2 - mStrokeWidth;
int centerX = mWidth / 2;
int centerY = mHeight / 2;
//添加【加载】路径
mLoadingPath.addCircle(centerX, centerY, mRadius, Path.Direction.CW);
//添加【成功】会首先添加外层的圆圈,再绘制里面的勾
mSuccessPath.addCircle(centerX, centerY, mRadius, Path.Direction.CW);
mSuccessPath.moveTo(centerX - mRadius / 2, centerY);
mSuccessPath.lineTo(centerX - mRadius / 6, centerY + mRadius / 2);
mSuccessPath.lineTo(centerX + mRadius * 2 / 3, centerY - mRadius / 3);
//添加【失败】会首先添加外层的圆圈,再绘制里面的叉
mFailPath.addCircle(centerX, centerY, mRadius, Path.Direction.CW);
mFailPath.moveTo(centerX - mRadius / 2, centerY - mRadius / 2);
mFailPath.lineTo(centerX + mRadius / 2, centerY + mRadius / 2);
mFailPath.moveTo(centerX + mRadius / 2, centerY - mRadius / 2);
mFailPath.lineTo(centerX - mRadius / 2, centerY + mRadius / 2);
mLoadingMeasure.setPath(mLoadingPath, false);
}
3)添加动画
上一步已经添加好了所有路径,接下来就是动画部分了,创建两个ValueAnimator,将计算出来的进度值作为我们getSegment的参数,调用invalidate()进行刷新。由于整个过程涉及状态的变更,所以我们可以先枚举几个状态,便于判断和切换状态:
private enum Status {
//成功
SUCCESS,
//失败
FAIL,
//加载中
LOADING
}
Loading动画
其中用于Loading动画的需要设置为无限循环播放,直到某个时刻状态被外界更改为SUCCESS或者FAIL的时候,就可以停止动画,注意动画细节,可以理解为一条路径有它的头部和尾部,是先慢慢出现半圈,接着尾部开始动起来,直到慢慢追上头部,最终合二为一:
mLoadingAnimator = ValueAnimator.ofFloat(0, 1);
mLoadingAnimator.setRepeatCount(ValueAnimator.INFINITE);
mLoadingAnimator.setDuration(1000);
mLoadingAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator valueAnimator) {
if (mStatus == Status.SUCCESS || mStatus == Status.FAIL) {
Log.d(TAG, "停止Loading,开始绘制结果");
mLoadingAnimator.cancel();
mResultAnimator.start();
return;
}
float curValue = (float) valueAnimator.getAnimatedValue();
if (curValue > 0.5) {
mStartPos = curValue * 2 - 1;
mEndPos = curValue;
} else {
mEndPos = curValue;
mStartPos = 0;
}
invalidate();
}
});
我们设置动画的值是0~1,当小于0.5时,即刚好达到一半之前,起点不变,终点不断增加,当越过0.5这个界限之后,起点开始动起来了,并且速度要比终点快,直到最终起点与终点都等于1,这里的mStartPos和mEndPos在onDraw的时候会用到。
Loading的onDraw相关代码如下:
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
if (mStatus != Status.SUCCESS && mStatus != Status.FAIL)) {
drawLoading(canvas);
return;
}
...
}
private void drawLoading(Canvas canvas) {
mSegPath.reset();
mLoadingMeasure.getSegment(mStartPos * mLoadingMeasure.getLength(), mEndPos * mLoadingMeasure.getLength(), mSegPath, true);
canvas.drawPath(mSegPath, mPaint);
}
刚才的动画中不断地计算并更新mStartPos和mEndPos的值,这里就派上用场了,可以看到每次onDraw的时候,都会先把之前的旧的剪切路径(mSegPath)重置,然后根据mStartPos和mEndPos来计算起点和终点,将其截取并存到mSegPath中。
结果动画
结果动画是先绘制了一个完整的圆,接着绘制中心部分,先看它的ValueAnimator:
mResultAnimator = ValueAnimator.ofFloat(1, 4);
mResultAnimator.setDuration(3000);
mResultAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator valueAnimator) {
mCurResultPos = (float) valueAnimator.getAnimatedValue();
if (mCurResultPos >= 2 && !mCircleDrawFinish) {
mCircleDrawFinish = true;
mCurResultPos = 2;
}
if (mCurResultPos >= 3 && !mFailDrawHalf) {
mFailDrawHalf = true;
mCurResultPos = 3;
}
invalidate();
}
});
注意,这里mCircleDrawFinish和mFailDrawHalf这两个boolean变量都是用来记录2和3的分界线的,这是由于ValueAnimator无法确定每次都能在回调过程中得到这两个值(比如本例设置的ValueAnimator.ofFloat是1~4这个过程中,只能保证会得到1和4,但不能保证过程中一定能得到2或3)。由于结果动画不像Loading动画,只会运动一次,所以PathMeasure的getSegment(float startD, float stopD, Path dst, boolean startWithMoveTo)的起点可以一直为0,只需要控制stopD就行了。
结果动画的onDraw关键代码:
private void drawResult(Canvas canvas) {
if (mCurResultPos < 2) {
mSegPath.reset();
mResultMeasure.getSegment(0, (mCurResultPos - 1) * mResultMeasure.getLength(), mSegPath, true);
} else if (mCurResultPos == 2) {
Log.d(TAG, "结果外圈绘制结束,开始绘制中心部分");
mResultMeasure.getSegment(0, (mCurResultPos - 1) * mResultMeasure.getLength(), mSegPath, true);
mResultMeasure.nextContour();
} else {
drawResultCenter();
}
canvas.drawPath(mSegPath, mPaint);
}
private void drawResultCenter() {
if (mStatus == Status.SUCCESS) {
mResultMeasure.getSegment(0, (mCurResultPos - 2) * mResultMeasure.getLength(), mSegPath, true);
return;
}
if (mCurResultPos < 3) {
mResultMeasure.getSegment(0, (mCurResultPos - 2) * mResultMeasure.getLength(), mSegPath, true);
} else if (mCurResultPos == 3) {
mResultMeasure.nextContour();
} else {
mResultMeasure.getSegment(0, (mCurResultPos - 3) * mResultMeasure.getLength(), mSegPath, true);
}
}
首先判断当前动画值,这也就是刚才为何设置为1~4 的原因了,1~2 是外圈动画,2~4 是结果动画(可能你会问,为啥不是2~3,因为❌是有两条线,所以需要分为两次)
如果小于2, 则起点为0,终点为(mCurResultPos - 1) * mResultMeasure.getLength()这个时候mResultMeasure的getLength会获取的只是第一段路径的长度,我们刚才上一步在初始化路径的时候,对于mSuccessPath或者mFailPath都是先addCircle添加了一段圆路径,所以在这里获取到的正是这一个圆,所以理所当然就绘制出了一个整圆。
如果等于2,就要调用mResultMeasure.nextContour()切换到下一段路径,即我们刚才addCircle之后的那些Path(✔或❌)
如果大于2,就要根据结果来区分,假如是成功状态,则只需要将终点设为(mCurResultPos - 2) * mResultMeasure.getLength(),这里减2是为了保持它的值在0~1之间(因为此时已经大于2了),假如是失败状态,则再分割为两段(以3为界限),先绘制❌的第一条线,接着nextContour切换到第二条线继续绘制。
4)提供外界调用接口
首先肯定要有一个启动Loading的接口:
/**
* 开始加载
*/
public void startLoading() {
mStatus = Status.LOADING;
mLoadingAnimator.start();
mCurResultPos = 1;
mCircleDrawFinish = false;
mFailDrawHalf = false;
Log.d(TAG, "开始展示");
}
由于我们的LoadingAnimator是设置的无限循环,所以需要有一个可以随时设置结果并停止动画的接口:
/**
* 展示结果
*
* @param flag true:成功 false:失败
*/
public void showResult(boolean flag) {
mResultMeasure.setPath(flag ? mSuccessPath : mFailPath, false);
mStatus = flag ? Status.SUCCESS : Status.FAIL;
}
5)其它优化
由于涉及比较多的Animator,并且还设置了无限循环,所以为了防止内存泄漏问题,还需要在View被移除的时候取消当前动画:
@Override
protected void onDetachedFromWindow() {
super.onDetachedFromWindow();
mLoadingAnimator.cancel();
mResultAnimator.cancel();
}
应用
xml布局中引用(这里的宽高由设置的弧长半径决定,只需设置wrap_conetnt即可):
Acitivity中实例代码如下:
mArcMenuView = findViewById(R.id.arc_menu);
List menuItems = new ArrayList<>();
menuItems.add(R.drawable.ic_menu_camera);
menuItems.add(R.drawable.ic_menu_photo);
menuItems.add(R.drawable.ic_menu_share);
mArcMenuView.setMenuItems(menuItems);
mArcMenuView.setClickItemListener(new YArcMenuView.ClickMenuListener() {
@Override
public void clickMenuItem(int resId) {
switch (resId){
case R.drawable.ic_menu_camera:
Toast.makeText(getApplicationContext(), "点击了相机", Toast.LENGTH_SHORT).show();
break;
case R.drawable.ic_menu_photo:
Toast.makeText(getApplicationContext(), "点击了相册", Toast.LENGTH_SHORT).show();
break;
case R.drawable.ic_menu_share:
Toast.makeText(getApplicationContext(), "点击了分享", Toast.LENGTH_SHORT).show();
break;
}
}
});
后续
本文主要是结合PathMeasure的特性来绘制路径动画效果,篇幅较大,但由于时间比较短,可能还是对PathMeasure的特性描述得不是很清楚,后面会不断更新优化,欢迎关注GitHub项目:
源码传送门:GitHub-ZJYWidget
CSDN博客:IT_ZJYANG
简 书:Android小Y
里面还有很多实用的自定义View源码及demo,会长期维护,欢迎Star~ 如有不足之处或建议还望指正,相互学习,相互进步,如果觉得不错动动小手给个喜欢, 谢谢~