本文主要讲述LoadingDrawable之CircleJump系列的两个动画的实现原理。建议在看此博文之前先阅读LoadingDrawable前言, LoadingDrawable涉及的一些基类此篇博文将不再赘述。
Note: 这个项目正处于更新阶段, 将会不断有新的加载动画加入,欢迎关注我的Github, 获取LoadingDrawable的最新动态。
概述
LoadingDrawable
所实现的动画深受大家的青睐,但是它是如何实现的呢? 这篇博文将带领大家领悟我的内心世界。这篇博文主要讲述CollisionLoadingRenderer
和SwapLoadingRenderer
这两个渲染器的实现原理。首先预览一下这两个LoadingRenderer
的效果图(左上方为CollisionLoadingRenderer
,右上方为SwapLoadingRenderer
),看到效果图不要太快的往下看,可以先思考一下实现方式。先思考再借鉴最后实践对于一个程序员的迅速提升是必不可少的。
![](https://raw.githubusercontent.com/dinuscxj/LoadingDrawable/master/Preview/CircleJumpDrawable.gif)
CollisionLoadingRenderer的思路
-
CollisionLoadingRenderer
的原理
(1)首先需要调用Paint
的setShader(Shader shader)
方法, 通过LinearGradient
设置渐变区域。 Note:渐变的距离是从第二个球到倒数第二个球之间的距离。
(2)绘制第二个至倒数第二个之间的圆球和圆球下面的椭圆。
(3)左右两边的球运动的曲线是y=ax^2
. a > 0 所以第一个球的运动轨迹就是抛物线y=ax^2
位于y轴左边的曲线, 最后一个球的运动轨迹就是抛物线y=ax^2
位于y轴右边的曲线。 附二次函数
(4)根据运动曲线绘制运动的球。 -
CollisionLoadingRenderer
的实现细节
LoadingRenderer
的动画实现主要通过draw(Canvas canvas)
(负责动画的绘制)和computeRender(float)
(负责计算绘制需要的参数)。 此动画的主要分为三步
设置渐变区域 --> 绘制渐变区域的图像和两边的球 --> 动起来
。
(1)设置渐变区域
private void adjustParams() {
mBallCenterY = mHeight / 2.0f;
//mWidth是drawable的宽度
//mBallRadius 是球的半径, 乘2表示直径
//mBallCount - 2是因为渐变区域是从第二个到倒数第二个, 减去两边的两个
mBallSideOffsets = (mWidth - mBallRadius * 2.0f * (mBallCount - 2)) / 2;
}
private void setupPaint() {
mPaint.setStyle(Paint.Style.FILL);
mPaint.setShader(new LinearGradient(mBallSideOffsets, 0, mWidth - mBallSideOffsets, 0,
mColors, mPositions, Shader.TileMode.CLAMP));
}
其中mBallSideOffsets
是线性渐变在x方向上的开始位置, mWidth - mBallSideOffsets
是线性渐变在x方向上的结束位置,在y方向上开始和结束值要一样, 因为我们是水平方向渐变。
(2)绘制渐变区域的图像和两边的球
@Override
protected void draw(Canvas canvas) {
//保存图层
int saveCount = canvas.save();
//绘制第二个到倒数第二个之间的球和球下面的椭圆
for (int i = 1; i < mBallCount - 1; i++) {
//绘制球
mPaint.setAlpha(MAX_ALPHA);
canvas.drawCircle(mBallRadius * (i * 2 - 1) + mBallSideOffsets,
mBallCenterY, mBallRadius, mPaint);
//绘制椭圆
mOvalRect.set(mBallRadius * (i * 2 - 2) + mBallSideOffsets,
mHeight - mOvalVerticalRadius * 2,
mBallRadius * (i * 2) + mBallSideOffsets,
mHeight);
mPaint.setAlpha(OVAL_ALPHA);
canvas.drawOval(mOvalRect, mPaint);
}
//绘制第一个球
mPaint.setAlpha(MAX_ALPHA);
canvas.drawCircle(mBallSideOffsets - mBallRadius - mLeftBallMoveXOffsets,
mBallCenterY - mLeftBallMoveYOffsets, mBallRadius, mPaint);
//绘制第一个椭圆
mOvalRect.set(mBallSideOffsets - mBallRadius - mBallRadius * mLeftOvalShapeRate - mLeftBallMoveXOffsets,
mHeight - mOvalVerticalRadius - mOvalVerticalRadius * mLeftOvalShapeRate,
mBallSideOffsets - mBallRadius + mBallRadius * mLeftOvalShapeRate - mLeftBallMoveXOffsets,
mHeight - mOvalVerticalRadius + mOvalVerticalRadius * mLeftOvalShapeRate);
mPaint.setAlpha(OVAL_ALPHA);
canvas.drawOval(mOvalRect, mPaint);
//绘制最后一个球
mPaint.setAlpha(MAX_ALPHA);
canvas.drawCircle(mBallRadius * (mBallCount * 2 - 3) + mBallSideOffsets + mRightBallMoveXOffsets,
mBallCenterY - mRightBallMoveYOffsets, mBallRadius, mPaint);
//绘制最后一个椭圆
mOvalRect.set(mBallRadius * (mBallCount * 2 - 3) - mBallRadius * mRightOvalShapeRate + mBallSideOffsets + mRightBallMoveXOffsets,
mHeight - mOvalVerticalRadius - mOvalVerticalRadius * mRightOvalShapeRate,
mBallRadius * (mBallCount * 2 - 3) + mBallRadius * mRightOvalShapeRate + mBallSideOffsets + mRightBallMoveXOffsets,
mHeight - mOvalVerticalRadius + mOvalVerticalRadius * mRightOvalShapeRate);
mPaint.setAlpha(OVAL_ALPHA);
canvas.drawOval(mOvalRect, mPaint);
//恢复图层
canvas.restoreToCount(saveCount);
}
draw(Canvas canvas)
函数的代码的难点主要是计算球心,与椭圆的归一化位置。
[1]首先给出渐变区域的球的球心和椭圆归一化位置的计算方式
第i个球的球心X坐标 = 第1个球的最左边坐标(线性渐变的开始位置mBallSideOffsets
) + 第i 个球心距第1个球的最左边的偏移量。【公式Ball】。
第i个椭圆的left = 第i个球的球心坐标 - 球的半径(mBallRadius
)【公式Oval_Left】。
第i个椭圆的right = 第i个球的球心坐标 + 球的半径(mBallRadius
)【公式Oval_Right】。
第i个椭圆的bottom = Drawable的底部(mHeight
)【公式Oval_Bottom】。
第i个椭圆的top = 第i个椭圆的bottom - 椭圆的高度(mOvalVerticalRadius * 2
)【公式Oval_Top】。
[2]然后给出第1个球的球心和椭圆归一化位置的计算方式
第1个球的球心X坐标 = 【公式Ball】i置0 - 当前第一个球的偏移量(mLeftBallMoveXOffsets
)。
第1个椭圆的left = 第1个球的球心坐标 - 球的半径(mBallRadius
) * 椭圆的缩小比例(mLeftOvalShapeRate
)。
第1个椭圆的right = 第1个球的球心坐标 + 球的半径(mBallRadius
)* 椭圆的缩小比例(mLeftOvalShapeRate
)。
第1个椭圆的bottom = Drawable的底部(mHeight
)+ 球的半径(mOvalVerticalRadius
)* 椭圆的缩小比例(mLeftOvalShapeRate
)。
第1个椭圆的top = 第1个椭圆的bottom - 椭圆垂直方向的半径(mOvalVerticalRadius
) * 椭圆的缩小比例(mLeftOvalShapeRate
)。
[3]最后给出最后1个球的球心和椭圆归一化位置的计算方式
同[2]。
(3)动起来
@Override
protected void computeRender(float renderProgress) {
// 在进度的前25%将第一个球移动到最左边
if (renderProgress <= START_LEFT_DURATION_OFFSET) {
float startLeftOffsetProgress = renderProgress / START_LEFT_DURATION_OFFSET;
computeLeftBallMoveOffsets(DECELERATE_INTERPOLATOR.getInterpolation(startLeftOffsetProgress));
return;
}
// 在进度的25%~50%将第一个球移动到原始位置
if (renderProgress <= START_RIGHT_DURATION_OFFSET) {
float startRightOffsetProgress = (renderProgress - START_LEFT_DURATION_OFFSET) / (START_RIGHT_DURATION_OFFSET - START_LEFT_DURATION_OFFSET);
computeLeftBallMoveOffsets(ACCELERATE_INTERPOLATOR.getInterpolation(1.0f - startRightOffsetProgress));
return;
}
// 在进度的50%~75%将最后一个球移动到最右边位置
if (renderProgress <= END_RIGHT_DURATION_OFFSET) {
float endRightOffsetProgress = (renderProgress - START_RIGHT_DURATION_OFFSET) / (END_RIGHT_DURATION_OFFSET - START_RIGHT_DURATION_OFFSET);
computeRightBallMoveOffsets(DECELERATE_INTERPOLATOR.getInterpolation(endRightOffsetProgress));
return;
}
// 在进度的75%~100%将最后一个球移动到原始位置
if (renderProgress <= END_LEFT_DURATION_OFFSET) {
float endRightOffsetProgress = (renderProgress - END_RIGHT_DURATION_OFFSET) / (END_LEFT_DURATION_OFFSET - END_RIGHT_DURATION_OFFSET);
computeRightBallMoveOffsets(ACCELERATE_INTERPOLATOR.getInterpolation(1 - endRightOffsetProgress));
return;
}
}
private void computeLeftBallMoveOffsets(float progress) {
mRightBallMoveXOffsets = 0.0f;
mRightBallMoveYOffsets = 0.0f;
mLeftOvalShapeRate = 1.0f - progress;
mLeftBallMoveXOffsets = mBallMoveXOffsets * progress;
//y = ax^2
mLeftBallMoveYOffsets = (float) (Math.pow(mLeftBallMoveXOffsets, 2) * mBallQuadCoefficient);
}
private void computeRightBallMoveOffsets(float progress) {
mLeftBallMoveXOffsets = 0.0f;
mLeftBallMoveYOffsets = 0.0f;
mRightOvalShapeRate = 1.0f - progress;
mRightBallMoveXOffsets = mBallMoveXOffsets * progress;
//y = ax^2
mRightBallMoveYOffsets = (float) (Math.pow(mRightBallMoveXOffsets, 2) * mBallQuadCoefficient);
}
computeRender(float renderProgress)
主要是通过对应的Interpolator计算当前球(第一个或者最后一个球)的进度, 并通过computeLeftBallMoveOffsets(float progress)
或者computeRightBallMoveOffsets(float progress)
计算当前球的x, y坐标。 computeLeftBallMoveOffsets(float progress)
中的代码主要做两件事情, 一、将右边的球复位, 二、通过当前进度,计算第一个球的x坐标值,并通过y = ax^2
计算对应的y坐标值。computeRightBallMoveOffsets(float progress)
同理。
SwapLoadingRenderer的思路
-
SwapLoadingRenderer
的原理
(1)绘制静止的圆环。
(2)球和圆环的运动曲线是x^2 + y^2 = r^2
. 球和圆环分别交替做上半圆和下半圆的弧线运动。附圆的标准方程
(3)绘制正在交换(绕圆弧运动)的球和圆环, 这球和圆环都做顺时针运动,球的交换规则是:上半圆 -->下半圆 -->循环... -->最后一个总是下半圆
,与其交换的圆环的交换规则是:下半圆 -->上半圆 -->循环... -->最后一个总是上半圆
-
SwapLoadingRenderer
的实现细节
SwapLoadingRenderer
的实现相对简单一点。此动画主要分为
绘制静止的圆环和交换中的球和圆环 --> 动起来
(1)绘制静止的圆环和交换中的球和圆环
@Override
protected void draw(Canvas canvas) {
//保存图层
int saveCount = canvas.save();
for (int i = 0; i < mBallCount; i++) {
if (i == mSwapIndex) {
//绘制交换中的球
mPaint.setStyle(Paint.Style.FILL);
canvas.drawCircle(mBallSideOffsets + mBallRadius * (i * 2 + 1) + i * mBallInterval + mSwapBallOffsetX
, mBallCenterY - mSwapBallOffsetY, mBallRadius, mPaint);
} else if (i == (mSwapIndex + 1) % mBallCount) {
/绘制交换中的圆环
mPaint.setStyle(Paint.Style.STROKE);
canvas.drawCircle(mBallSideOffsets + mBallRadius * (i * 2 + 1) + i * mBallInterval - mSwapBallOffsetX
, mBallCenterY + mSwapBallOffsetY, mBallRadius - mStrokeWidth / 2, mPaint);
} else {
//绘制静止的圆环
mPaint.setStyle(Paint.Style.STROKE);
canvas.drawCircle(mBallSideOffsets + mBallRadius * (i * 2 + 1) + i * mBallInterval,
mBallCenterY, mBallRadius - mStrokeWidth / 2, mPaint);
}
}
//恢复图层
canvas.restoreToCount(saveCount);
}
与CollisionLoadingRenderer
类似。 draw(Canvas canvas)
函数的代码的难点主要是计算球心和圆环的圆心。
[1]首先给出静止圆环的圆心的计算方式
第i个圆环的圆心X坐标 = 第1个圆环的最左边坐标(圆环偏移量mBallSideOffsets
) + 第i 个圆心距第1个圆环的最左边的偏移量(mBallRadius * (i * 2 + 1) + i * mBallInterval
其中mBallRadius
是圆环的半径,mBallInterval
是圆环之间的间距)。【公式Circle】。
[2]然后给出运动中的球的球心的计算方式
第i个运动中球的球心X坐标 = 将i带入【公式Circle】+ 当前第i个球的球心X坐标的偏移量(mSwapBallOffsetX
)。
第i个运动中球的球心Y坐标 = 球心Y坐标+ 当前第i个球的球心Y坐标的偏移量(mSwapBallOffsetY
)。
[3]然后给出运动中的圆环的圆心的计算方式
同[2]。
(2)动起来
@Override
protected void computeRender(float renderProgress) {
mSwapIndex = (int) (renderProgress / mASwapThreshold);
//交换的轨迹 : x^2 + y^2 = r ^ 2
float swapTraceProgress = ACCELERATE_DECELERATE_INTERPOLATOR.getInterpolation(
(renderProgress - mSwapIndex * mASwapThreshold) / mASwapThreshold);
float swapTraceRadius = mSwapIndex == mBallCount - 1
? (mBallRadius * 2 * (mBallCount - 1) + mBallInterval * (mBallCount - 1)) / 2
: (mBallRadius * 2 + mBallInterval) / 2;
// 计算当前交换的球的球心的X偏移量
mSwapBallOffsetX = mSwapIndex == mBallCount - 1
? -swapTraceProgress * swapTraceRadius * 2
: swapTraceProgress * swapTraceRadius * 2;
// 如果 mSwapIndex == mBallCount - 1 则 (swapTraceRadius, swapTraceRadius) 作为运动轨迹的初始圆心
// 否则 (-swapTraceRadius, -swapTraceRadius) 作为运动轨迹的初始圆心
float xCoordinate = mSwapIndex == mBallCount - 1
? mSwapBallOffsetX + swapTraceRadius
: mSwapBallOffsetX - swapTraceRadius;
// 计算当前交换的球的球心的Y偏移量
mSwapBallOffsetY = (float) (mSwapIndex % 2 == 0 && mSwapIndex != mBallCount - 1
? Math.sqrt(Math.pow(swapTraceRadius, 2.0f) - Math.pow(xCoordinate, 2.0f))
: -Math.sqrt(Math.pow(swapTraceRadius, 2.0f) - Math.pow(xCoordinate, 2.0f)));
}
computeRender(float renderProgress)
主要是通过对应的Interpolator计算当前处于交换的球的交换进度swapTraceProgress
, 并根据当前处于交换的球的位置计算其与其交换的圆环之间球心距swapTraceRadius * 2
。 其中当前交换的球的球心X偏移量mSwapBallOffsetX
=swapTraceProgress
* swapTraceRadius * 2
如果是最后一个则为相反数,因为是从左往右移动了。其中xCoordinate
是当前运动轨迹的相对球心位置。 当前交换的球的球心Y偏移量是通过球的方程 mSwapBallOffsetY ^2 + xCoordinate^2 = swapTraceRadius ^2
将xCoordinate
带入方程即可计算得出mSwapBallOffsetY
。
用法
关于我
我喜欢Android, 喜欢开源, 喜欢做动画, 会不定期开源一些有用的项目。
源码地址:传送门
网友评论