8.Android Vivo X200S手机充电动画 PK 遥遥领先Huawei手机充电动画

155 阅读10分钟

最近换了个新手机VIVO,看了充电动画还是挺炫丽的! 网上找了下没有,于是自己写了个,效果类似

同时也写了个华为手机充电效果,他们相同的地方: 就是用贝塞尔曲线,思路来源于QQ里面的气泡拖动效果, 涉及的计算,也是特别复杂! 如果你能把这个掌握,基本自定义view和动画就非常牛逼了

后面一课vLOG:做抖音的剪映里面处理视频,各自拖动,缩放,滑动,帧处理的UI效果,也是非常复杂! (之前看同事做的一直会闪烁,并且会偶现白)

1.效果图

手机充电动画 实现了充电过程中能量流动的视觉效果,配合百分比文字可直观显示充电进度。粒子系统的运动轨迹和颜色变化增强了动画的科技感和动态感。

1000007480_免费视频转GIF_20250730_162918.gif

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);

颜色分布验证:

角度位置颜色验证
顶部红色
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.架构图

deepseek_mermaid_20250730_47f409.png

deepseek_mermaid_20250730_3da88e.png

5.总结

deepseek_mermaid_20250730_e1cec1.png 比较复杂的就是计算

5.1 静态圆环的颜色计算

5.2 粒子的颜色计算

5.3 粒子移动的坐标计算

5.1. 静态圆环颜色计算

计算原理

圆环使用SweepGradient实现,这是一个环形渐变着色器。默认情况下:

  • 0° 指向三点钟方向(X轴正方向)
  • 角度顺时针增加

但我们需要:

  • 0° 指向顶部(12点钟方向)
  • 90° 指向右侧
  • 180° 指向底部
  • 270° 指向左侧

实现步骤

  1. 创建基础渐变

    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)
  2. 旋转渐变矩阵

    Matrix gradientMatrix = new Matrix();
    gradientMatrix.setRotate(-90, mCenterX, mCenterY);
    sweepGradient.setLocalMatrix(gradientMatrix);
    
    • 将整个渐变逆时针旋转90°
    • 使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. 粒子颜色计算

计算原理

粒子颜色需要匹配其在圆环上的生成位置的颜色。使用角度参数计算颜色:

  1. 归一化位置

    float normalizedPos = angle / 360f;
    
  2. 分段计算颜色

    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);
}

粒子颜色生命周期

  1. 起始颜色:目标颜色的半透明版本

    int startColor = Color.argb(100, 
            Color.red(targetColor),
            Color.green(targetColor),
            Color.blue(targetColor));
    
  2. 当前颜色:随时间线性变化

    return interpolateColor(startColor, targetColor, progress);
    

5.3. 粒子移动坐标计算

坐标系转换

所有计算基于数学坐标系:

  • 0°:X轴正方向(三点钟方向)
  • 90°:Y轴正方向(六点钟方向)

但我们需要:

  • 0°:顶部(十二点钟方向)
  • 90°:右侧(三点钟方向)
  • 180°:底部(六点钟方向)
  • 270°:左侧(九点钟方向)

转换公式

float rad = (float) Math.toRadians(angle - 90);

粒子位置计算

  1. 起始位置(圆环外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;
    
  2. 目标位置(安全区边缘):

    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;
    }
}

完整计算流程图示

deepseek_mermaid_20250730_e1cec1.png

角度系统转换:
  数学坐标系(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计算颜色

粒子移动:
  线性插值: 从起始位置到目标位置
  大小变化: 从小到大
  颜色变化: 从半透明到不透明

碰撞检测:
  计算粒子中心到视图中心的距离
  与内圆半径比较

关键公式总结

  1. 角度转换: math_angle = view_angle - 90°

  2. 坐标计算

    x = center_x + cos(math_angle) * radius
    y = center_y + sin(math_angle) * radius
    
  3. 颜色分段

    0-90°:   红 → 绿
    90-180°: 绿 → 蓝
    180-270°:蓝 → 红
    270-360°:红色
    
  4. 线性插值

    value = start + (end - start) * progress
    
  5. 碰撞检测

    distance = sqrt(dx² + dy²)
    collided = distance < (inner_radius + particle_radius)
    
  6. 贝塞尔曲线 在充电水滴效果中,我们主要使用了三次贝塞尔曲线(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是控制点。

deepseek_mermaid_20250730_81d198.png

6.源码

项目的地址:github.com/pengcaihua1…