本文记录一下仿支付宝支付结果动画过程。
效果如下:
首先,实现一个首尾互相追赶,忽长忽短的动画过程。 (与google的等待动画相似)
这里通过canvas的drawArc方式实现, 该方法签名如下:
public void drawArc(@NonNull RectF oval, float startAngle, float sweepAngle, boolean useCenter,
@NonNull Paint paint) {
super.drawArc(oval, startAngle, sweepAngle, useCenter, paint);
}
参数中有起始角度和扫过角度, 我们可以通过改变起始角度 startAngle 和 扫过的角度 sweepAngle 来实现这个动画的过程。
故我们用两个属性动画分别控制这两个变量,代码如下:
/**
* 开始加载动画
*/
public void startLoadingAnimation() {
Log.i(TAG, "startLoadingAnimation: ");
mStatus = Status.LOADING;
mStartAngleAnimator = ValueAnimator.ofFloat(0, 360);
mStartAngleAnimator.setDuration(2000);
mStartAngleAnimator.setRepeatCount(ValueAnimator.INFINITE);
mStartAngleAnimator.setInterpolator(ANGLE_INTERPOLATOR);
mStartAngleAnimator.setRepeatMode(ValueAnimator.RESTART);
mStartAngleAnimator.addUpdateListener(animation -> {
mCurrentGlobalAngle = (float) animation.getAnimatedValue();
invalidate();
});
mStartAngleAnimator.start();
// mCurrentSweepAngle 从 0 到 300 变化
mSweepAngleAnimator = ValueAnimator.ofFloat(0, 360 - 2 * MIN_SWEEP_ANGLE);
mSweepAngleAnimator.setDuration(600);
mSweepAngleAnimator.setRepeatCount(ValueAnimator.INFINITE);
mSweepAngleAnimator.setInterpolator(SWEEP_INTERPOLATOR);
mSweepAngleAnimator.setRepeatMode(ValueAnimator.RESTART);
mSweepAngleAnimator.addUpdateListener(animation -> {
mCurrentSweepAngle = (float) animation.getAnimatedValue();
invalidate();
});
mSweepAngleAnimator.addListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationRepeat(Animator animation) {
super.onAnimationRepeat(animation);
// 重复动画时修正 mCurrentGlobalAngle 开始角度的。
toggleAppearingMode();
}
});
mSweepAngleAnimator.start();
}
根据这两个值来绘制圆弧,代码如下:
if (mStatus == Status.LOADING) {
float startAngle = mCurrentGlobalAngle - mCurrentGlobalAngleOffset;
float sweepAngle = mCurrentSweepAngle;
if (!mModeAppearing) {
// 圆弧变短的过程, startAngle 累加上 sweepAngle 形成一种旋转的假像
startAngle += sweepAngle;
// mCurrentSweepAngle从 0 到 300 变化,故这里 sweepAngle 会减小
sweepAngle = 360 - sweepAngle - MIN_SWEEP_ANGLE;
} else {
// 圆弧变长的过程
sweepAngle += MIN_SWEEP_ANGLE;
}
canvas.drawArc(mOval, startAngle, sweepAngle, false, mPaint);
// 保存相关值,作为后面画整圆的起始值
mCurrentGlobalAngle = startAngle;
mCurrentSweepAngle = sweepAngle;
}
如上代码通过 sweepAngle 与 sweepAngle累加形成旋转的动画,而 sweepAngle角度控制弧长忽长忽短。 其中
通过 float startAngle = mCurrentGlobalAngle - mCurrentGlobalAngleOffset; 方式来计算 startAngle 是为了修正动画一次后,将 startAngle 停留在上一次结束的位置,而不是从0重新开始(这样会造成动画抖动不连贯)。
mCurrentGlobalAngleOffset的值计算方法如下:
/**
* 如果完成一次扫的动画,就要重新计算开始角度的偏移,以防动画抖动不连贯
*/
private void toggleAppearingMode() {
Log.i(TAG, "toggleAppearingMode: ");
mModeAppearing = !mModeAppearing;
if (mModeAppearing) {
mCurrentGlobalAngleOffset = (mCurrentGlobalAngleOffset + 2 * MIN_SWEEP_ANGLE) % 360;
}
}
因为 mCurrentGlobalAngle 动画一次,转过的角度为360 度,并且还要累加扫过的300度。故弧长变长的一次动画过程,mCurrentGlobalAngle 转过的角度为 360 + 300,也即-60度的位置。
故 mCurrentGlobalAngle 再次动画时,起始值为0,减去 mCurrentGlobalAngleOffset 为 60 的整数倍,(每次动画停止的位置不同,但分析类同)正好停在上一次动画结束的位置,这样就让动画衔接起来了。
其次,再将上面的圆弧变成整圆。 由于之前的等待框在弧长最长时还有30度的弧未闭合,故先执行一个过度的动画,让圆圈以动画的形式闭合,然后再来绘制对勾 或 叉。
动画部分代码如下:
/**
* 将有缺口的圆圈画完整,然后再画出相应的状态
*/
public void startWholeCircleAnimation(AnimatorListenerAdapter adapter) {
mStatus = Status.MIDDLE;
Log.i(TAG, "startWholeCircleAnimation: ");
if (mSweepAngleAnimator != null && mSweepAngleAnimator.isRunning()) {
mSweepAngleAnimator.cancel();
}
if (mStartAngleAnimator != null && mStartAngleAnimator.isRunning()) {
mStartAngleAnimator.cancel();
}
mStartAngleAnimator = ValueAnimator.ofFloat(mCurrentGlobalAngle % 360, 360);
mStartAngleAnimator.setDuration(2000);
mStartAngleAnimator.setRepeatCount(ValueAnimator.INFINITE);
mStartAngleAnimator.setInterpolator(ANGLE_INTERPOLATOR);
mStartAngleAnimator.setRepeatMode(ValueAnimator.RESTART);
mStartAngleAnimator.addUpdateListener(animation -> {
mCurrentGlobalAngle = (float) animation.getAnimatedValue();
invalidate();
});
mStartAngleAnimator.start();
// 只需将扫过的角度调整为360度即可。
mSweepAngleAnimator = ValueAnimator.ofFloat(mCurrentSweepAngle % 360, 360);
mSweepAngleAnimator.setDuration(600);
mSweepAngleAnimator.setInterpolator(SWEEP_INTERPOLATOR);
mSweepAngleAnimator.addUpdateListener(animation -> {
mCurrentSweepAngle = (float) animation.getAnimatedValue();
invalidate();
});
mSweepAngleAnimator.addListener(adapter);
mSweepAngleAnimator.start();
}
这两个动画,分别以上一次结束动画时的 mCurrentGlobalAngle 和 mCurrentSweepAngle 为起始值,均动画到360度。
对应的绘制代码如下:
if (mStatus == Status.MIDDLE) {
float startAngle = mCurrentGlobalAngle;
float sweepAngle = mCurrentSweepAngle;
canvas.drawArc(mOval, startAngle, sweepAngle, false, mPaint);
}
至此,就绘制了一个整圆了。
然后,分别绘制对勾 和叉,这里使用的是的path结合pathmeasure来实现。 代码如下:
/**
* 构建对勾的path,画的比较随意
*/
private void initSuccessPath() {
Log.i(TAG, "initSuccessPath: ");
// 主要是画一个对勾的形状
mSuccPath.moveTo(mCenterX - mInnerRectSize / 2, mCenterY);
mSuccPath.lineTo(mCenterX, mCenterY + mInnerRectSize / 2);
mSuccPath.lineTo(mCenterX + mInnerRectSize / 2, mCenterY);
// 整体向上移动一点,好看一些
mSuccPath.offset(0, -mInnerRectSize / 4);
// 先克隆一个path用来计算目标path有多少个轮廓。
Path tmpPath = new Path(mSuccPath);
// 起始是 1
int pathCount = 1;
PathMeasure pm = new PathMeasure(tmpPath, false);
// 此种情形的path由于是连续的,只有1条轮廓。 (后面错误的path有两条轮廓)
while (pm.nextContour()) {
pathCount++;
}
startSuccAnimation(pathCount);
}
private void startSuccAnimation(int pathCount) {
Log.i(TAG, "startSuccAnimation: ");
mStatus = Status.SUCC;
mDst.reset();
PathMeasure measure = new PathMeasure(mSuccPath, false);
ValueAnimator va = ValueAnimator.ofFloat(0.f, 1.0f);
va.setDuration(600);
va.setInterpolator(new LinearInterpolator());
va.setRepeatMode(ValueAnimator.RESTART);
// 有几个轮廓就执行几次动画
va.setRepeatCount(pathCount);
va.addUpdateListener(animation -> {
float curVal = (float) animation.getAnimatedValue();
measure.getSegment(0, curVal * measure.getLength(), mDst, true);
invalidate();
});
va.addListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationRepeat(Animator animation) {
super.onAnimationRepeat(animation);
// 切换该path的下一个轮廓。
measure.nextContour();
}
});
va.start();
}
对于错误状态的处理也同上,只是构建错误path路径不一样。具体代码见文后完整代码。
对于这两种状态的绘制代码比较简单,如下:
if (mStatus == Status.SUCC) {
canvas.drawArc(mOval, 0, 360, false, mPaint);
canvas.drawPath(mDst, mPaint);
} else if (mStatus == Status.FAIL) {
canvas.drawArc(mOval, 0, 360, false, mPaint);
canvas.drawPath(mDst, mPaint);
}
完成之后,对外暴露一个设置状态的接口即可。
本文只是记录实现过程中的一些想法,文中代码只截取了部分,如果不慎点进来可以结合后文完整代码查看。
注意事项:
1、ValueAnimator 默认的插值器为 AccelerateDecelerateInterpolator , 在某些不断重复的旋转过程中,如果不设置线性插值器,可能会有卡顿一下的感觉。
2、在用 pathmeasure 计算轮廓数量时,最好克隆一个path。因为 调用nextContour后会切换到下一条轮廓,可能会导致绘制不正确。
3、在使用 measure.getSegment 截取path片断时,会将结果add 进 mDstPath,故在适当时候要reset一下。
4、上面等待动画计算有点不好理解,可以使用只动态改变扫过的角度,同时加入旋转来实现此动画。
5、path有个offset方法可以用来移动path的位置。
参考:
1、circular-progress-button 。