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

泡在网上的日子 / 文 发表于2015-10-09 14:50 次阅读 Canvas,Path,SVG

英文原文: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绘制一条动画曲线 

收藏 赞 (3) 踩 (0)
上一篇:SpannableString与SpannableStringBuilder
原文出处: http://blog.csdn.net/harvic880925/article/details/38984705 一、概述 1、SpannableString、SpannableStringBuilder与String的关系 首先SpannableString、SpannableStringBuilder基本上与String差不多,也是用来存储字符串,但它们俩的特殊就在
下一篇:Android开发最佳实践(胡凯版本)
来源: http://hukai.me/android-dev-patterns/ ,文中对从右往左滑出DrawerLayout 的描述没有验证过,不知是否可行。 前段时间,Google公布了 Android开发最佳实践 的一系列课程,涉及到一些平时开发过程中应该保持的良好习惯以及如何使用最新的 Android De