SVG(Scalable Vector Graphics 可缩放矢量图形),是一种矢量图格式。在 Android 5.0 ( API 21) 时候,用 VectorDrawable 支持矢量图,用 AnimatedVectorDrawable 支持矢量图动画。
在项目中如果要添加一张图片,那么必须要考虑到不同屏幕分辨率的问题,也就需要为不同分辨率的屏幕准备一个相应的图片,否则可能造成图片失真。 如果图片添加多了,自然而然,apk 的大小就增大了,这往往也是我们最不想看到的。而 Vector Drawable 就可以解决这些问题。
VectorDrawable 是在 XML 中定义的,与 SVG 格式非常类似,但是我并不打算去介绍 SVG ,而是用 Android 所支持的举例说明。
Vector Drawable 在 XML 中是以 < Vector > 标签定义,由 < path > ,< group > ,< clip-path >组成,并形成一个树型结构。< path > 是绘制几何图形的轮廓,< group > 是定义变换的细节,< clip-path > 是定义裁剪的区域。
请看下面从 Android 官网抠的图。
本篇文章只介绍 SVG 的 PathData 在 Android 中的实现,先从一个简单的例子说起
<vector
xmlns:android="http://schemas.android.com/apk/res/android"
android:width="64dp"
android:height="64dp"
android:viewportHeight="100"
android:viewportWidth="100">
<path
android:name="triangle"
android:pathData="M 25,25 h 50 l -25 50z"
android:strokeColor="@color/colorAccent"
android:strokeWidth="1"/>
vector>
这是绘制一个倒三角形,效果如下
可能大家或多或少对这个 XML 有点疑问,那么我就来带大家抽丝剥茧。
先想象下,既然 SVG 是通过一些命令( 附带点坐标 )来定义绘制路径的,那么 Android 中 VectorDrawable 的实际大小如何定义呢? 这是由 < vector > 的 android:width
和 android:height
来定义的,单位其实是随意的,不过一般为 dp。
再来联想下,< path > 应该就是相当于 Android 中的 Path 类,那么 Path 要有数据吧,< path > 的 android:pathData
就定义了数据。
既然有了 Path,还需要 Canvas 是吧,系统肯定会为我们自动生成,不过 Canvas 的大小肯定还是要我们自己设置的,所以 < vector > 的 android:viewportWidth
和 android:viewportHeight
就是设置画布的大小的。
既然 Canvs 也有了,当然不能少了 Paint 吧,很自然会想到系统会帮我们生成,但是 Paint 的 Style,Color 这就需要我们自己设置的,所以 < path > 的属性 android:strokeWidth
和 android:strokeColor
就是对应 Paint 的 Style 为 Stroke,以及对应 Paint 的 Color。 当然,也有对应 Paint 的 Style 为 Fill 的属性,例如 android:fillColor
。
这个分析是不是很到位,不过重点来了,这个 android:pathData
的数据如何定义呢? 这里面的字母其实就是与 SVG 的 Paths 的命令是相对应的,https://www.w3.org/TR/SVG11/paths.html#PathData 这个网址对 Paths 解释的非常详细,如果你喜欢原汁原味的,非常建议读一读。如果你觉得一大堆的英文很没意思,我就从 Android 的角度来解释下 SVG 的 Paths 的命令。
首先介绍下使用于 Android 的 Path Data 的基本性质
1. 为了可读性,Path Data 可以包含换行符
2. 所有指令都是以字母开头,例如 M 指令代表 moveto ,也就是 Path 类的 moveTo() 方法。
3. 空格和分隔符会被自动排除,例如 M 25,25 L 100,100 这个指令看起来很清晰,同时也等价于 M25 25L100 100。 为了可读性,本文会选择前面一种写法。
4. 如果同一个命令连续用多次,那么后面命令的字母是可以省略的,例如 M 25,25 L 50,25 L 50,50,第二个 L 是可以省略的,可以写成 M 25,25 L 50,25 50,50
5. 大写字母代表绝对路径,小写的字母代表相对路径
了解了这些基本东西后,我们再具体看看每个命令是怎么用的。
M/m 代表 moveto 命令,就是相当于 Path 类的 moveTo() 方法,如果还要说的再明白点,就是把一支笔从画布上抬起,然后落到画布上一个新的点。
既然 moveto 命令定义了绘制的起点,所以 Path Data 必须以 M/m 开头,其实这里大家可以理解下,不论是以M开头还是m开头,落点都是一样的。
但是 M/m 放在命令的中间,效果就不一样了。例如 M 25,25 L75,25 M 25,50 L75,50 是绘制两条平行的线段,
而 M 25,25 L75,25 m 25,50 L75,50 就绘制的不是平行的线段,因为 m 25,50 是相对于点(75,25)的坐标,而不是相对于原点(0,0)的坐标
如果 M 后面带有多个坐标点,那么相当于 lineto 命令,例如 M 25,25 50,25 50,50 等于 M 25,25 L 50,25 L 50,50 ,效果如下
L/l 命令表示从当前点到新点之间画一条线段,例如 android:pathData="M 25,25 L 75,25"
就代表从点(25,25)到点(75,25)的一条线段。效果如下
如果 L/l 后面接了多个坐标点,从前面讲述的 Path Data 的基本特性可知,其实就是省略了 L/l,例如 android:pathData="M 25,25 L 75,25 50,75"
,其实就是绘制了二段直线,效果如下
上面说过,大写的字母代表是绝对坐标,小写字母代表相对坐标,那么 M 25,25 L 75,25 50,75 就是等价于 M 25,25 l 50,0 -25,55。 后面的讲解的命令我就不再讲解如何用小写表示了。
lineto 命令还有2种特殊的情况,这2种特殊情况是画水平,垂直直线,分别用字母 H/h 和 V/v 表示。原理与 L/l 一样,这里就不再举例了。
Z/z 代表结束当前 Path ,并且会自动从当前点到初始点绘制一条线段。
例如, 上面讲 lineto 命令的时候,绘制了两条线段,那么如果我在 pathData 后面再加一个 Z/z 呢,也就是 M 25,25 L 75,25 50,75 Z,效果如下
由于 Z 和 z 都是结束当前 path 绘制的命令,所以大小写是等价的。
如果在 Z/z 命令后面接一个 M/m 命令,代表下一个 Path 的起点。现在来看看在 z 后面接 M 和 m的区别。
如果命令是 M 25,25 L 75,25 50,75 Z M 50,50 L75,50
这个大家一看就能明白,M 50,50 是基于坐标原点的绝对坐标,所以很好理解。
如果命令是 M 25,25 L 75,25 50,75 Z m 50,50 L75,50
这个可能一下子没看明白,其实 m 50,50,这个是相对于上个 path 的起始点坐标(25,25)而言的,而 L 75,50 是基于原点的绝对坐标。
而如果 Z/z 后面接的非 M/m 命令呢,那么下一个 Path 的起始点,就是当前 Path 的起始点,例如 M 25,25 L 75,25 50,75 Z l 25,25
现在我们来看看一个比较有意思的事情,首先只绘制一条线段,这里我们让 StrokeWidht 宽一点
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp"
android:height="48dp"
android:viewportHeight="100"
android:viewportWidth="100">
<path
android:pathData="M 25,25 L 75,25"
android:strokeColor="@color/colorAccent"
android:strokeWidth="5"/>
vector>
注意看图,绘制的是一个矩形形状的线段。
现在把命令修改为 M 25,25 L 75,25 L 50,75
不知道大家有没有注意到线段连接处,如果按照两个矩形相连接的情况,不应该出现这么完美的尖角形状的,没错,答案就是系统自动帮我们处理了。
现在把命令修改为 M 25,25 L 75,25 L 50,75Z
这个图形是不是很完美,没错,你肯定又想到了,不就是系统又帮我们处理了一次嘛。
而如果现在命令修改为 M 25,25 L 75,25 L 50,75 L 25,25
这个时候,我和我的小伙伴都惊呆了,这次特么的系统怎么不帮我自动处理了。 所以,记住一点,想实现图形闭合,用 Z/z 是最佳的方式。
那么,我现在又想说了,这个系统处理的这么丑,还有没有其他选择? 那是当然有。
<path
android:strokeLineJoin="round"
android:pathData="M 25,25 L 75,25 L 50,75 Z"
android:strokeColor="@color/colorAccent"
android:strokeWidth="5"/>
现在变成了圆角连接的效果了,那么 android:strokeLineJoin
还有哪几个值呢?
1. miter : 默认值,也就是系统默认使用的
2. round : 圆角式连接
3. bevel:斜角式连接
我们看看斜角式连接的效果
Q/q 用来表示二阶贝赛尔曲线的命令,参数是(x1,y1 x,y),其中 x1,y1 表示控制点,x,y 表示绘制结束点。
例如,M 25,75 Q 50,25 75,75 可以绘制一条二阶贝赛尔曲线
这个效果图中,红色那条线才是二阶贝塞尔曲线。
T/t 代表光滑的二阶贝塞尔曲线,参数只有一个点(x,y),这个点是代表绘制的结束点,而不是控制点,那么控制点是 前一个二阶贝塞尔曲线的控制点 相对于 前一个贝塞尔曲线的结束点 的对称点。
例如 M 0,50 Q 25,0 50,50 T 100,50
上面的命令中用 T 100,50 表示平滑的二阶曲线,它的控制点是(25,0) 关于 (50,50) 的对称点。从这里可以看出,之所以叫平滑二阶曲线,是因为前后曲线相接处都与控制点连接的直线相切,所以叫平滑,看来命名还是很有道理的。
C/c 表示三阶贝赛尔曲线的命令字母,参数为(x1,y1 x2,y2 x,y),其中 x1,y1 为第一个控制点,x2,y2 为第二个控制点,x,y 为绘制的结束点。
例如 M 10,60 C30,0 90,20 100,60
同样的,S/s 表示平滑的三阶贝塞尔曲线,参数为(x2,y2 x,y) ,其中 x2,y2 为第二个控制点,x,y为绘制终点。 那么还差一个点,我想大家也能猜出来这个点是怎么来的吧,第一个控制点就是前一个三阶曲线的第二个控制点 关于 前一个三阶曲线终点的 对称点。
例如 M 10,60 C30,0 90,20 100,60 S 170,120 180,60
这个就不用多解释了吧。
A/a 表示椭圆圆弧曲线命令,参数为(rx ry x-axis-rotation large-arc-flag sweep-flag x,y),其中 rx 和 ry 表示椭圆长轴和短轴的半径,x-axis-rotation 表示绕当前坐标轴的x轴旋转的角度,x,y 标志圆弧终点坐标,然而这三个参数画出的椭圆有2种情况(假设 x-axis-rotation 为 0),如下图。
而这个时候,用2个点可以确定4条弧线,那么如何确定具体是哪条弧线呢? 通过观察可以发现,2个点可以把一个椭圆分成2段弧线,这2段弧线要么一长一短,要么相等。 如果是一长一短,large-arc-flag 如果为 1,表示取长弧,为0表示取短弧,这样一来,就只剩下2种情况的圆弧了。这个时候,如果sweep-flag 为 1,表示顺时针方向绘制的弧,为0表示逆时针方向绘制的弧。所以,设置这2个参数后,就可以完美的确定一条弧线。 那么有人可能会问,如果不是一长一短,而是相等呢?这种情况下,两个圆弧不就是重合了嘛。
例如,M 100,100 L 50,100 A 50 50 0 1 0 100,50Z
当2个 Path 有相交的区域的时候,如下图
我们用 FILL 来填充图形的时候,往往是指填充图形内部,那么现在问题来了,相交的图形,相交的区域到底是内部还是外部? 如果我只想填充交互的区域,那么交互的区域就是内部,其他都是外部。 而如果我只想填充非交互区域的图形的内部,那么交互区域就是外部了。所以,我们需要规则去判断所谓的“内部区域”和“外部区域”。
从Canvas 上任意一点画一条射线,检查与 Path 交互的点,如果 Path 是从左到右穿过射线,count( 默认为0 )就加1,如果从右到左穿过,count 就减1,计算完毕后,如果 count 为 0,就代表这个点在外部,如果 count 非零,就代表这个点在内部。
从上图可以看到,两个矩形的内部的任意一点画射线,得到的 count 都是非0,所以,如果要填充的话,两个矩形内部都会被填充。
这个就更简单了,从Cavas上任意一点画射线,计算射线与 Path 交互点的个数,如果是奇数,就认为这个点在内部,如果是偶数,就代表这个点在外部,这个用上面的图就可以解释了。
在 Android 中,< path > 有个属性 android:fillType
(Android 7.0 API 24 才有这个属性)就是与上面的填充规则对应,有2个值,nonZero (非零规则)和 evenOdd(奇偶规则)。
举个例子,先画2个交互的 Path
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp"
android:height="48dp"
android:viewportHeight="200"
android:viewportWidth="200">
<path
android:pathData="M 40,40 L 120,40 120,120 40,120z
M 80,80 L 160,80 160,160 80,160Z"
android:strokeColor="@color/colorAccent"
android:strokeWidth="1"
/>
vector>
现在,我们改为填充模式,并用 nonZero 规则
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp"
android:height="48dp"
android:viewportHeight="200"
android:viewportWidth="200">
<path
android:fillType="nonZero"
android:fillColor="@color/colorAccent"
android:pathData="M 40,40 L 120,40 120,120 40,120z
M 80,80 L 160,80 160,160 80,160Z"
/>
vector>
现在把 nonZero 改为 evenOdd 规则的话就是下面的效果了
本篇文章,就是为了理解 SVG 的 PathData 在 Android 中如何应用。通看这篇文章,你会发现,其实这个 PathData 的命令和 Android 中的 Path 类是想通的。后面的文章会介绍如何生成 SVG 图,如何把 SVG 图转化为 VectorDrawable,以及如何使用 AnimatedVectorDrawable 来生成矢量图动画。
https://www.w3.org/TR/SVG11/paths.html#PathData
https://developer.mozilla.org/zh-CN/docs/Web/SVG/Tutorial/Paths
https://www.w3.org/TR/SVG/painting.html#FillRuleProperty
https://developer.android.com/reference/android/graphics/drawable/VectorDrawable.html