
先贴出学习的博客地址:一个绚丽的loading动效分析与实现 。
这是一篇15年的老博客了,一直收藏在我的浏览器标签里面。但是一直没去看。貌似收藏了一大堆这样的博客。
最近准备去面试工作,又回去看了一下自定义View。看的是GcsSloop的自定义控件系列教程:Android自定义View教程目录,这个教程写得非常好,主要是通俗易懂。也是在这个系列教程中,作者推荐学习这个自定义View,点开一看,很熟悉啊,一翻书签才发现收藏了一年了-_-
ps:原作者的叶子和风扇是抠图的,这里改成了使用Path绘制。照搬了叶子的飞行曲线函数。
基础
如果基础不太熟悉,我的建议是看一看GcsSloop的自定义控件系列教程。良心推荐!
步骤
- 准备叶子和风扇的Path。
- 绘制背景的圆角矩形。
- 绘制风扇。
- 绘制进度条。
- 绘制叶子。
进度条
首先确定进度条的宽度:

如图所示,进度条的宽度为RO
。
已知view的宽度为mWidth
,高度为mHeight
,
MR = mProgressPadding
QP = mHeight/2
,
QO = mHeight/2 - mProgressPadding
所以:
OP² = QP² - QO²
RO = mWidth - MR - PS - OP
代码表示:
/*计算op*/
double leftMargin = Math.sqrt(Math.pow(mHeight / 2, 2) - Math.pow(mHeight / 2 - mProgressPadding, 2));
mProgressBarWidth = (int) (mWidth - mProgressPadding - mHeight / 2 - leftMargin);
难点主要是在绘制进度条左边的半圆部分和飞行中的叶子。
左边的半圆绘制方法为:
drawArc(RectF oval, float startAngle, float sweepAngle, boolean useCenter,Paint paint)
弧线的外围矩形是很好确定的,主要是找到弧度的开始角度。
我们先看图:

首先移动画布到交叉点,要绘制蓝色区域的圆弧,startAngle
的大小为∠B
,即为180度 - 角A
,sweepAngle
的大小为2倍∠A
。所以确定∠A
大小即可。
cosA = c/b; c=b-d;
∠A=acos(c/b) (反cos)
此时求出的∠A
为弧度制的,使用Math.toDegrees
转为角度。
使用代码表达:
/*计算弧度夹角*/
float degrees = (float) Math.toDegrees(Math.acos((
mSemicircleRadius - currentProgressWidth) * 1f / mSemicircleRadius));
canvas.drawArc(mSemiCircleRectF, 180 - degrees, 2 * degrees,
false, mProgressPaint);
当进度宽度>= d+c
的时候,我们需要再绘制上右边的矩形部分:

else if (currentProgressWidth >= mSemicircleRadius) {
/*进度条大于半圆的时候,需要绘制半圆加矩形*/
canvas.drawArc(mSemiCircleRectF, 90, 180, false, mProgressPaint);
/*更新矩形的宽度*/
mProgressRectF.right = currentProgressWidth - mSemicircleRadius;
canvas.drawRect(mProgressRectF, mProgressPaint);
}
风扇
准备扇叶:
扇叶分为四个,但是只准备一个Path,旋转画布重复绘制Path就可以达到效果。
使用了Path.cubicTo
贝塞尔曲线三阶级。为了美观,参数基本上是慢慢调整的,所以不用太在意参数。
/**
* 初始化风扇叶的路径 只包含一个风扇叶的路径
*/
private void initFanLeafPath() {
/*风扇叶距离中心的高度*/
int fanLeafTop = mHeight / 2 - mFanCircleWidth - fanLeafOutMargin / 2;
int fanLeafRectWidth = mHeight / 2 - mFanCircleWidth;
mFanLeafPath = new Path();
mFanLeafPath.moveTo(0, -fanLeafInMargin);
mFanLeafPath.cubicTo(fanLeafRectWidth / 4f, -fanLeafRectWidth / 3f,
fanLeafRectWidth / 2f, -fanLeafRectWidth + fanLeafOutMargin / 2,
0, -fanLeafTop);
mFanLeafPath.cubicTo(-fanLeafRectWidth / 2f, -fanLeafRectWidth + fanLeafOutMargin / 2,
-fanLeafRectWidth / 4f, -fanLeafRectWidth / 3f,
0, -fanLeafInMargin);
mFanLeafPath.close();
}
移动画布到风扇中心:
/*移动到风扇中心*/
canvas.translate(mWidth - mHeight / 2, mHeight / 2);
绘制风扇叶缘的两个圆:
/*绘制外圆*/
canvas.drawCircle(0, 0, mHeight / 2, mFanPaint);
/*绘制内圆*/
canvas.drawCircle(0, 0, mHeight / 2 - mFanCircleWidth, mFanFillPaint);
绘制风扇叶子:
for (int i = 0; i < 4; i++) {
canvas.drawPath(mFanLeafPath, mFanPaint);
canvas.rotate(90);
}
但是风扇是旋转的,所以还要更新一下风扇旋转角度:
canvas.rotate(-fanRotateAngel);
for (int i = 0; i < 4; i++) {
canvas.drawPath(mFanLeafPath, mFanPaint);
canvas.rotate(90);
}
fanRotateAngel += (margin * mFanRotateDirection);
if (fanRotateAngel == 360) {
fanRotateAngel = 0;
}
风扇是一直在转的,所以在绘制结束之后,再调用一下invalidate()
重绘,这样就到达一直旋转的效果了。
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
drawBackground(canvas);
drawLeaf(canvas);
drawProgress(canvas);
if (mIsFinish) {
drawFan(canvas, true);
} else {
drawFan(canvas, false);
invalidate();
}
}
叶子
准备叶子:
叶子的Path和风扇的差不多,也是慢慢调整参数的,不使用图片的情况下,暂时还没有找到比较好的方法。老是感觉这个叶子像小蝌蚪 (゜-゜)つロ
/**
* 初始化叶子的路径
*/
private void initLeafPath() {
mLeafPath = new Path();
mLeafPath.moveTo(-1 / 20f * mLeafWidth, 4 / 10f * mLeafWidth);
mLeafPath.lineTo(1 / 40f * mLeafWidth, 4 / 10f * mLeafWidth);
mLeafPath.lineTo(1 / 20f * mLeafWidth, 2 / 10f * mLeafWidth);
mLeafPath.cubicTo(
1 / 3f * mLeafWidth, 0,
1 / 4f * mLeafWidth, -2 / 5f * mLeafWidth,
0, -1 / 2f * mLeafWidth);
mLeafPath.cubicTo(
-1 / 4f * mLeafWidth, -2 / 5f * mLeafWidth,
-1 / 3f * mLeafWidth, 0,
-1 / 20f * mLeafWidth, 2 / 10f * mLeafWidth);
mLeafPath.close();
}
由于叶子是一直在飞行的(不管进度是否更新),所以参照原博主的方法,使用当前时间来控制叶子的坐标。
基本思想:
-
一开始就初始化几片叶子,并且给每片叶子设置一个出场时间,出场时间到了才从风扇中心飞出去。
-
飞行轨迹是一个曲线函数,这个曲线函数可以让叶子的轨迹显得更自然。
-
飞到终点之后,再把叶子的坐标设置为起点,重置出场时间。等待下一波出场。
-
叶子在飞行的过程中要旋转,所以还要给叶子设置一个旋转角度。
所以叶子有坐标,有出场时间,有旋转角度:
static class Leaf {
int x;
int y;
int angle;
long startTime;
int direction;/*旋转方向 0 逆时针 1顺时针*/
StartType type;
}
初始化的时候就new一堆叶子出来,一定范围内,随机设置他们的参数。
private void initLeafArray() {
if (mLeafPathArray == null) {
mLeafPathArray = new ArrayList<>();
} else {
mLeafPathArray.clear();
}
for (int i = 0; i < mLeafCount; i++) {
Leaf leaf = new Leaf();
leaf.angle = mRandom.nextInt(360);
leaf.direction = mRandom.nextInt(2);
int randomType = mRandom.nextInt(3);
/*随时类型- 随机振幅*/
StartType type = StartType.MIDDLE;
switch (randomType) {
case 0:
break;
case 1:
type = StartType.LITTLE;
break;
case 2:
type = StartType.BIG;
break;
default:
break;
}
leaf.type = type;
leaf.startTime = System.currentTimeMillis() + mRandom.nextInt(mLeafOnceCycleTime);
mLeafPathArray.add(leaf);
}
}
绘制叶子:
private void drawLeaf(Canvas canvas) {
canvas.save();
/*移动画布中心到图中的P点,P点为风扇中心即叶子的出发点*/
canvas.translate(mWidth - mSemicircleRadius, mHeight / 2);
for (Leaf leaf : mLeafPathArray) {
canvas.save();
/*更新叶子的坐标*/
setLeafLocation(leaf);
canvas.translate(leaf.x, leaf.y);
/*旋转叶子的角度*/
canvas.rotate(leaf.angle);
canvas.drawPath(mLeafPath, mProgressPaint);
canvas.restore();
}
canvas.restore();
}
更新叶子坐标的方法:
private void setLeafLocation(Leaf leaf) {
/*根据叶子的旋转方向,修改旋转度数*/
leaf.angle += ((leaf.direction == 0) ? 5 : -5);
long currentTimeMillis = System.currentTimeMillis();
/*计算当前时间和叶子出场时间的差值*/
long timeDiff = currentTimeMillis - leaf.startTime;
/*1. 未到出场时间*/
if (timeDiff < 0) {
return;
}
/*2. 到达终点*/
if (timeDiff > mLeafOnceCycleTime) {
leaf.x = 0;
leaf.y = 0;
/*重置坐标到原点,并且把开始时间加上一个周期,再加一个随机值避免每个周期出场时间都一样*/
leaf.startTime += mLeafOnceCycleTime + mRandom.nextInt(1000);
return;
}
/*3. 在飞行途中*/
/*按照时间比例,计算x*/
leaf.x = -(int) ((mWidth - mProgressPadding - mLeafWidth / 2 - mSemicircleRadius)
* timeDiff * 1f / mLeafOnceCycleTime);
leaf.y = getLocationY(leaf) - mHeight / 4;
}
计算Y值的函数,为了使叶子飞行更自然,给叶子设置了不同的振幅。
/*通过叶子信息获取当前叶子的Y值*/
private int getLocationY(Leaf leaf) {
float w = (float) ((float) 2 * Math.PI / mProgressBarWidth);
float a = mMiddleAmplitude;
switch (leaf.type) {
case LITTLE:
/*小振幅 = 中等振幅 - 振幅差*/
a = mMiddleAmplitude - mAmplitudeDisparity;
break;
case MIDDLE:
a = mMiddleAmplitude;
break;
case BIG:
/*小振幅 = 中等振幅 + 振幅差*/
a = mMiddleAmplitude + mAmplitudeDisparity;
break;
default:
break;
}
return (int) (a * Math.sin(w * leaf.x)) + mSemicircleRadius * 3 / 4;
}
结束动画
效果表现为 风扇叶缩小的同时,100%
文字放大。
我这里的方法是,扇叶缩小到0.5的时候,开始显示并放大100%
文字,扇叶缩小到0.3以下的时候,不再绘制扇叶。其实只要自己看着舒服就行。
扇叶的缩小,只要在绘制扇叶前缩小画布即可,其他代码一样。
/*在执行缩放动画*/
if (scale) {
canvas.save(); /*@1*/
/*缩放画布*/
canvas.scale(mFanLeafScaleValue, mFanLeafScaleValue);
}
文字的放大是修改了Paint的字体大小,风扇缩小的同时放大字体。
/**
* 字体居中绘制处理
*/
private void initTextBaseLine() {
Paint.FontMetrics fontMetrics = mTextPaint.getFontMetrics();
float top = fontMetrics.top;/*为基线到字体上边框的距离*/
float bottom = fontMetrics.bottom;/*为基线到字体下边框的距离*/
/*基线中间点的y轴计算公式*/
mTextBaseLineY = (int) (mSemiCircleRectF.centerY() - top / 2 - bottom / 2);
}
private void draw100Text(Canvas canvas) {
/*设置字体的大小为: (1-风扇的缩放比例)*/
mTextPaint.setTextSize(mTextMaxSize * (1 - mFanLeafScaleValue));
initTextBaseLine();
canvas.drawText("100%", mSemiCircleRectF.centerX(), mTextBaseLineY, mTextPaint);
}
尾声
😀代码基本上都写上了中文注释,有任何问题和建议欢迎评论!
😭项目地址:Github android_leaf_loading
最后贴一下我的博客小站:xxyangyoulin.cn
网友评论