最近换了个新手机VIVO,看了充电动画还是挺炫丽的! 网上找了下没有,于是自己写了个,效果类似
同时也写了个华为手机充电效果,他们相同的地方: 就是用贝塞尔曲线,思路来源于QQ里面的气泡拖动效果, 涉及的计算,也是特别复杂! 如果你能把这个掌握,基本自定义view和动画就非常牛逼了
后面一课vLOG:做抖音的剪映里面处理视频,各自拖动,缩放,滑动,帧处理的UI效果,也是非常复杂! (之前看同事做的一直会闪烁,并且会偶现白)
1.效果图
手机充电动画 实现了充电过程中能量流动的视觉效果,配合百分比文字可直观显示充电进度。粒子系统的运动轨迹和颜色变化增强了动画的科技感和动态感。
2.功能需求
1.中间一个大的3色,颜色相邻是渐变效果产生的空心圆圈
2.每个时间都有1-3个对于颜色的小圆漂移到大圆内,并合成一体,消失
3.小圆和大圆接触的时候,有那种2个水滴融合在一起的效果
3.思路
一共分为4步骤!
第一步: 圆环绘制:
第二步: 文本绘制:
第三步: 计算粒子的颜色值:
第四步: 粒子移动动画,碰到圆环消失:
第四步: 水滴效果,贝塞尔曲线
3.1: 圆环绘制:
- 使用
SweepGradient
创建三色渐变,设置到画笔中mRingPaint.setShader(sweepGradient);
drawArc()
绘制360度圆环
// 创建圆环渐变着色器
SweepGradient sweepGradient = new SweepGradient(
mCenterX, mCenterY,
mRingColors,
new float[]{0f, 0.33f, 0.66f}
);
// 旋转渐变使其从顶部开始
Matrix gradientMatrix = new Matrix();
gradientMatrix.preRotate(-90, mCenterX, mCenterY);
sweepGradient.setLocalMatrix(gradientMatrix);
mRingPaint.setShader(sweepGradient);
// 绘制圆环
canvas.drawArc(mRingRect, 0, 360, false, mRingPaint);
颜色分布验证:
角度 | 位置 | 颜色 | 验证 |
---|---|---|---|
0° | 顶部 | 红色 | ✅ |
90° | 右侧 | 绿色 | ✅ |
180° | 底部 | 绿蓝混合(青色) | ✅ |
270° | 左侧 | 蓝色 | ✅ |
360° | 顶部 | 红色 | ✅ |
3.2: 文本绘制:
// 绘制文本
canvas.drawText(mPercentText, mCenterX, mCenterY - mTextSpacing, mTextPaint);
canvas.drawText(mChargeText, mCenterX, mCenterY + mTextSpacing + mTextSize, mTextPaint);
3.3: 计算粒子的颜色值:通过角度值,映射对应的颜色值
/**
* 根据角度获取圆环上对应位置的颜色(修正角度转换问题)
*/
private int getRingColorAtAngle(float angle) {
// 关键修正:将原始角度转换为圆环渐变坐标系的角度(考虑-90度旋转)
float gradientAngle = (angle - 90 + 360) % 360;
// 计算归一化位置 (0.0 - 1.0)
float normalizedPos = gradientAngle / 360f;
// 圆环颜色分段
if (normalizedPos < 0.33f) {
// 红色到绿色渐变
float fraction = normalizedPos / 0.33f;
return (Integer) mColorEvaluator.evaluate(fraction, mRingColors[0], mRingColors[1]);
} else if (normalizedPos < 0.66f) {
// 绿色到蓝色渐变
float fraction = (normalizedPos - 0.33f) / 0.33f;
return (Integer) mColorEvaluator.evaluate(fraction, mRingColors[1], mRingColors[2]);
} else {
// 蓝色到红色渐变
float fraction = (normalizedPos - 0.66f) / 0.34f;
return (Integer) mColorEvaluator.evaluate(fraction, mRingColors[2], mRingColors[0]);
}
}
3.4 产生粒子
private void generateParticles() {
long currentTime = System.currentTimeMillis();
if (currentTime - mLastParticleTime > PARTICLE_INTERVAL && mParticles.size() < MAX_PARTICLES) {
mLastParticleTime = currentTime;
// 随机角度 (0-360度)
float angle = mRandom.nextFloat() * 360;
// 避开顶部区域(-60度到60度之间不生成粒子)
float normalizedAngle = (angle + 360) % 360;
if (normalizedAngle > 60 && normalizedAngle < 300) {
// 粒子起始位置在圆环外1.5-2倍半径处
float distanceFactor = 1.5f + mRandom.nextFloat() * 0.5f;
float startX = mCenterX + (float) Math.cos(Math.toRadians(angle)) * mRingRadius * distanceFactor;
float startY = mCenterY + (float) Math.sin(Math.toRadians(angle)) * mRingRadius * distanceFactor;
// 目标位置在黄色内圆边缘(确保在安全区域外)
float targetDistance = Math.max(mInnerCircleRadius, mSafeZoneRadius);
float targetX = mCenterX + (float) Math.cos(Math.toRadians(angle)) * targetDistance;
float targetY = mCenterY + (float) Math.sin(Math.toRadians(angle)) * targetDistance;
// 随机初始大小 (最大不超过圆环宽度的一半)
float startSize = mRandom.nextFloat() * (mRingWidth / 4) + 2; // 更小的初始大小
// 目标大小为圆环截面半径(即圆环宽度的一半)
float targetSize = mRingWidth / 2; // 最终等于圆环截面半径
// 随机移动速度 (0.01-0.05)
float speed = 0.01f + mRandom.nextFloat() * 0.04f;
// 计算目标位置在圆环上的颜色(使用修正后的角度转换)
int targetColor = getRingColorAtAngle(angle);
// 起始颜色为半透明版本的目标颜色
int startColor = Color.argb(100,
Color.red(targetColor),
Color.green(targetColor),
Color.blue(targetColor));
mParticles.add(new Particle(
startX, startY, startSize, startColor,
targetX, targetY, targetSize, speed, targetColor,
mCenterX, mCenterY, mInnerCircleRadius
));
}
}
}
3.5: 粒子移动动画,碰到圆环消失:
不停的更新粒子的x,y,进行重绘,就产生了动画的效果
private void drawAndUpdateParticles(Canvas canvas) {
Paint particlePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
particlePaint.setStyle(Paint.Style.FILL);
List<Particle> toRemove = new ArrayList<>();
for (Particle particle : mParticles) {
// 更新粒子位置和大小
particle.update();
// 设置粒子颜色
particlePaint.setColor(particle.getCurrentColor());
// 绘制粒子
canvas.drawCircle(particle.x, particle.y, particle.size, particlePaint);
// 检查是否到达目标或发生碰撞
if (particle.isAtTarget() || particle.isCollided()) {
toRemove.add(particle);
}
}
// 移除到达目标或碰撞的粒子
mParticles.removeAll(toRemove);
}
碰撞检测:
boolean isAtTarget() {
return progress >= 1.0f;
}
3.6: 水滴效果,贝塞尔曲线:涉及复杂的计算
/**
* 生成2个控制点
*
* @param p0
* @param p1
* @param p2
* @param lineSmoothness
* @return
*/
public static PointF[] generateMidControlPoints(PointF p0, PointF p1, PointF p2, float lineSmoothness) {
float c1x = p0.x + (p1.x - p0.x) * lineSmoothness;
float c1y = p0.y + (p1.y - p0.y) * lineSmoothness;
float c2x = p1.x - (p2.x - p0.x) * lineSmoothness;
float c2y = p1.y - (p2.y - p0.y) * lineSmoothness;
PointF[] points = new PointF[2];
points[0] = new PointF(c1x, c1y);
points[1] = new PointF(c2x, c2y);
return points;
}
/**
* 生成最后的2个控制点
*
* @param p0
* @param p1
* @param p2
* @param lineSmoothness
* @return
*/
public static PointF[] generateLastControlPoints(PointF p0, PointF p1, PointF p2, float lineSmoothness) {
float c1x = p1.x + (p2.x - p0.x) * lineSmoothness;
float c1y = p1.y + (p2.y - p0.y) * lineSmoothness;
float c2x = p2.x - (p2.x - p1.x) * lineSmoothness;
float c2y = p2.y - (p2.y - p1.y) * lineSmoothness;
PointF[] points = new PointF[2];
points[0] = new PointF(c1x, c1y);
points[1] = new PointF(c2x, c2y);
return points;
}
/**
* 生成圆弧上的3个点
*
* @param centerX 中心点x
* @param centerY 中心点y
* @param radius 半径
* @param startAngle 起始角度
* @param sweepAngle 扫描角度
* @return
*/
public static PointF[] generatePoints(float centerX, float centerY, float radius, float startAngle, float sweepAngle) {
double startRad = DegreeUtil.toRadians(startAngle);
double midRad = DegreeUtil.toRadians(startAngle + sweepAngle / 2);
double endRad = DegreeUtil.toRadians(startAngle + sweepAngle);
//起始点
double startX = centerX + DegreeUtil.getCosSideLength(radius, startRad);
double startY = centerY + DegreeUtil.getSinSideLength(radius, startRad);
//圆弧中点
double midX = centerX + DegreeUtil.getCosSideLength(radius, midRad);
double midY = centerY + DegreeUtil.getSinSideLength(radius, midRad);
//结束点
double endX = centerX + DegreeUtil.getCosSideLength(radius, endRad);
double endY = centerY + DegreeUtil.getSinSideLength(radius, endRad);
PointF[] points = new PointF[3];
points[0] = new PointF((float) startX, (float) startY);
points[1] = new PointF((float) midX, (float) midY);
points[2] = new PointF((float) endX, (float) endY);
return points;
}
/**
* 连成path
*
* @param path
* @param points
* @param lineSmoothness
*/
public static void connectToPath(Path path, PointF[] points, float lineSmoothness) {
if (points == null) throw new NullPointerException("points == null");
if (points.length < 3) throw new IllegalArgumentException("points.length < 3");
PointF[] c = null;
for (int i = 0; i < points.length; i++) {
if (i == 0) {
path.moveTo(points[i].x, points[i].y);
} else if (i == points.length - 1) {
c = ChargingHelper.generateLastControlPoints(points[i - 2], points[i - 1], points[i], lineSmoothness);
path.cubicTo(c[0].x, c[0].y, c[1].x, c[1].y, points[i].x, points[i].y);
} else {
c = ChargingHelper.generateMidControlPoints(points[i - 1], points[i], points[i + 1], lineSmoothness);
path.cubicTo(c[0].x, c[0].y, c[1].x, c[1].y, points[i].x, points[i].y);
}
}
}
/**
* 生成2个点
* @param centerX
* @param centerY
* @param angle
* @param cosSideLength
* @param sinSideLength
* @param isYAdd
* @return
*/
public static PointF[] generatePoints(float centerX, float centerY, double angle, double cosSideLength, double sinSideLength, boolean isYAdd){
double rad = DegreeUtil.getCoordinateRadians(cosSideLength, sinSideLength);
double degree = DegreeUtil.toDegrees(rad);
//斜边长度
double hypotenuse = Math.sqrt(Math.pow(cosSideLength, 2) + Math.pow(sinSideLength, 2));
double rad1 = DegreeUtil.toRadians(angle - degree);
//x1,y1坐标
double x1 = centerX + DegreeUtil.getCosSideLength(hypotenuse, rad1);
double rad2 = DegreeUtil.toRadians(angle + degree);
//x2,y2坐标
double x2 = centerX + DegreeUtil.getCosSideLength(hypotenuse, rad2);
double y1 = 0, y2 = 0;
if(isYAdd){
y1 = centerY + DegreeUtil.getSinSideLength(hypotenuse, rad1);
y2 = centerY + DegreeUtil.getSinSideLength(hypotenuse, rad2);
}else{
y1 = centerY - DegreeUtil.getSinSideLength(hypotenuse, rad1);
y2 = centerY - DegreeUtil.getSinSideLength(hypotenuse, rad2);
}
PointF []points = new PointF[2];
points[0] = new PointF((float) x1,(float) y1);
points[1] = new PointF((float) x2,(float) y2);
return points;
}
4.架构图
5.总结
比较复杂的就是计算
5.1 静态圆环的颜色计算
5.2 粒子的颜色计算
5.3 粒子移动的坐标计算
5.1. 静态圆环颜色计算
计算原理
圆环使用SweepGradient
实现,这是一个环形渐变着色器。默认情况下:
- 0° 指向三点钟方向(X轴正方向)
- 角度顺时针增加
但我们需要:
- 0° 指向顶部(12点钟方向)
- 90° 指向右侧
- 180° 指向底部
- 270° 指向左侧
实现步骤
-
创建基础渐变:
SweepGradient sweepGradient = new SweepGradient( mCenterX, mCenterY, mRingColors, new float[]{0f, 0.25f, 0.5f, 0.75f} );
- 使用4个颜色点:红(0°)、绿(90°)、蓝(180°)、红(270°)
- 位置对应:0°(0.0), 90°(0.25), 180°(0.5), 270°(0.75)
-
旋转渐变矩阵:
Matrix gradientMatrix = new Matrix(); gradientMatrix.setRotate(-90, mCenterX, mCenterY); sweepGradient.setLocalMatrix(gradientMatrix);
- 将整个渐变逆时针旋转90°
- 使0°指向顶部
颜色分布验证
角度 | 位置 | 归一化位置 | 颜色计算 |
---|---|---|---|
0° | 顶部 | 0.0 | 红色 (100%) |
45° | 右上 | 0.125 | 红绿渐变 (50%红, 50%绿) |
90° | 右侧 | 0.25 | 绿色 (100%) |
135° | 右下 | 0.375 | 绿蓝渐变 (50%绿, 50%蓝) |
180° | 底部 | 0.5 | 蓝色 (100%) |
225° | 左下 | 0.625 | 蓝红渐变 (50%蓝, 50%红) |
270° | 左侧 | 0.75 | 红色 (100%) |
315° | 左上 | 0.875 | 红蓝渐变 (50%红, 50%蓝) |
5.2. 粒子颜色计算
计算原理
粒子颜色需要匹配其在圆环上的生成位置的颜色。使用角度参数计算颜色:
-
归一化位置:
float normalizedPos = angle / 360f;
-
分段计算颜色:
if (normalizedPos < 0.25f) { // 0°-90°:红到绿渐变 float fraction = normalizedPos / 0.25f; return interpolateColor(mRingColors[0], mRingColors[1], fraction); } else if (normalizedPos < 0.5f) { // 90°-180°:绿到蓝渐变 float fraction = (normalizedPos - 0.25f) / 0.25f; return interpolateColor(mRingColors[1], mRingColors[2], fraction); } else if (normalizedPos < 0.75f) { // 180°-270°:蓝到红渐变 float fraction = (normalizedPos - 0.5f) / 0.25f; return interpolateColor(mRingColors[2], mRingColors[3], fraction); } else { // 270°-360°:红色 return mRingColors[3]; }
颜色插值算法
private int interpolateColor(int color1, int color2, float fraction) {
int a1 = (color1 >> 24) & 0xff;
int r1 = (color1 >> 16) & 0xff;
int g1 = (color1 >> 8) & 0xff;
int b1 = color1 & 0xff;
int a2 = (color2 >> 24) & 0xff;
int r2 = (color2 >> 16) & 0xff;
int g2 = (color2 >> 8) & 0xff;
int b2 = color2 & 0xff;
int a = (int)(a1 + (a2 - a1) * fraction);
int r = (int)(r1 + (r2 - r1) * fraction);
int g = (int)(g1 + (g2 - g1) * fraction);
int b = (int)(b1 + (b2 - b1) * fraction);
return Color.argb(a, r, g, b);
}
粒子颜色生命周期
-
起始颜色:目标颜色的半透明版本
int startColor = Color.argb(100, Color.red(targetColor), Color.green(targetColor), Color.blue(targetColor));
-
当前颜色:随时间线性变化
return interpolateColor(startColor, targetColor, progress);
5.3. 粒子移动坐标计算
坐标系转换
所有计算基于数学坐标系:
- 0°:X轴正方向(三点钟方向)
- 90°:Y轴正方向(六点钟方向)
但我们需要:
- 0°:顶部(十二点钟方向)
- 90°:右侧(三点钟方向)
- 180°:底部(六点钟方向)
- 270°:左侧(九点钟方向)
转换公式:
float rad = (float) Math.toRadians(angle - 90);
粒子位置计算
-
起始位置(圆环外1.5-2倍半径处):
float distanceFactor = 1.5f + random.nextFloat() * 0.5f; float startX = mCenterX + (float) Math.cos(rad) * mRingRadius * distanceFactor; float startY = mCenterY + (float) Math.sin(rad) * mRingRadius * distanceFactor;
-
目标位置(安全区边缘):
float targetDistance = Math.max(mInnerCircleRadius, mSafeZoneRadius); float targetX = mCenterX + (float) Math.cos(rad) * targetDistance; float targetY = mCenterY + (float) Math.sin(rad) * targetDistance;
粒子移动计算
使用线性插值:
void update() {
progress = Math.min(progress + speed, 1.0f);
// 位置插值
x = startX + (targetX - startX) * progress;
y = startY + (targetY - startY) * progress;
// 大小插值
size = startSize + (targetSize - startSize) * progress;
}
碰撞检测
private void checkCollision() {
float dx = x - mCenterX;
float dy = y - mCenterY;
float distance = (float) Math.sqrt(dx * dx + dy * dy);
if (distance < mInnerCircleRadius + size) {
mCollided = true;
}
}
完整计算流程图示
角度系统转换:
数学坐标系(0°→东) → 视图坐标系(0°→北)
角度转换: view_angle = math_angle - 90°
粒子生成:
1. 随机角度 (0-360°)
2. 转换到数学坐标系: math_angle = view_angle - 90°
3. 计算位置:
x = center_x + cos(math_angle) * radius
y = center_y + sin(math_angle) * radius
4. 计算颜色: 使用view_angle计算颜色
粒子移动:
线性插值: 从起始位置到目标位置
大小变化: 从小到大
颜色变化: 从半透明到不透明
碰撞检测:
计算粒子中心到视图中心的距离
与内圆半径比较
关键公式总结
-
角度转换: math_angle = view_angle - 90°
-
坐标计算:
x = center_x + cos(math_angle) * radius y = center_y + sin(math_angle) * radius
-
颜色分段:
0-90°: 红 → 绿 90-180°: 绿 → 蓝 180-270°:蓝 → 红 270-360°:红色
-
线性插值:
value = start + (end - start) * progress
-
碰撞检测:
distance = sqrt(dx² + dy²) collided = distance < (inner_radius + particle_radius)
-
贝塞尔曲线 在充电水滴效果中,我们主要使用了三次贝塞尔曲线(cubicTo)。贝塞尔曲线由控制点定义曲线的形状:
- 起点:圆环上的连接点(ringX, ringY)
- 终点:水滴中心(dropX, dropY)
- 控制点1:在圆环切线方向上偏移(controlX1, controlY1)
- 控制点2:在水滴切线方向上偏移(controlX2, controlY2)
公式表示:
B(t) = (1-t)^3 * P0 + 3*(1-t)^2*t * P1 + 3*(1-t)*t^2 * P2 + t^3 * P3
其中 t ∈ [0,1],P0是起点,P3是终点,P1和P2是控制点。
6.源码
项目的地址:github.com/pengcaihua1…