在Canvas 中玩转SVG Path-AndroidFillableLoader源码解析

英文原文:Play with SVG Paths in Canvas with AndroidFillableLoaders 

我们通常不太喜欢Android SDK 内部的那些绘制逻辑。当我读到关于这些东西的时候我们通常会感到怪异,因为它看起来有点乏味。但是如果你仔细阅读的话其实也没那么难,而且一旦你能正确的理解它了,你就能创建出真正有趣的图像或者动画,比如下面的:

small-gif

是不是很酷?前面的动画是取自几天前我发布的AndroidFillableLoaders library 。这个库想为给定的SVG Path所自定义的轮廓创建一个有趣的填充效果,它对安卓社区是完全开放的,以便获得社区的支持。我会用它来作为这篇文章的示例代码。

解析SVG path

标准的SVG path格式是不能被Android SDK理解的,但是如果有一个相应的解析器你完全可以构造自己的Path 元素,从而被安卓所用。如果你有任意一张透明的png图片,你可以使用一些工具比如GIMP从png中导出标准格式的SVG path。这里有一个清晰的例子告诉你如何使用。

一旦你得到了path,只需把定义它的数字拷贝下来,就是类似于下面的东西:

M 2948.00,18.00
   C 2956.86,18.01 2954.31,18.45 2962.00,19.91
     3009.70,28.94 3043.56,69.15 3043.00,118.00
     3042.94,122.96 3042.06,127.15 3041.25,132.00
     3036.37,161.02 3020.92,184.46 2996.00,200.31
     2976.23,212.88 2959.60,214.26 2937.00,214.00
     2926.91,213.88 2912.06,209.70 2903.00,205.24
     2893.00,200.33 2884.08,194.74 2876.04,186.91
     2848.21,159.81 2839.19,115.93 2853.45,80.00
     2863.41,54.91 2883.01,35.57 2908.00,25.45
     2916.97,21.82 2924.84,20.75 2934.00,18.51
     2938.63,17.79 2943.32,17.99 2948.00,18.00 Z
   M 2870.76,78.00
   ...

这就是你要通过解析从而转换成Path对象的SVG Path 。

为了解析它,我使用的是从 romannurik's Muzei 代码中找到的SvgPathParser类。这里面没有太多看点,它只不过是一个手动的解析器,使用Path的path.moveTo(),path.lineTo()或者path.cubicTo()等方法把字符形式的SVG path所定义的标准path运动与方向转换成Path item的移动元素。

如果你知道一点关于SVG 机制的知识,你就知道它定义了一些关于移动的tag标识,比如M或者m表示线性移动(不会绘制),C或者c表示曲线,H或者h,V或者v分别表示水平和垂直的线条,L或者l表示普通线条等等…。大写字母表示绝对位置,小写字母表示相对位置。

不同状态的生命周期

FillableLoader是这里的主要view,为了让动画完全工作,它有几个先后发生的状态。状态只是告诉view当前如何绘制的flag。同时你也应该知道本文的每一个动画(虚线或者填充动画)都是有自己的持续时间(duration)的。这个持续时间让view知道每一步何时结束,这样它就能从当前的状态变到下一个状态。

The drawingState list for the view is going to be:

view的绘制状态列表如下:

  • NOT_STARTED: 还未开始。

  • TRACE_STARTED: 开始绘制轮廓(虚线,实线,路径跟踪)。

  • FILL_STARTED: 轮廓的跟踪绘制完毕,开始填充view。

  • FINISHED: view的最终状态。

每次view改变自己的状态都会调用OnStateChangeListener方法,以给外部一个反馈,让调用的人对动画做出正确的反应。

动态线条的绘制

一旦view进入TRACE_STARTED状态,跟踪曲线就开始绘制,为此我初始化了一个画笔(Paint):

dashPaint = new Paint();
    dashPaint.setStyle(Paint.Style.STROKE);
    dashPaint.setAntiAlias(true);
    dashPaint.setStrokeWidth(strokeWidth);
    dashPaint.setColor(strokeColor);

这些东西都很普通。但是该如何绘制这个线条呢?如果你思考一下,你可能会觉得需要每隔一段时间绘制一点,然后越绘越长直至最后。

但是Android SDK中有一个很方便的方法可以做到这种效果。即dashPaint.setPathEffect(new DashPathEffect(...)))方法。就如文档中描述的那样,DashPathEffect需要在构造方法中得到一个item个数为偶数的区间数组。数组的偶数item指定的是"on”区间,而奇数item指定的是"off”区间。第二个参数是偏移量,但是我们的这个库不会使用它。

ps:为了更好的理解举个例子,PathEffect effects = new DashPathEffect(new float[] { 1, 2, 4, 8}, 1);  

代码中的float数组,必须是偶数长度,且>=2,指定了多少长度的实线之后再画多少长度的空白.

如本代码中,绘制长度1的实线,再绘制长度2的空白,再绘制长度4的实线,再绘制长度8的空白,依次重复

注意:这个patheffect 只会对STROKE或者FILL_AND_STROKE的paint style产生影响。如果style == FILL它会被忽略掉。

但是这里我们是不是缺少了什么东西呢?那就是当前时间内要绘制的线条长度。完整的代码如下(放在onDraw()方法中):

float phase 
    = MathUtil.constrain(0, 1, elapsedTime * 1f / strokeDuration);
float distance = animInterpolator.getInterpolation(phase) 
    * pathData.length;
dashPaint.setPathEffect(
    new DashPathEffect(new float\[\] { distance, pathData.length }, 0));
canvas.drawPath(pathData.path, dashPaint);

我们将得到当前时间在动画整个时间中的百分比,线条的距离也是根据这个计算而来,使用一个interpolator 作为value 的基准。而pathData.length在前面已经使用 PathMeasure 类获得了。

这里,我们以及完成了运动跟踪效果的绘制。更多信息参见 FillableLoader class 。现在我们继续讲解。

填充效果的绘制

我们再次为这种效果准备一个画笔(paint ),这次是一个填充画笔:

fillPaint = new Paint();
    fillPaint.setAntiAlias(true);
    fillPaint.setStyle(Paint.Style.FILL);
    fillPaint.setColor(fillColor);

绘制部分的代码如下(放到onDraw()方法中):

float fillPhase = 
    MathUtil.constrain(0, 1, 
    (elapsedTime - strokeDuration) * 1f / fillDuration);
clippingTransform.transform(canvas, fillPhase, this);
canvas.drawPath(pathData.path, fillPaint);

就如你看到的,time phase是截止到当前时间,填充绘制时间所消耗的百分比。为了计算这个值我们必须先减去线条动画效果绘制的时间strokeDuration。

裁减的逻辑由 ClippingTransform 代理实现,而负责创建填充效果的逻辑则放在它的transform()方法中。这里的唯一技巧就是clipping  forms,如果我们有一幅图像将被 filling paint绘制,我们希望在填充绘制之前让canvas被裁减。

为了理解这点,我将使用两个例子。

SpikesClippingTransform(锯齿)

这是第一个例子,也是一个相对简单的例子。这个自定义ClippingTransform的transform()方法是这样的:

@Override public void transform(Canvas canvas, float currentFillPhase, View view) {
    cacheDimensions(view.getWidth(), view.getHeight());
    buildClippingPath();
    spikesPath.offset(0, height * -currentFillPhase);
    canvas.clipPath(spikesPath, Region.Op.DIFFERENCE);
}

我们暂时忽略cacheDimensions()方法,因为它只是用来把view的尺寸存储在内存中,而且只存一次。这里最重要的是最后三行。buildClippingPath()方法创建一个绘制锯齿边框的path,名为spikesPath。这里是效果图:

spikes-gif

spikesPath创建完成之后,我们将给它一个Y偏移量,这个Y偏移量将根据currentFillPhase百分比以及view高度而变化。因此每一次调用onDraw()的时候,它都会向上移动一点点。这是以上代码片段的这一行完成的:

spikesPath.offset(0, height * -currentFillPhase);

最后canvas.clipPath()将把clipping path 设置为前面创建并设置好了位置的spikesPath。同时我们将在regions approach之间使用DIFFERENCE 操作。

canvas.clipPath(spikesPath, Region.Op.DIFFERENCE);

这完全是可选的,因为你可以用其他operations创建自己的ClippingTransform,比如默认的INTERSECT(细节查看 Region.Op 文档 )。

但是spikes path如何绘制的呢?这里就是了:

private void buildClippingPath() {
    float heightDiff = width * 1f / 32;
    float widthDiff = width * 1f / 32;
    float startingHeight = height - heightDiff;
    spikesPath.moveTo(0, startingHeight);
    float nextX = widthDiff;
    float nextY = startingHeight + heightDiff;
    for (int i = 0; i < 32; i++) {
      spikesPath.lineTo(nextX, nextY);
      nextX += widthDiff;
      nextY += (i % 2 == 0) ? heightDiff : -heightDiff;
    }
    spikesPath.lineTo(width, 0);
    spikesPath.lineTo(0, 0);
    spikesPath.close();
  }

别被它吓到了。如果你分析就会发现,我只不过是用了一个withDiff常量来变换锯齿之间的x轴,以及一个heightDiff来正负交替移动Y 轴。这样就形成了锯齿效果。

ps:path里面主要是绘制锯齿,但是还需要在开始喝结束的时候把路径封闭,因此有spikesPath.moveTo(0, startingHeight)和spikesPath.lineTo(width, 0);    spikesPath.lineTo(0, 0);

那么第一个例子就可以了。你可以去查看完整的SpikesClippingTransform类以获得更多细节。

WavesClippingTransform(波浪)

这个的transform()方法和前面的例子完全一样。因此不再拷贝。我们关注的是path 的构建,因为它是这里最有趣的部分。

我们总共有128个波形:

private void buildClippingPath() {
    buildWaveAtIndex(currentWaveBatch++ % 128, 128);
}

128只是一个随意的值,而且循环中的波形批次越多,整个动画就会变得越慢。可以把它们想象成标准动画里的帧。每次调用onDraw()方法,以下方法里的index参数都将变化。每一批波形包含了4个波。并且这些波的X和Y取决于当前波形批次的index。

private void buildWaveAtIndex(int index, int waveCount) {
    float startingHeight = height - 20;
    boolean initialOrLast = (index == 1 || index == waveCount);
    float xMovement = (width * 1f / waveCount) * index;
    float divisions = 8;
    float variation = 10;
    wavesPath.moveTo(-width, startingHeight);
    // First wave
    if (!initialOrLast) {
      variation = randomFloat();
    }
    wavesPath.quadTo(-width + width * 1f / divisions + xMovement, 
                    startingHeight + variation, 
                    -width + width * 1f / 4 + xMovement, 
                    startingHeight);
    if (!initialOrLast) {
      variation = randomFloat();
    }
    wavesPath.quadTo(-width + width * 1f / divisions * 3 + xMovement, 
                    startingHeight - variation,
                    -width + width * 1f / 2 + xMovement, 
                    startingHeight);
    // Second wave
    if (!initialOrLast) {
      variation = randomFloat();
    }
    wavesPath.quadTo(-width + width * 1f / divisions * 5 + xMovement, 
                    startingHeight + variation,
                    -width + width * 1f / 4 * 3 + xMovement,
                     startingHeight);
    if (!initialOrLast) {
      variation = randomFloat();
    }
    wavesPath.quadTo(-width + width * 1f / divisions * 7 + xMovement,
                    startingHeight - variation, 
                    -width + width + xMovement, 
                    startingHeight);
    // Third wave
    if (!initialOrLast) {
      variation = randomFloat();
    }
    wavesPath.quadTo(width * 1f / divisions + xMovement, 
                    startingHeight + variation, 
                    width * 1f / 4 + xMovement,
                    startingHeight);
    if (!initialOrLast) {
      variation = randomFloat();
    }
    wavesPath.quadTo(width * 1f / divisions * 3 + xMovement, 
                    startingHeight - variation, 
                    width * 1f / 2 + xMovement, 
                    startingHeight);
    // Forth wave
    if (!initialOrLast) {
      variation = randomFloat();
    }
    wavesPath.quadTo(width * 1f / divisions * 5 + xMovement, 
                    startingHeight + variation, 
                    width * 1f / 4 * 3 + xMovement, 
                    startingHeight);
    if (!initialOrLast) {
      variation = randomFloat();
    }
    wavesPath.quadTo(width * 1f / divisions * 7 + xMovement, 
                    startingHeight - variation,
                    width + xMovement, 
                    startingHeight);
    // Closing path
    wavesPath.lineTo(width + 100, startingHeight);
    wavesPath.lineTo(width + 100, 0);
    wavesPath.lineTo(0, 0);
    wavesPath.close();
}
private float randomFloat() {
    return nextFloat(10) + height * 1f / 25;
}
private float nextFloat(float upperBound) {
    Random random = new Random();
    return (Math.abs(random.nextFloat()) % (upperBound + 1));
}

就如我所说的,每个批次的波由四个波绘制而成。xMovement变量非常明确,它处理x轴上的移动。而波形是通过path.quatTo()方法绘制的。path.quatTo()方法绘制一个起始于当前点的二阶贝塞尔曲线,第一个参数( (X,Y 轴坐标))为贝塞尔曲线的控制点,最后一个点为结束点。

path.quadTo(controlPointX, controlPointY, endPointX, endPointY)

bez-curve

variation 的值是随机的,并和一个交替的标志作用于控制点的Y坐标,这样我们就能得到凹凸交替的效果。divisions则是8,确定从哪里开始波形。

一开始你可能会觉得理解起来很困难,但是我希望你能很清晰的了解那些lipping path figures / animations 是如何工作的。这里是波形的最终效果图:

waves-gif

以上就是全部。别忘了查看 AndroidFillableLoaders 库获取更多的细节!

如果你喜欢这篇文章,你可以分享给你的粉丝或者在推特上关注我!

译者注:关于动态跟踪曲线的绘制可以参考这篇文章:使用DashPathEffect绘制一条动画曲线

来自:UI实验室