英文原文: Play with SVG Paths in Canvas with AndroidFillableLoaders
我们通常不太喜欢Android SDK 内部的那些绘制逻辑。当我读到关于这些东西的时候我们通常会感到怪异,因为它看起来有点乏味。但是如果你仔细阅读的话其实也没那么难,而且一旦你能正确的理解它了,你就能创建出真正有趣的图像或者动画,比如下面的:
是不是很酷?前面的动画是取自几天前我发布的 AndroidFillableLoaders library 。这个库想为给定的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被裁减。
为了理解这点,我将使用两个例子。
这是第一个例子,也是一个相对简单的例子。这个自定义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。这里是效果图:
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 类以获得更多细节。
这个的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)
variation 的值是随机的,并和一个交替的标志作用于控制点的Y坐标,这样我们就能得到凹凸交替的效果。divisions则是8,确定从哪里开始波形。
一开始你可能会觉得理解起来很困难,但是我希望你能很清晰的了解那些lipping path figures / animations 是如何工作的。这里是波形的最终效果图:
以上就是全部。别忘了查看 AndroidFillableLoaders 库获取更多的细节!
如果你喜欢这篇文章,你可以或者 在推特上关注我 !