12、View动画之自定义View动画:Canvas动画、PathMeasure动画、SVG动画

说实话,讲完前面那些补间动画和属性动画,很多同学会觉得——嗯,动画也就这样了。但真正到了复杂UI场景,你会发现系统提供的那些API根本不够用。

举个例子,我之前做过一个车载导航项目,需要模拟车辆沿复杂道路行驶的轨迹动画。用属性动画?根本搞不定。最后还是得撸起袖子,自己写自定义View动画。

这一章,我们就来聊聊自定义View动画的三种核心玩法:Canvas动画PathMeasure动画SVG动画。说白了,就是让你能控制每一帧画什么、怎么画。

自定义View动画三大流派 Canvas动画 onDraw() 中逐帧绘制 canvas.translate/rotate canvas.drawPath/Arc 配合 ValueAnimator 适用:自定义绘制、图表 PathMeasure动画 PathMeasure.getPosTan() 获取路径坐标/切线 getSegment() 截取片段 实现路径运动/描边 适用:轨迹动画、路径绘制 SVG动画 解析SVG Path数据 VectorDrawable + AnimatedVectorDrawable 属性动画驱动 trimPathStart/End 适用:图标动画、Logo动画 核心思路:用代码控制每一帧的绘制内容,实现系统API无法覆盖的动画效果

12.1 Canvas动画——最底层的绘制控制

Canvas动画,说白了就是你在 onDraw() 方法里自己画每一帧。你想想看,系统动画无非是帮你调用了 setTranslationX()setRotation() 这些方法。但如果你要画一个自定义的仪表盘指针旋转,或者一个水波纹扩散效果,系统动画就帮不上忙了。

我个人的习惯是:能用属性动画解决的,绝不用Canvas动画。因为Canvas动画需要你自己处理刷新、性能、坐标系变换,坑比较多。但一旦遇到属性动画搞不定的场景,Canvas动画就是你的王牌。

来看一个典型的Canvas动画实现——让一个圆形沿着正弦曲线运动:

public class SinWaveView extends View {
    private float phase = 0f;
    private ValueAnimator animator;

    public SinWaveView(Context context) {
        super(context);
        animator = ValueAnimator.ofFloat(0f, 2f * (float) Math.PI);
        animator.setDuration(2000);
        animator.setRepeatCount(ValueAnimator.INFINITE);
        animator.addUpdateListener(animation -> {
            phase = (float) animation.getAnimatedValue();
            invalidate(); // 关键:触发重绘
        });
    }

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        float width = getWidth();
        float height = getHeight();
        float centerY = height / 2f;

        // 绘制轨迹线
        Paint pathPaint = new Paint();
        pathPaint.setStyle(Paint.Style.STROKE);
        pathPaint.setColor(Color.LTGRAY);
        Path path = new Path();
        for (float x = 0; x <= width; x++) {
            float y = centerY + 200f * (float) Math.sin(2 * Math.PI * x / width + phase);
            if (x == 0) path.moveTo(x, y);
            else path.lineTo(x, y);
        }
        canvas.drawPath(path, pathPaint);

        // 绘制运动的小球
        float ballX = width / 2f;
        float ballY = centerY + 200f * (float) Math.sin(phase);
        Paint ballPaint = new Paint();
        ballPaint.setColor(Color.RED);
        ballPaint.setStyle(Paint.Style.FILL);
        canvas.drawCircle(ballX, ballY, 20f, ballPaint);
    }
}

核心要点:

  • ValueAnimator 驱动数值变化,在监听器中调用 invalidate()
  • onDraw() 中根据当前数值计算绘制内容
  • 注意 onDraw() 中不要创建对象,避免GC导致卡顿

我曾经踩过的坑:在 onDraw() 里 new Paint() 和 new Path()。那时候做的是一个实时心率图,每帧都new对象,结果GC频繁触发,动画一卡一卡的。后来改成成员变量复用,问题就解决了。

12.2 PathMeasure动画——让物体沿路径运动

PathMeasure 这个类,说实话,是Android动画里被低估的一个工具。它能把一个 Path 对象拆解成坐标和切线信息。什么意思呢?就是你画一条曲线,PathMeasure 能告诉你「在这条曲线的50%位置,坐标是多少,方向朝哪」。

我在项目中做过一个功能:地图上显示车辆从A点到B点的行驶动画。如果用属性动画直接移动,车辆不会沿着道路转弯,看起来像瞬移。用 PathMeasure 就完美解决了。

核心用法就两个方法:

方法 作用
getPosTan(float distance, float[] pos, float[] tan) 获取路径上指定距离处的坐标和切线方向
getSegment(float startD, float stopD, Path dst, boolean startWithMoveTo) 截取路径的一段,用于描边动画

来看一个路径描边动画的经典实现——让一条线从无到有地「画」出来:

public class PathTraceView extends View {
    private Path path;
    private PathMeasure pathMeasure;
    private float pathLength;
    private float progress = 0f;

    public PathTraceView(Context context) {
        super(context);
        path = new Path();
        path.moveTo(100, 100);
        path.cubicTo(300, 50, 200, 300, 500, 200);
        pathMeasure = new PathMeasure(path, false);
        pathLength = pathMeasure.getLength();

        ValueAnimator anim = ValueAnimator.ofFloat(0f, 1f);
        anim.setDuration(3000);
        anim.setRepeatCount(ValueAnimator.INFINITE);
        anim.addUpdateListener(animation -> {
            progress = (float) animation.getAnimatedValue();
            invalidate();
        });
        anim.start();
    }

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        Paint paint = new Paint();
        paint.setStyle(Paint.Style.STROKE);
        paint.setStrokeWidth(8f);
        paint.setColor(Color.BLUE);
        paint.setStrokeCap(Paint.Cap.ROUND);

        Path dst = new Path();
        float stopD = pathLength * progress;
        pathMeasure.getSegment(0, stopD, dst, true);
        canvas.drawPath(dst, paint);
    }
}

小技巧:如果你想让一个图标沿着路径运动,用 getPosTan() 获取坐标,再用 Math.atan2(tan[1], tan[0]) 计算旋转角度,就能让图标始终朝向运动方向。我在地图导航项目里就是这么做的。

12.3 SVG动画——矢量图的动画魔法

SVG动画在Android里,其实是通过 VectorDrawableAnimatedVectorDrawable 实现的。你想想看,一个SVG图标,能不能让它像画出来一样?或者让一个箭头从A平滑变形到B?这些就是SVG动画的强项。

我记得第一次接触这个,是在做启动页Logo动画。设计师给了一个SVG格式的Logo,要求从中心向外扩散显示。如果用帧动画,资源包会很大。用SVG动画,一个XML文件就搞定了。

实现步骤分三步:

  1. 准备SVG路径数据——用 <vector> 标签定义矢量图
  2. 定义动画——用 <objectAnimator> 驱动 trimPathStarttrimPathEnd 属性
  3. 组合——用 <animated-vector> 将两者关联

来看一个最简单的描边动画XML:

<!-- res/drawable/ic_heart.xml -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
    android:width="100dp"
    android:height="100dp"
    android:viewportWidth="100"
    android:viewportHeight="100">
    <path
        android:name="heart"
        android:pathData="M50,85 L20,50 A20,20 0 0,1 50,30 A20,20 0 0,1 80,50 Z"
        android:strokeColor="#FF0000"
        android:strokeWidth="3"
        android:fillColor="@android:color/transparent"
        android:trimPathStart="0"
        android:trimPathEnd="0" />
</vector>

<!-- res/drawable/avd_heart.xml -->
<animated-vector xmlns:android="http://schemas.android.com/apk/res/android"
    android:drawable="@drawable/ic_heart">
    <target
        android:name="heart"
        android:animation="@animator/heart_draw" />
</animated-vector>

<!-- res/animator/heart_draw.xml -->
<objectAnimator xmlns:android="http://schemas.android.com/apk/res/android"
    android:propertyName="trimPathEnd"
    android:duration="2000"
    android:valueFrom="0"
    android:valueTo="1"
    android:repeatCount="infinite" />

关键属性说明:

  • trimPathStart:从路径的起始位置裁剪(0~1)
  • trimPathEnd:从路径的结束位置裁剪(0~1)
  • 两者配合使用,可以实现「从中间向两边展开」的效果

我曾经踩过的坑:SVG路径数据中的坐标必须与 viewportWidth/viewportHeight 匹配。有一次我从设计师那里拿到的SVG是1000x1000的viewport,但我写成了100x100,结果图标显示不全。后来统一约定用100x100的viewport,路径坐标按比例缩放。

12.4 三种动画的选型对比

说了这么多,到底什么时候用哪种?我根据自己的项目经验,整理了一个对比表:

场景 推荐方案 原因
自定义绘制(图表、仪表盘) Canvas动画 需要完全控制绘制逻辑
物体沿复杂路径运动 PathMeasure动画 天然支持路径坐标和方向计算
图标描边/变形动画 SVG动画 资源小、适配好、支持矢量缩放
简单位移/旋转/缩放 属性动画 系统自带,性能最优

嗯,到这里,自定义View动画的三种核心玩法就讲完了。Canvas动画给你最底层的绘制自由,PathMeasure动画让路径运动变得简单,SVG动画则把矢量图的优势发挥到极致。这三种技术,基本能覆盖90%以上的复杂动画需求。

下一章,我们会聊聊属性动画的进阶用法——插值器和估值器的自定义。不过那是后话了,先把今天的内容消化掉。


公众号:蓝海资料掘金营,微信deep3321