一、效果展示
动画分为三种状态:Loading、Success、Fail,可以点击按钮切换状态。
加载后成功的效果如下所示。
加载后失败的效果如下所示。
二、前置知识
1. ValueAnimator
ValueAnimator是属性动画的一种,它不直接改变View的属性,而是不断生成一个代表动画进度的值,用户通过该值改变View的某些属性达到动画的效果。ValueAnimator基本的用法如下。
ValueAnimator anim = ValueAnimator.ofFloat(0, 1); // 动画的进度为[0, 1]中某个值
anim.setDuration(1000); // 动画的时长为 1s
anim.setRepeatMode(ValueAnimator.RESTART); // 动画重复时重新开始
anim.setRepeatCount(ValueAnimator.INFINITE); // 动画重复次数为无限次
anim.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
progress = (float) animation.getAnimatedValue(); // 获取到动画的当前进度
invalidate(); // 立即重绘当前 View
}
});
anim.start();
程序通过为ValueAnimator设置监听来获取当前的进度progress,这里的进度为[0, 1]中的某个float值。如果有一个这样的需求,要求某个View从透明慢慢变为显示,就可以通过view.setAlpha(progress * 255)
来实现这种淡出的效果。
除了AnimatorUpdateListener,ValueAnimator还有个监听器AnimatorListener,主要用于对动画的状态进行监听,4个方法如下。
public void start() { } // 动画开始时调用
public void end() { } // 动画结束时调用
public void cancel() { } // 动画取消时调用
public void repeat() { } // 动画重复时调用
2. PathMeasure
PathMeasure用于实现路径动画,它能够截取Path中的一段内容进行显示。构造方法如下。
参数中的path就是PathMeasure 之后截取的对象,forceClosed只对测量PathMeasure长度的结果有影响,一般设置为false。
PathMeasure pm = new PathMeasure(Path path, boolean forceClosed);
PathMeasure的常用函数如下。
float getLength(); // 获取当前段的长度(注意是当前)
boolean nextContour(); // 跳转到Path的下一条曲线,成功返回true
/**
* 通过startD和stopD来截取Path中的某个片段
* 结果保存至dst
* startWithMoveTo为true时,截取的path保存原样
*/
boolean getSegment(float startD, float stopD, Path dst, boolean startWithMoveTo);
如果你还不理解也不要紧,下面让我们在实战中去理解ValueAnimator和PathMeasure。
三、Loading动画
首先来完成Loading动画,该动画的主体是一个圆,在动画的过程中不断对圆切割,展示其中的一部分。实现加载框的有种做法是在View每次调用onDraw(Canvas)
时调整圆弧的起始角度(startAngle)和扫过的角度(sweepAngle),再根据角度绘制Arc圆弧,但是这种做法有3个问题。第一是不方便控制动画的时间,也就是duration; 第二是不方便设置插值器,导致动画一直为匀速;第三是无法对动画的各种状态(开始、结束等)进行监听。
而ValueAnimator正好能解决上述问题,我们通过ValueAnimator计算动画的进度,再通过PathMeasure切割圆弧。我们一步一步来,先尝试把简单的圆画出来。
自定义一个PayTestView ,在其中新建一个ValueAnimator,该动画的进度从0到1,并设置为无限循环,在监听器的onAnimationUpdate()
方法中将动画的进度赋值给mProgress。随后新建一个PathMeasure,因为所要截取的动画为一个圆,所以新建PathMeasure时传入一个圆形Path。
public class PayTestView extends View {
private float mProgress; // 代表动画当前进度
private Paint mBluePaint; // 蓝色画笔
private ValueAnimator mLoadingAnimator;
private PathMeasure mLoadingPathMeasure;
private Path mDstPath; // 保存PathMeasure切割后的内容
public PayTestView(Context context) {
super(context);
init();
}
public PayTestView(Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
init();
}
public PayTestView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
init();
}
private void init() {
setLayerType(LAYER_TYPE_SOFTWARE, null); // 取消硬件加速
// 画笔设置
mBluePaint = new Paint(Paint.ANTI_ALIAS_FLAG); // 画笔抗锯齿
mBluePaint.setColor(Color.BLUE);
mBluePaint.setStyle(Paint.Style.STROKE);
mBluePaint.setStrokeWidth(10);
mBluePaint.setStrokeCap(Paint.Cap.ROUND);
// 新建 PathMeasure
Path loadingPath = new Path();
loadingPath.addCircle(100, 100, 60, Path.Direction.CW); // CW代表顺时针
mLoadingPathMeasure = new PathMeasure(loadingPath, false);
mDstPath = new Path();
// 动画
mLoadingAnimator = ValueAnimator.ofFloat(0, 1);
mLoadingAnimator.setDuration(1500);
mLoadingAnimator.setRepeatMode(ValueAnimator.RESTART);
mLoadingAnimator.setRepeatCount(ValueAnimator.INFINITE);
mLoadingAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
mProgress = (float) animation.getAnimatedValue();
invalidate();
}
});
mLoadingAnimator.start();
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
mDstPath.reset();
float stop = mLoadingPathMeasure.getLength() * mProgress;
mLoadingPathMeasure.getSegment(0, stop, mDstPath, true);
canvas.drawPath(mDstPath, mBluePaint);
}
}
可以发现在onDraw()
中使用getSegment(start, stop, ...)
切割圆弧时,起点start永远是0,而终点是整个圆弧的长度乘当前进度。每次调用onDraw()
时就将从0到当前进度的圆弧切割,因此得到的是圆弧从0度增长到到360度的动画,并且循环播放。效果如下。
当前动画中圆弧的起点一直是0,最后的效果比较僵硬,我们尝试修改动画的起点。这里使用启舰《Anroid自定义控件开发入门与实战》中的方法,在mProgress <= 0.5
时,start为0,在mProgress > 0.5
时,start为mProgress * 2 - 1
。修改onDraw()中的代码如下:
mDstPath.reset();
float length = mPathMeasure.getLength();
float stop = mProgress * length;
float start = (float) (stop - (0.5 - Math.abs(mProgress - 0.5)) * length);
mPathMeasure.getSegment(start, stop, mDstPath, true);
这里将start的计算放在了一句代码中,当然用if-else的方法来做可读性会更高。最终的效果如下。
这个效果离之前的展示的Loading动画已经比较接近了,仔细观察可以发现,最终的Loading动画只是在当前的动画上加了一个整体旋转的效果。我们可以通过旋转View的画布(Canvas)来实现,要注意的是,旋转时必须按照Loading动画的圆心进行旋转。
不过对画布的旋转也会影响到之后成功/失败状态下的动画,因此在旋转之前需要将当前的画布保存,然后在Loading动画结束之后恢复画布。
首先定义一个变量标记画布是否被保存了,因为画布只需要保存一次;随后在进入onDraw()
时判断画布是否已经被保存,如果未保存,则保存当前画布,否则跳过。
public class PayTestView extends View {
// ......
private boolean hasCanvasSaved = false; // 画布是否已被保存
private int mCurRotate = 0; // 当前画布旋转的角度
// ......
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
// 在 Loading 状态下 Canvas 会被旋转, 需要在第一次进入时保存
if (!hasCanvasSaved) {
canvas.save();
hasCanvasSaved = true;
}
// Loading 动画
// ......
mCurRotate = (mCurRotate + 2) % 360;
canvas.rotate(mCurRotate, 100, 100);
canvas.drawPath(mDstPath, mBluePaint);
}
}
效果如下所示。
此时的Loading动画已经完成,只不过圆的坐标(100, 100)和半径(60)是固定的。其实我们可以在onSizeChanged()
中获取当前View的高宽,再去设置圆的坐标和半径。这里不细说,后面会在整体代码中贴出。
三、状态切换
整个支付的动画包含3种状态:加载、成功、失败。那么绘制时怎么识别当前的状态?他们之间的状态又是怎么切换的呢?
对于第一个问题,我们可以在onDraw(Canvas)
中根据动画当前的状态来绘制不同的图形。但是要注意在绘制Loading动画之前保存画布;在绘制成功或失败动画之前将原始的画布恢复。onDraw()
中的逻辑如下所示。
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
// 在 Loading 状态下 Canvas 会被旋转, 需要在第一次进入时保存
if (!hasCanvasSaved) {
canvas.save();
hasCanvasSaved = true;
}
// 判断当前动画的状态并绘制相应动画
if (curStatus == STATUS_LOADING) {
// 绘制 Loading 动画
} else if (curStatus == STATUS_SUCCESS) {
// 如果画布还未恢复则将其恢复
if (!hasCanvasRestored) {
canvas.restore();
hasCanvasRestored = true;
}
// 绘制 success 动画
} else if (curStatus == STATUS_FAIL) {
if (!hasCanvasRestored) {
canvas.restore();
hasCanvasRestored = true;
}
// 绘制 fail 动画
}
}
对于第二个问题,状态之间的切换需要外部调用,且只能由loading向success或fail切换。因此View中需要一个供外部修改状态的方法,同时修改状态后需要停止Loading动画。
// 将动画的状态从 Loading 变为 success 或 fail
public void setStatus(int status) {
if (curStatus == STATUS_LOADING && status != STATUS_LOADING) {
curStatus = status;
mLoadingAnimator.end();
}
}
Loading动画结束之后需要开始success动画或fail动画,你可以在上述的setStatus()
方法中开始success/fail动画,也可以为Loading动画设置监听,监听到其结束时开启新动画。这里使用监听的方式,如下所示。
mLoadingAnimator.addListener(new Animator.AnimatorListener() {
@Override
public void onAnimationStart(Animator animation) { }
@Override
public void onAnimationEnd(Animator animation) {
if (curStatus == STATUS_SUCCESS) {
mSuccessAnimator.start();
} else if (curStatus == STATUS_FAIL) {
mFailAnimator.start();
}
}
@Override
public void onAnimationCancel(Animator animation) { }
@Override
public void onAnimationRepeat(Animator animation) { }
});
了解了3种动画的切换逻辑之后,再来看看成功/失败动画的实现。
四、成功动画
之前的Loading动画只有一段路径,就是一个圆。而成功的动画包含两段:外部的圆和内部的勾。之前介绍过PathMeasure是可以通过nextContour()
方法从Path中的一段路径切换到下一段的,因此我们可以构造一个由两段路径构成的PathMeasure。
Path successPath = new Path();
successPath.addCircle(100, 100, 60, Path.Direction.CW);
successPath.moveTo(100- 60 * 0.5f, 100- 60 * 0.2f);
successPath.lineTo(100 - 60 * 0.1f, 100 + 60 * 0.4f);
successPath.lineTo(100 + 60 * 0.6f, 100 - 60 * 0.5f);
mSuccessPathMeasure = new PathMeasure(successPath, false);
代码首先在successPath中添加了外圈的圆,随后moveTo到勾的起点,lineTo到勾的下方,最后lineTo到勾的终点。很显然moveTo之前的是第一段路径,moveTo之后的是第二段路径。
为了在ValueAnimator的进度中将两段路径分开,新建时进度的范围设置为[0, 2]。
mSuccessAnimator = ValueAnimator.ofFloat(0, 2);
// ......
在绘制时,mProgress∈[0, 1]代表外部的圆,mProgress∈[1, 2]代表内部的勾,我们通过一个变量来表示当前在绘制第几段,初始化时为1。
private int mSuccessIndex = 1;
绘制代码如下,当mProgress < 1
时绘制外部的圆,mProgress >= 1
时切换到下一条路径。代码在切换路径之前通过mSuccessPathMeasure.getSegment(0, mSuccessPathMeasure.getLength(), mSuccessDstPath, true)
将第一段路径完整地绘制了一下。如果不使用这句代码,第一段的圆绘制出来不是完整的。
if (mProgress < 1) {
float stop = mSuccessPathMeasure.getLength() * mProgress;
mSuccessPathMeasure.getSegment(0, stop, mSuccessDstPath, true);
} else {
if (mSuccessIndex == 1) {
mSuccessIndex = 2;
mSuccessPathMeasure.getSegment(0, mSuccessPathMeasure.getLength(), mSuccessDstPath, true);
mSuccessPathMeasure.nextContour();
}
float stop = mSuccessPathMeasure.getLength() * (mProgress - 1);
mSuccessPathMeasure.getSegment(0, stop, mSuccessDstPath, true);
}
canvas.drawPath(mSuccessDstPath, mBluePaint);
PS:千万不要在切换路径时使用if (mProgress == 1)
这种写法,首先动画的进度值是float类型的,要判断float值是否“相等”只能用if (Math.abs(mProgress - 1) < 0.01)
这种方式;其次如果把动画执行中的所有进度值打印出来,会是这个样子的:
2019-06-13 00:00:24.842 2224-2224/com.lister.myviews E/TAG: progress = 0.0
2019-06-13 00:00:24.853 2224-2224/com.lister.myviews E/TAG: progress = 5.57065E-4
2019-06-13 00:00:24.869 2224-2224/com.lister.myviews E/TAG: progress = 0.0022275448
......
2019-06-13 00:00:25.604 2224-2224/com.lister.myviews E/TAG: progress = 0.9411293
2019-06-13 00:00:25.621 2224-2224/com.lister.myviews E/TAG: progress = 0.9725146
2019-06-13 00:00:25.637 2224-2224/com.lister.myviews E/TAG: progress = 1.0058903
2019-06-13 00:00:25.656 2224-2224/com.lister.myviews E/TAG: progress = 1.0392599
2019-06-13 00:00:25.675 2224-2224/com.lister.myviews E/TAG: progress = 1.0706271
2019-06-13 00:00:25.693 2224-2224/com.lister.myviews E/TAG: progress = 1.1038773
......
2019-06-13 00:00:26.407 2224-2224/com.lister.myviews E/TAG: progress = 1.9984891
2019-06-13 00:00:26.423 2224-2224/com.lister.myviews E/TAG: progress = 1.9997668
2019-06-13 00:00:26.440 2224-2224/com.lister.myviews E/TAG: progress = 2.0
进度值progress会在多大的范围内逼近1.0是无法确定的,因此直接判断进度值是不是小于1比较妥当。
五、完整代码
fail状态下的动画比较简单,是一个三段的路径,不再赘述。这里贴出完整代码,注释也比较齐全,如果有不完善的地方还望批评指正。
public class PayAnimatorView extends View {
/**
* 动画状态:加载中、成功、失败
*/
public static final int STATUS_LOADING = 1;
public static final int STATUS_SUCCESS = 2;
public static final int STATUS_FAIL = 3;
/**
* 当前动画的状态
*/
private int curStatus;
/**
* loading 动画变量
*/
private PathMeasure mPathMeasure;
private Path mDstPath;
private int mCurRotate = 0;
private float mProgress;
private boolean hasCanvasSaved = false;
private boolean hasCanvasRestored = false;
/**
* success / Fail 动画变量
*/
private PathMeasure mSuccessPathMeasure;
private Path mSuccessDstPath;
private PathMeasure mFailPathMeasure;
private Path mFailDstPath;
/**
* 动画
*/
private ValueAnimator mLoadingAnimator;
private ValueAnimator mSuccessAnimator;
private ValueAnimator mFailAnimator;
private Paint mBluePaint;
private Paint mRedPaint;
private int mCenterX, mCenterY;
public PayAnimatorView(Context context) {
super(context);
init();
}
public PayAnimatorView(Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
init();
}
public PayAnimatorView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
init();
}
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
mCenterX = w / 2;
mCenterY = h / 2;
int radius = (int) (Math.min(w, h) * 0.3);
// 在获取宽高之后设置加载框的位置和大小
Path circlePath = new Path();
circlePath.addCircle(mCenterX, mCenterY, radius, Path.Direction.CW);
mPathMeasure = new PathMeasure(circlePath, true);
mDstPath = new Path();
// 设置 success 动画的 path
Path successPath = new Path();
successPath.addCircle(mCenterX, mCenterY, radius, Path.Direction.CW);
successPath.moveTo(mCenterX - radius * 0.5f, mCenterY - radius * 0.2f);
successPath.lineTo(mCenterX - radius * 0.1f, mCenterY + radius * 0.4f);
successPath.lineTo(mCenterX + radius * 0.6f, mCenterY - radius * 0.5f);
mSuccessPathMeasure = new PathMeasure(successPath, false);
mSuccessDstPath = new Path();
// 设置 fail 动画的 path
Path failPath = new Path();
failPath.addCircle(mCenterX, mCenterY, radius, Path.Direction.CW);
failPath.moveTo(mCenterX - radius / 3, mCenterY - radius / 3);
failPath.lineTo(mCenterX + radius / 3, mCenterY + radius / 3);
failPath.moveTo(mCenterX + radius / 3, mCenterY - radius / 3);
failPath.lineTo(mCenterX - radius / 3, mCenterY + radius / 3);
mFailPathMeasure = new PathMeasure(failPath, false);
mFailDstPath = new Path();
}
private void init() {
// 取消硬件加速
setLayerType(LAYER_TYPE_SOFTWARE, null);
// 初始化画笔
mBluePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
mBluePaint.setColor(Color.BLUE);
mBluePaint.setStyle(Paint.Style.STROKE);
mBluePaint.setStrokeCap(Paint.Cap.ROUND);
mBluePaint.setStrokeWidth(10);
mRedPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
mRedPaint.setColor(Color.RED);
mRedPaint.setStyle(Paint.Style.STROKE);
mRedPaint.setStrokeCap(Paint.Cap.ROUND);
mRedPaint.setStrokeWidth(10);
// 初始化时, 动画为加载状态
curStatus = STATUS_LOADING;
// 新建 Loading 动画并 start
mLoadingAnimator = ValueAnimator.ofFloat(0, 1);
mLoadingAnimator.setDuration(2000);
mLoadingAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
mProgress = (float) animation.getAnimatedValue();
invalidate();
}
});
mLoadingAnimator.addListener(new Animator.AnimatorListener() {
@Override
public void onAnimationStart(Animator animation) { }
@Override
public void onAnimationEnd(Animator animation) {
if (curStatus == STATUS_SUCCESS) {
mSuccessAnimator.start();
} else if (curStatus == STATUS_FAIL) {
mFailAnimator.start();
}
}
@Override
public void onAnimationCancel(Animator animation) { }
@Override
public void onAnimationRepeat(Animator animation) { }
});
mLoadingAnimator.setInterpolator(new AccelerateDecelerateInterpolator());
mLoadingAnimator.setRepeatCount(ValueAnimator.INFINITE);
mLoadingAnimator.setRepeatMode(ValueAnimator.RESTART);
mLoadingAnimator.start();
// 新建 success 动画
mSuccessAnimator = ValueAnimator.ofFloat(0, 2);
mSuccessAnimator.setDuration(1600);
mSuccessAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
mProgress = (float) animation.getAnimatedValue();
invalidate();
}
});
// 新建 fail 动画
mFailAnimator = ValueAnimator.ofFloat(0, 3);
mFailAnimator.setDuration(2100);
mFailAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
mProgress = (float) animation.getAnimatedValue();
invalidate();
}
});
}
/**
* 将动画的状态从 Loading 变为 success 或 fail
*/
public void setStatus(int status) {
if (curStatus == STATUS_LOADING && status != STATUS_LOADING) {
curStatus = status;
mLoadingAnimator.end();
}
}
private int mSuccessIndex = 1;
private int mFailIndex = 1;
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
// 在 Loading 状态下 Canvas 会被旋转, 需要在第一次进入时保存
if (!hasCanvasSaved) {
canvas.save();
hasCanvasSaved = true;
}
// 判断当前动画的状态并绘制相应动画
if (curStatus == STATUS_LOADING) {
mDstPath.reset();
float length = mPathMeasure.getLength();
float stop = mProgress * length;
float start = (float) (stop - (0.5 - Math.abs(mProgress - 0.5)) * length);
mPathMeasure.getSegment(start, stop, mDstPath, true);
// 旋转画布
mCurRotate = (mCurRotate + 2) % 360;
canvas.rotate(mCurRotate, mCenterX, mCenterY);
canvas.drawPath(mDstPath, mBluePaint);
} else if (curStatus == STATUS_SUCCESS) {
if (!hasCanvasRestored) {
canvas.restore();
hasCanvasRestored = true;
}
if (mProgress < 1) {
float stop = mSuccessPathMeasure.getLength() * mProgress;
mSuccessPathMeasure.getSegment(0, stop, mSuccessDstPath, true);
} else {
if (mSuccessIndex == 1) {
mSuccessIndex = 2;
mSuccessPathMeasure.getSegment(0, mSuccessPathMeasure.getLength(),
mSuccessDstPath, true);
mSuccessPathMeasure.nextContour();
}
float stop = mSuccessPathMeasure.getLength() * (mProgress - 1);
mSuccessPathMeasure.getSegment(0, stop, mSuccessDstPath, true);
}
canvas.drawPath(mSuccessDstPath, mBluePaint);
} else if (curStatus == STATUS_FAIL) {
if (!hasCanvasRestored) {
canvas.restore();
hasCanvasRestored = true;
}
if (mProgress < 1) {
float stop = mFailPathMeasure.getLength() * mProgress;
mFailPathMeasure.getSegment(0, stop, mFailDstPath, true);
} else if (mProgress < 2) {
if (mFailIndex == 1) {
mFailIndex = 2;
mFailPathMeasure.getSegment(0, mFailPathMeasure.getLength(),
mFailDstPath, true);
mFailPathMeasure.nextContour();
}
float stop = mFailPathMeasure.getLength() * (mProgress - 1);
mFailPathMeasure.getSegment(0, stop, mFailDstPath, true);
} else {
if (mFailIndex == 2) {
mFailIndex = 3;
mFailPathMeasure.getSegment(0, mFailPathMeasure.getLength(),
mFailDstPath, true);
mFailPathMeasure.nextContour();
}
float stop = mFailPathMeasure.getLength() * (mProgress - 2);
mFailPathMeasure.getSegment(0, stop, mFailDstPath, true);
}
canvas.drawPath(mFailDstPath, mRedPaint);
}
}
}