项目地址:LeafLoadingView
开始实践之前,请关闭硬件加速、关闭硬件加速、关闭硬件加速。
下面主要分析一下这个动画进度条的图形计算和绘制:
-
- 左边弧形部分的绘制
-
- 树叶旋转和飞行的绘制
-
- 风扇的旋转绘制
-
- 进度条的绘制
-
- 完成时的文字绘制
完成了上面四点,这个进度条就已经完成了大半,但是如果想要更精细,还需要下面的计算:
-
- 树叶数量、飞行速度和进度条增长速率关联
-
- 树叶飞到进度条,进度条才增长
-
- 风扇转动速度和进度条增长速率关联
-
- 树叶的振幅、周期、旋转速率、起始时间浮动
-
- 完成后文字动画
-
- 增加测量方法
-
- 暴露控制接口
看了上面应该思路就很清楚了,基本都是之前学习到的东西,剩下的就是动手做了。
一、绘制背景
首先,我们需要绘制这整个背景。这个背景,通常我们会用一个半圆加一个矩形组合而成。由于颜色相同,我们可以直接绘制一个圆和一个矩形,圆的一部分会被矩形覆盖
//绘制圆
canvas.drawCircle(mBgCircleRadio, mBgCircleRadio, mBgCircleRadio, mBgPaint);
//绘制矩形
canvas.drawRect(mBgCircleRadio, 0, mBgWidth, mBgHeight, mBgPaint);
当然,对于这样的图形,我们也可以一笔绘制出来。这里需要利用 Path 类来绘制出整个图形的轮廓,再利用画布绘制。
//描出轮廓
mPath = new Path();
RectF rectF = new RectF(mOutBoundWidth, mOutBoundWidth, 2 * mBgCircleProgressRadio + mOutBoundWidth, mBgHeight - mOutBoundWidth);
mPath.addArc(rectF, 90, 180);
mPath.lineTo(mBgWidth, mOutBoundWidth);
mPath.lineTo(mBgWidth, mBgProgressHeight + mOutBoundWidth);
mPath.lineTo(mBgCircleRadio, mBgProgressHeight + mOutBoundWidth);
//绘制不规则图形
canvas.drawPath(mPath,mBgPaint);
按照上面的方法,可以绘制出这样的图形:
之后,我们用相同的办法,也可以绘制出进度条的部分,如下图:
由于进度条是不断变化的,这个部分的绘制会不断重复,简单的实现这个过程,可以通过每次通过传入的 progress 计算长度,然后绘制相应长度的进度条。但是这里,我们不这样操作。为了达到学习的目的,这里我们使用画布中已 clip 开头的方法:clipPath\clipRect 。
这里简单说明下这个方法:
clip 意为修剪,这里以 clip 开头的方法都是对画布进行裁剪操作。裁剪后,绘制到裁剪区域以外的部分都不会显示。
因此这里我们把这个绘制过程利用 Picture 录制起来,每次绘制的时候利用 clip 方法裁剪这个录像就可以了。将 Picture 转化为 PictureDrawable 后,利用 setBounds 方法会间接调用到 clip 方法,因此,我们只需要计算要绘制的长度,就可以控制进度显示了。
private void initPicture() {
if (mProgressPicture == null) {
mProgressPicture = new Picture();
Canvas canvas = mProgressPicture.beginRecording(mBgWidth, mBgHeight);
canvas.save();
canvas.drawPath(mPath,mProgressPaint);
canvas.restore();
mProgressPicture.endRecording();
}
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
PictureDrawable proPd = new PictureDrawable(mProgressPicture);
proPd.setBounds(0, 0, width, mBgHeight);
proPd.draw(canvas);
}
二、绘制叶子
叶子的绘制部分,其实是整个 LoadingView 的核心,这个叶子绘制的流畅与否关乎整个视图的美观度。
首先创建一个类用于存放叶子的属性:
static class Leaf {
//y = A Sin(w * x + Q) + k
// 叶子振幅 计算公式为:AmplitudeType(类型) * ApmlitudeDiff(振幅差值) + 默认值
private static final int A_L = -1;
private static final int A_M = 0;
private static final int A_H = 1;
// 旋转方向
private static final int Rotate_D_ZH = -1;
private static final int Rotate_D_F = 1;
//振幅类型
private int amplitudeType;
//周期
private int cycleTime;
//位置
private int x, y;
//初始相位
private float Q;
//开始旋转的时间
private long startT;
//旋转初始角度
private int rotateInit;
//旋转角度
private int rotateAngle;
//旋转方向
private int rotateDirection;
}
之后我们需要一个叶子工厂,用于创建叶子:
static class LeafFactory {
public static final int DEFAULT_SIZE = 7;
public static Leaf generateLeaf() {
Random random = new Random();
Leaf leaf = new Leaf();
//随机值使叶子在产生时有先后顺序
long addTime = random.nextInt(CYCLE_TIME);
leaf.startT = System.currentTimeMillis() + addTime;
//初始旋转角度
leaf.rotateInit = random.nextInt(360);
//随机初始旋转方向
leaf.rotateDirection = (int) Math.pow(-1, random.nextInt(1));
//随机振幅
leaf.amplitudeType = random.nextInt(2) - 1;
//随机周期
leaf.cycleTime = random.nextInt(1500) + mCycleTime;
//随机相位
leaf.Q = (float) (2 * Math.PI * random.nextInt(3) / 6);
return leaf;
}
public static List generateLeaves(int size) {
List leaves = new ArrayList<>();
for (int i = 0; i < size; i++) {
Leaf leaf = generateLeaf();
leaves.add(leaf);
}
return leaves;
}
public static List generateLeaves() {
return generateLeaves(DEFAULT_SIZE);
}
}
好了,叶子信息已经准备好了。接下来,需要我们在初始化方法中,初始化这些值:
public void init(Context context){
//初始化画笔
initPaint();
//获取叶子
initLeafInfo();
//初始化图片
initBitmap(context);
//初始化尺寸
initDimens();
//初始化路径
initPath();
//初始化录像
initPicture();
}
private void initLeafInfo() {
if (mLeafInfo == null) {
mLeafInfo = new ArrayList<>();
mLeafInfo = LeafFactory.generateLeaves(6);
}
}
private void initBitmap(Context context) {
mLeafBitmap = BitmapFactory.decodeResource(context.getResources(), R.drawable.leaf);
mLeafWidth = mLeafBitmap.getWidth();
mLeafHeight = mLeafBitmap.getHeight();
}
现在数据都已经准备好了,我们只需要在 onDraw 方法中进行相应的绘制工作就可以了。
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
drawLoadingBg(canvas);
drawLeafFly(canvas);
drawLoadingProgress(canvas);
}
private void drawLeafFly(Canvas canvas) {
long currentT = System.currentTimeMillis();
canvas.save();
canvas.clipPath(mPath);
canvas.translate(mBgWidth, mBgCircleRadio);
for (int i = 0; i < mLeafInfo.size(); i++) {
Leaf leaf = mLeafInfo.get(i);
if (currentT > leaf.startT && leaf.startT != 0) {
canvas.save();
//计算相对原点的 x y ,将数据存入 leaf 中
generateLeafLocation(leaf);
//计算相对原点的旋转角度,将数据存入 leaf 中
generateLeafRotation(leaf);
//利用 matrix 进行绘制
Matrix matrix = new Matrix();
matrix.postTranslate(leaf.x, leaf.y);
matrix.postRotate(leaf.rotateAngle, leaf.x + mLeafWidth / 2, leaf.y + mLeafHeight / 2);
canvas.drawBitmap(mLeafBitmap, matrix, mBitmapPaint);
canvas.restore();
} else {
continue;
}
}
canvas.restore();
}
private void generateLeafRotation(Leaf leaf) {
long intervalTime = System.currentTimeMillis() - leaf.startT;
if (intervalTime < 0) {
return;
} else if (intervalTime > leaf.cycleTime) {
leaf.startT = System.currentTimeMillis()
+ new Random().nextInt(leaf.cycleTime);
}
float fraction = intervalTime % mRotateTime / (float) mRotateTime;
float angle = fraction * 360;
leaf.rotateAngle = (int) (leaf.rotateInit + leaf.rotateDirection * angle);
}
private void generateLeafLocation(Leaf leaf) {
long intervalTime = System.currentTimeMillis() - leaf.startT;
if (intervalTime < 0) {
return;
} else if (intervalTime > leaf.cycleTime) {
leaf.startT = System.currentTimeMillis()
+ new Random().nextInt(leaf.cycleTime);
}
float fraction = (float) intervalTime % leaf.cycleTime / leaf.cycleTime;
leaf.x = (int) (-mBgProgressWidth * fraction);
leaf.y = calLocationY(leaf);
}
private int calLocationY(Leaf leaf) {
// y = A Sin(wx+Q) + k
int A = leaf.amplitudeType * mApmlitudeDiff + mAmplitudeMid;
double w = ((Math.PI * 2) / (mBgProgressWidth));
return (int) (A * Math.sin(w * leaf.x + leaf.Q));
}
绘制完叶子之后,记得要把进度条绘制和 progress 关联起来,否则会看不到叶子。另外在 onDraw 绘制结束后需要调用 postInvalidate 让它自动重新绘制,以保证动画的持续变化。
这样修改后,我们可以得到这样的效果:
三、风扇绘制
风扇这个部分其实是比较困难的,如果可以,其实在绘制完上面的部分之后,利用自定义 ViewGroup 组合一下 LeafLoadingView 和一个 风扇 ImageView 就可以完成得很好。但是如果一定要绘制到一个控件中去,也不是没有办法。
风扇可以分为这样几个部分:
- 1.两个圆
- 2.风扇
- 3.完成时的文字
按照这样的顺序,我们可以马上进行绘制:
//绘制风扇
private void drawFan(Canvas canvas) {
canvas.save();
canvas.translate(mBgWidth, mBgCircleRadio);
//白圆
mFanBgPaint.setColor(Color.WHITE);
canvas.drawCircle(0, 0, mBgCircleRadio, mFanBgPaint);
//橙圆
mFanBgPaint.setColor(Color.parseColor(COLOR_PROGRESS));
canvas.drawCircle(0, 0, mBgCircleRadio - mFanOutBoundWidth, mFanBgPaint);
if (mProgress >= 100) {//完成文字
//获取文字尺寸
Rect rect = new Rect();
mTextPaint.getTextBounds(mTextComplete,-0,mTextComplete.length(),rect);
canvas.drawText(mTextComplete, -(rect.right-rect.left)/2, (rect.bottom - rect.top)/2,mTextPaint);
}else{//风扇
//计算旋转角度
mFanRotate = (int) (System.currentTimeMillis() % mFanCycleTime / (float)mFanCycleTime * 360);
canvas.rotate(mFanRotate, 0, 0);
//圆和风扇间留空位置 == 2
int dx = mBgCircleProgressRadio - 2;
canvas.translate(-dx, 0);
//缩放画布使得风扇中心可以绘制在圆心上
canvas.scale((float) (dx * 2) / (float) mFanWidth, (float) (dx * 2) / (float) mFanHeight);
canvas.drawBitmap(mFanBitmap, 0, -mFanHeight / 2, mBitmapPaint);
}
canvas.restore();
}
现在获得的效果大致是这样的:
四、一些优化
1) 进度条显示
现在进度条的显示还是比较跳跃的直接显示,可以给它增加过渡效果。为此,我们需要添加几个属性:
//设置新进度时的旧进度
private float mOldProgress;
//设置新进度时的时间
private long mProgressSetTime;
//进度过渡动画的绘制完成时间
private float mIntervalDrawTime = 200;
我们在 setProgress 方法中获取上面属性,并且在绘制方法中利用新属性计算应该绘制的长度:
//绘制橙色滚动条
private void drawLoadingProgress(Canvas canvas) {
int width = generateProgressWidth();
PictureDrawable proPd = new PictureDrawable(mProgressPicture);
proPd.setBounds(0, 0, width, mBgHeight);
proPd.draw(canvas);
}
//根据属性计算应绘制的进度条长度
private int generateProgressWidth() {
int result = 0;
long deltaT = System.currentTimeMillis() - mProgressSetTime;
if (deltaT > 0 && mIntervalDrawTime > deltaT) {
float deltaWidth = (mProgress-mOldProgress) / mIntervalDrawTime * deltaT;
result = (int) ((mOldProgress + deltaWidth) / 100f * mBgWidth);
} else {
result = (int) (mProgress / 100f * mBgWidth);
}
return result;
}
public void setProgress(float progress){
this.mOldProgress = this.mProgress;
this.mProgress = progress;
this.mProgressSetTime = System.currentTimeMillis();
postInvalidate();
}
得到效果图和原来的还是有很明显的差别的:
不过,由于加上动画, 进度条实际到达顶端的时间点有变化,因此在绘制完成文字的临界点也需要改变
//绘制风扇
private void drawFan(Canvas canvas) {
canvas.save();
//...
//if (mProgress() >= 100) {//完成文字
if (generateProgressWidth() >= mBgWidth) {//完成文字
//...
} else {//风扇
//...
}
canvas.restore();
}
2) Leaf 的旋转速度随机
这里主要给 Leaf 类多添加了一个 rotateCycle 属性,在 LeafFactory 中给这个属性赋值为随机值,然后在 generateLeafRotation 方法中,利用 leaf 自身携带的属性进行计算。
private static class Leaf {
//...
//旋转周期
private int rotateCycle;
}
private static class LeafFactory {
private static final int DEFAULT_SIZE = 7;
private static Leaf generateLeaf() {
Random random = new Random();
Leaf leaf = new Leaf();
// ...
//随机旋转周期
leaf.rotateCycle = random.nextInt(1000) + ROTATE_TIME;
return leaf;
}
// ...
}
//计算树叶旋转角度
private void generateLeafRotation(Leaf leaf) {
long intervalTime = System.currentTimeMillis() - leaf.startT;
if (intervalTime < 0) {
return;
} else if (intervalTime > leaf.cycleTime) {
leaf.startT = System.currentTimeMillis()
+ new Random().nextInt(leaf.cycleTime);
}
// float fraction = intervalTime % mRotateTime / (float) mRotateTime;
float fraction = intervalTime % leaf.rotateCycle / (float) leaf.rotateCycle;
float angle = fraction * 360;
leaf.rotateAngle = (int) (leaf.rotateInit + leaf.rotateDirection * angle);
}
为了显示效果方便,我把飞行速度加快,增加了叶子数量,并且关闭了进度条的绘制
3) 叶子的产生数量和进度条的增长速度关联
现在叶子信息是由最开始初始化的,也就是说,虽然我们看到的许多叶子,但是事实上,它们都是最开始我们初始化好的叶子。
如果最开始只初始化了一个叶子,那么进度条上永远只会出现一片,同理,如果初始化了十片,那么在叶子的一个运动周期上,总是会有十片叶子同时出现的情况。
想要让数量和进度条增长的速率相关联,就需要终止重复绘制这样的机制,那么在绘制结束的时候,就需要把这片叶子移除。在进度变化的时候,根据进度变化速率来产生相应的叶子加入绘制。
首先,先修改 setProgress 方法,在设置进度的时候添加树叶:
public void setProgress(float progress) {
//如果进度条过渡还未绘制完成,则跳过这个变化
if (System.currentTimeMillis() - mProgressSetTime > mIntervalDrawTime || progress >= 100) {
//保存旧进度
this.mOldProgress = this.mProgress;
//保存新进度
this.mProgress = progress;
//保存设置时间
this.mProgressSetTime = System.currentTimeMillis();
//添加叶子
addLeaf();
Log.i(TAG, "setProgress: delta=" + (mProgress - mOldProgress));
postInvalidate();
}
}
private void addLeaf() {
float deltaProgress = mProgress - mOldProgress;
if (deltaProgress > 0 && mLeafMax > mLeafInfo.size()) {
int addNum = 1;
if (8 > deltaProgress && deltaProgress > 5) {
addNum = 2;
} else if (deltaProgress > 8) {
addNum = 3;
}
if (addNum > (mLeafMax - mLeafInfo.size())) {// 不能超过最大数量
addNum = mLeafMax - mLeafInfo.size();
}
mLeafInfo.addAll(LeafFactory.generateLeaves(addNum));
}
}
之后,我们需要把已经完成一个周期的叶子移除
private void drawLeafFly(Canvas canvas) {
// ...
canvas.save();
canvas.clipPath(mPath);
canvas.translate(mBgWidth, mBgCircleRadio);
for (int i = 0; i < mLeafInfo.size(); ) {// 由于移除叶子下标变化,不再自加,否则会闪烁
// ...drawLeaf
//移除已经飞到末端的树叶
if (isFlyAway(leaf)) {
mLeafInfo.remove(i);
if (mLeafInfo.size() == 0) {//保持至少有一片树叶
Log.d(TAG, "drawLeafFly: addLeaf");
mLeafInfo.add(LeafFactory.generateLeaf());
}
} else {
i++;
}
}
canvas.restore();
}
4) 完成时,风扇缩小,出现 100% 文字的动画
这里可以看到,就是一个在一定时间内完成缩小放大绘制的操作。
由于缩小放大操作需要用到进度刚刚到达 100 时的时间,因此,这里需要加入一个 flag 标志当前进度条状态,并用于更新进度到达 100% 的时间。
下面附上代码:
//绘制风扇
private void drawFan(Canvas canvas) {
//...
if (generateProgressWidth() >= mBgWidth) {//完成文字
if (!bFinishFlag) {//记录完成时间
bFinishFlag = true;
mFinishTime = System.currentTimeMillis();
}
//完成后、切换风扇文字动画
drawCompleteFan(canvas);
drawCompleteText(canvas);
} else {//风扇
//...
}
//...
}
private void drawCompleteFan(Canvas canvas) {
long deltaT = System.currentTimeMillis() - mFinishTime;
if (deltaT > mFanIntervalSpeedTime || !bFinishFlag) {
return;
}
canvas.save();
canvas.rotate(mFanRotate, 0, 0);
float dx = (int) ((mBgCircleProgressRadio - 2) * (1 - 1 / mFanIntervalSpeedTime * deltaT));
canvas.translate(-dx, 0);
//缩放画布使得风扇中心可以绘制在圆心上
canvas.scale((dx * 2) / (float) mFanWidth, (dx * 2) / (float) mFanHeight);
canvas.drawBitmap(mFanBitmap, 0, -mFanHeight / 2, mBitmapPaint);
canvas.restore();
}
private void drawCompleteText(Canvas canvas) {
long deltaT = System.currentTimeMillis() - mFinishTime;
if (!bFinishFlag) {
return;
}
if (deltaT < mFanIntervalAnimTime) {
int textSize = (int) ((DEFAULT_TEXT_SIZE) / mFanIntervalAnimTime * deltaT);
mTextPaint.setTextSize(textSize);
}
//获取文字尺寸
Rect rect = new Rect();
mTextPaint.getTextBounds(mTextComplete, -0, mTextComplete.length(), rect);
canvas.drawText(mTextComplete, -(rect.right - rect.left) / 2, (rect.bottom - rect.top) / 2, mTextPaint);
}
public void setProgress(float progress) {
if (System.currentTimeMillis() - mProgressSetTime > mIntervalDrawTime
&& System.currentTimeMillis() - mProgressSetTime > mFanIntervalAnimTime) {
//...
//完成标志
if (100 > progress) {
bFinishFlag = false;
}
}
}
五、遗留问题
- 效果图中,风扇的旋转也是和增长速度相关联的,但是尝试过角速度计算之后,依然不能达到很好的效果。主要还是数学忘得差不多了,有空可以再考虑。
- 计算的部分大概容易让人困惑,实际上有一部分可以利用 ValueAnimator 代替计算
- 属性之间的比例并没有仔细设置,比如修改了 mBgWidth 后,按理说完成时 100% 的字体大小应该进行相应改变,这里也没有发生改变。总之,这种尺寸计算,从一开始也没打算做。哈哈哈哈哈
- 控件的测量,这个还是比较简单的,和之前做的几个控件一样测量就好
- 暴露接口
最终效果:
效果中素材和部分代码来自:FROM GA_studio ,瞎改了部分计算方法,让绘制没有那么复杂,多添加了一些设置。
感谢:GcsSloop 自定义 View 系列
以上。