原文: How To Create Vector Graphics on iOS
作者: Akiel Khan
译者:CocoaChina--softwin(CC论坛ID)
介绍
在数字世界中,图像资源可分为光栅和矢量两种基本类型。光栅图形本质上是一组矩阵的像素强度。而矢量图形是形状的数学表示。
虽然有很多场景光栅图形是不可替代的(比如照片),但在其他某些场景中,矢量图是一个良好的替代方式。矢量图形使得我们在多屏分辨率上创建图像资源易如反掌。在笔者写作之时,iOS平台至少有6种分辨率的屏幕需要处理。
矢量图形的一个巨大优势就是它可以被渲染在任意分辨率的屏幕上,同时保持绝对的平滑的且不失真。这就是为什么 PostScript 和 TrueType 字体在任意放大倍数下都如此清晰的原因。因为智能手机和电脑显示屏幕一般是光栅排列,所以在合适的分辨率下,矢量图形必须作为光栅图形才能在屏幕渲染。而这些底层图形库已经封装了上述实现,程序员并不需要了解。
1.什么时候使用矢量图形
让我们来考虑一下使用矢量图形的一些场景。
几年前(iOS7),苹果公司在自己的app和iOS平台自身的用户界面中抛弃了拟物设计风格(skeuomorphism),而采用更扁平的精细设计。可以参考下Camera和Photo app引用的图标。
十有八九,这些元素是由矢量图形工具设计的。为了符合这些设计规则,开发者不得不跟随扁平化风格,这导致大部分流行的(非游戏类)app完全改变了风格。
简单图像( Asteroids )或几何主题( Super Hexagon )的游戏,能使用游戏引擎渲染矢量图形。游戏中通过代码编写的部分也采用了矢量图形。
你可以随机的插入图片来获得基于相同基本图形的多个版本的图像。
2.贝塞尔曲线
什么是贝塞尔曲线?在不深入探讨数学理论情况下,我们来讨论下开发者实际用到的贝塞尔曲线特征。
贝塞尔曲线特点是它有多少的自由度。自由度越大,曲线变化越大(数学计算就越复杂)
一次方贝塞尔曲线就是两点的直线线段。二次方贝塞尔曲线也称作闭合曲线。三次方贝塞尔曲线(立方)是我们重点关注的,因为它在伸缩性和复杂性上提供了这种方案。
立方贝塞尔曲线不仅可以表示简单平滑曲线,也可以表示封闭曲线和尖端曲线(两曲线相汇与一点)。许多立方贝塞尔曲线段可以通过点对点的衔接在一起形成更复杂的形状。
立方贝塞尔曲线的形状是由它的两个端点和两个额外的描点决定它的形状的。一般来说,n次方的贝塞尔曲线有(n-1)个描点,不用计算有几个端点。
立方贝塞尔曲线有一个引人注目的特征是这些点有可视化的特性。连接端点和它最近的喵点的这条线是曲线的切线。这条切线是设计贝塞尔曲线形状的基础,我们会稍后深入研究这个特性。
基于曲线的数学特性,你可以简单的在曲线上进行没有任何精度损失的几何变化,比如缩放,旋转和平移。
下面的图片展示了不同形状的三次方贝塞尔曲线的样本。注意绿色线就是曲线的切线。
3.Core Graphics和UIBezierPath类
在iOS和OS X平台,矢量图形底层是基于C语言的核心图形库实现的。它基于UIKit/Cocoa上层,封装面向对象的类 。它的实现者就是UIBezierPath类(OS X是NSBezierPath类),一个贝塞尔曲线理论的实现。
UIBezierPath类支持一次方贝塞尔曲线(就是直线端),二次方贝塞尔曲线(封闭曲线)和三次方贝塞尔曲线(三维曲线)
从编程角度考虑,UIBezierPath对象可以通过添加子路径的方式一个一个添加。为了实现这个方式,UIBezierPath对象持续关注currentPoint属性。每次你添加一个新的子路径段,最末端点就成为当前点,接下来的绘图操作就从这个当前点开始。你可以手动移动这个点到你想要的位置。
UIBezierPath类为一些常用的形状提供了便捷的方法,比如弧,圆和圆角矩形等。其内部的实现是多个子路径互相连接而成。
贝塞尔曲线路径形状可以是开放或封闭的,甚至可以自包含或者同时有多个封闭曲线。
4.入门
这本指南需要读者有一定的矢量图形基础。不过如果你是一位有经验的开发者但从来没使用过Core Graphics库或UIBezierPath类,你可以学习下去。但如果你是新手并且不熟悉,我建议你先阅读 UIBezierPath 的 官方API说明 (同样参考Core Graphics官方文档API)。在这篇教程中我们只会练习API中几个有限的功能。
话不多说,我们这就开始编写代码。在该篇教程的剩余部分,我会展现两个适合使用矢量图形的场景。
打开Xcode工具,创建一个新的playground文件,设置平台为iOS。顺便说一句,Xcode的playground是使用矢量图形工作变得有趣的另一个原因。你可以敲入代码并立即获得代码的可视效果。请记住你必须使用最新版的Xcode,目前的版本是7.2。
我们要生成一组云图片,它是依附于一个基本的云图形的,但是这些图形是随机的产生的并且形状看起来不一样。我选定的基本设计是一个复合的形状,它是由多个半径大小随机设定并且圆心可以组成一个大小合适的椭圆路径。
明确一点,如果我们只是画矢量图路径而没有填充,结果如下图所示
如果你的几何知识不太好,那么 维基百科 图片展示了椭圆的基本形状。
一些实用的函数
首先,我们需要写两个有用的函数
import UIKit func randomInt(lower lower: Int, upper: Int) -> Int { assert(lower < upper) return lower + Int(arc4random_uniform(UInt32(upper - lower))) } func circle(at center: CGPoint, radius: CGFloat) -> UIBezierPath { return UIBezierPath(arcCenter: center, radius: radius, startAngle: 0, endAngle: CGFloat(2 * M_PI), clockwise: true) }
这个 random(lower:upper:) 函数使用内置的arc4random_uniform方法在lower和upper-1数值区间产生随机数。这个circle(at:center:) 函数生成UIBezierPath对象,它表示一个给定圆心和半径的圆。
现在我们关注构成椭圆路径的那些点。一个沿坐标系轴对称的以坐标系原点为中心的椭圆它的数学公式特别简单,如下。
P(r, θ) = (a cos(θ), b sin(θ))
我们给椭圆长轴和短轴的长度赋任意值,让它的形状看起来像一片云朵的样子,水平方向比垂直方向加长些。
我们用stride() 函数围绕这个圆圈规律地生成空间夹角,然后用map() 函数,在通过以上数学公式生成的椭圆上有规律地生成点。
let a = Double(randomInt(lower: 70, upper: 100)) let b = Double(randomInt(lower: 10, upper: 35)) let ndiv = 12 as Double let points = (0.0).stride(to: 1.0, by: 1/ndiv).map { CGPoint(x: a * cos(2 * M_PI * $0), y: b * sin(2 * M_PI * $0)) }
我们通过连接椭圆路径上的点生成了中央的云团。如果没这样操作,中间就会一片空白。
let path = UIBezierPath() path.moveToPoint(points[0]) for point in points[1..
注意不需要精确的路径,因为我们会填充路径,而不是画路径。这意味着这种做法不会区分那些圆。
为了生成圆,我们从随机圆的半径选一个范围。实际上我们在playground中敲入代码调节数值直到我们得到满意的效果。
let minRadius = (Int)(M_PI * a/ndiv) let maxRadius = minRadius + 25 for point in points[0..
你可以点击右边栏目与“path” 语句平行的眼睛图标查看效果。
我们如何栅格化得到最后的结果?我们需要一个所谓的“graphical context”去绘制路径。在我们的例子中,我们会画到一个图像中(UIImage实例)。这时候你需要设置一些绘制最终路径的参数,比如颜色和路径宽度。最后,你会画或者填充你的路径(可能都会)。在我们的例子中,我们希望云朵是白色的,所以我们只想填充白色。
我们把这些代码封装进一个函数,以便我们生成更多我们想要的云朵。说到这里,我们会在蓝色背景(代表天空)上用代码绘制一些随机的云朵,这些功能全部在playground中实时预览。
这是最终代码:
import UIKit import XCPlayground func generateRandomCloud() -> UIImage { func randomInt(lower lower: Int, upper: Int) -> Int { assert(lower < upper) return lower + Int(arc4random_uniform(UInt32(upper - lower))) } func circle(at center: CGPoint, radius: CGFloat) -> UIBezierPath { return UIBezierPath(arcCenter: center, radius: radius, startAngle: 0, endAngle: CGFloat(2 * M_PI), clockwise: true) } let a = Double(randomInt(lower: 70, upper: 100)) let b = Double(randomInt(lower: 10, upper: 35)) let ndiv = 12 as Double let points = (0.0).stride(to: 1.0, by: 1/ndiv).map { CGPoint(x: a * cos(2 * M_PI * $0), y: b * sin(2 * M_PI * $0)) } let path = UIBezierPath() path.moveToPoint(points[0]) for point in points[1..
这就是最终得到的结果:
上面图片的云朵的轮廓有一些模糊,但这就是一个简单的尺寸作品。真正输出的图片是非常锐利的。
为了在你自己的Playground预览效果,确保Assistant Editor是打开状态,从View菜单选择Show Assitant Editor 。
拼图块通常有个正方形的“构架”,每一个边缘都是平坦的,有一个向外突出的圆形标签,或者相同的形状的向内凹陷的标签凹槽,可以从临近的块嵌入突出的标签。下面就是典型的拼图块。
假设你正在开发拼图块app,你想用一块拼图形状的遮罩去分割一整个代表拼图的图形,你可以在app中预先生成光栅遮罩,但是为了适应四个边缘所有可能的形状变化,你需要了解几种变化。
通过矢量图形,你可以生成任意类型的遮罩,另外,这也是使得适应曲线变化变得更容易,比如你想要矩形的或者斜块(而不是正方形块)。
我们如何设计拼图块,也就是说,我们如何通过放置控制点生成像带一点弧度标签的贝塞尔曲线路径?
回顾我之前提过的三次方贝塞尔曲线常用的相切属性,你可以通过绘制想要的近似的形状开始工作,通过预估需要多少三次方段(了解一个三次方段需要作用的不同类型的形状)来分解成多段曲线,然后画出这些曲线段的切线,找出你的控制点。下面这张图表解释了我上面讲的内容。
我决定用四段贝塞尔线段来表示(易拉罐)拉环形,效果应该不错:
两条代表图形两头的直线段部分
两条代表S形状的线段在tab中心表示
我注意到绿色和黄色的虚线相当于S形线段的切线,可以帮我预估在哪里放控制点。我也注意到在赋予每个单元一个长度的时候也就是把切块可视化,也就是为什么全部坐标上的切块都能合而为一的原因了。我可以很轻易地设定曲线长度,比如说,100点那么长(以100点为系数来划分控制点)。矢量图形解决方案的独立性让事情变得不再困难。
最后,我纯粹是为了方便就用了三次方贝塞尔曲线表示直线段,这样代码就能写的更简洁统一。
我为了避免杂乱略过在图标里用控制点来画直线段。当然,代表直线段的三次方贝塞尔曲线端点和控制点都简单地在线段本身上面。
实际上你在playground敲入代码的时候就意味着你能容易地调节控制点的值,来找打你喜欢的形状并且能立马得到反馈。
开始入门。你可以在之前用过的相同的playground中新建一页。从File按钮选择New > Playground Page或者创建新的playground
用下面的代码替换新的一页上所有的代码:
import UIKit let outie_coords: [(x: CGFloat, y: CGFloat)] = [(1.0/9, 0), (2.0/9, 0), (1.0/3, 0), (37.0/60, 0), (1.0/6, 1.0/3), (1.0/2, 1.0/3), (5.0/6, 1.0/3), (23.0/60, 0), (2.0/3, 0), (7.0/9, 0), (8.0/9, 0), (1.0, 0)] let size: CGFloat = 100 let outie_points = outie_coords.map { CGPointApplyAffineTransform(CGPointMake($0.x, $0.y), CGAffineTransformMakeScale(size, size)) } let path = UIBezierPath() path.moveToPoint(CGPointZero) for i in 0.stride(through: outie_points.count - 3, by: 3) { path.addCurveToPoint(outie_points[i+2], controlPoint1: outie_points[i], controlPoint2: outie_points[i+1]) } path
注意,我们决定通过使用缩放变换点来使画100个点长的路径。
我们用”快速查看“功能来看看接下来的结果:
目前看来还不错。我们怎么生成拼图切块的四条边呢?答案(如你所料)就是用几何变形。先旋转90度,然后把以上路径适当转化,我就能很容易地生成其他几条边了。
警告:内部填充问题
不幸的是,这里有一个警告要说。变形并不能自动把各线段连接在一起。尽管我们的拼图切片轮廓看起来还可以,但是它的内部不会填充而且我们把它当蒙版会遇到麻烦。我们可以在playground看到这点。添加以下代码:
let transform = CGAffineTransformTranslate(CGAffineTransformMakeRotation(CGFloat(-M_PI/2)), 0, size) let temppath = path.copy() as! UIBezierPath let foursided = UIBezierPath() for i in 0...3 { temppath.applyTransform(transform) foursided.appendPath(temppath) } foursided
快速查看展示给我们:
注意切块的内部没有阴影就暗示它没有被填充。
你可以在playground检查它的debugDescription属性找到构建一个复合体UIBezierPath的画图命令。
对常见的使用状况来说,在UIBezierPath上几何变形效果是相当不错,例如这个情况,你已经得到一个闭合图形或者你在变形的图形本质上就是开放的,而你想要生成他们的几何变形版本。现在我们的使用状况有点不一样。我们在构建的路径是较大图形的子路径而且我们要填充路径内部。这就有点难办了。
有个办法会把路径内部搞乱(从 the Core Graphics API中使用CGPathApply() 函数)而且要手动把线段连接在一起,最后形成一个独立、封闭,填充合适的图形。
但这个办法感觉有点独创性,所以我选用另一个办法。我们先把点本身用CGPointApplyAffineTransform() 函数进行几何变形,就用我们刚才试图使用的相同的变形。然后,我们用变形的点来创建追加子路径,这个过程会追加到整个图形上。教程的最后,我们会看到一个能正确在贝塞尔路径上应用几何变形的例子。
我们如何生成一个”内凹”的拉环形?我们可以再用一次几何变形,在y抽方向乘以一个负值系数(反转图形),但是我选择简单地手动把这些点的y轴坐标向外翻转。
至于平角拉环形,我没能简单地用一条直线段来代表它,为了避免不得不为特别案例专门写代码的情况,我简单地把外凸点的每点的y轴坐标设定为0。见如下:
let innie_points = outie_points.map { CGPointMake($0.x, -$0.y) } let flat_points = outie_points.map { CGPointMake($0.x, 0) }
作为练习,你可以从这些边上生成贝塞尔曲线并且使用快速查看来看视图。
依我看,现在你已经学的够多,可以突击完整代码,每个单独功能都会通过代码链接到一起。
用以下内容替换playground页面的内容:
import UIKit import XCPlayground enum Edge { case Outie case Innie case Flat } func jigsawPieceMaker(size size: CGFloat, edges: [Edge]) -> UIBezierPath { func incrementalPathBuilder(firstPoint: CGPoint) -> ([CGPoint]) -> UIBezierPath { let path = UIBezierPath() path.moveToPoint(firstPoint) return { points in assert(points.count % 3 == 0) for i in 0.stride(through: points.count - 3, by: 3) { path.addCurveToPoint(points[i+2], controlPoint1: points[i], controlPoint2: points[i+1]) } return path } } let outie_coords: [(x: CGFloat, y: CGFloat)] = [/*(0, 0), */ (1.0/9, 0), (2.0/9, 0), (1.0/3, 0), (37.0/60, 0), (1.0/6, 1.0/3), (1.0/2, 1.0/3), (5.0/6, 1.0/3), (23.0/60, 0), (2.0/3, 0), (7.0/9, 0), (8.0/9, 0), (1.0, 0)] let outie_points = outie_coords.map { CGPointApplyAffineTransform(CGPointMake($0.x, $0.y), CGAffineTransformMakeScale(size, size)) } let innie_points = outie_points.map { CGPointMake($0.x, -$0.y) } let flat_points = outie_points.map { CGPointMake($0.x, 0) } var shapeDict: [Edge: [CGPoint]] = [.Outie: outie_points, .Innie: innie_points, .Flat: flat_points] let transform = CGAffineTransformTranslate(CGAffineTransformMakeRotation(CGFloat(-M_PI/2)), 0, size) let path_builder = incrementalPathBuilder(CGPointZero) var path: UIBezierPath! for edge in edges { path = path_builder(shapeDict[edge]!) for (e, pts) in shapeDict { let tr_pts = pts.map { CGPointApplyAffineTransform($0, transform) } shapeDict[e] = tr_pts } } path.closePath() return path } let piece1 = jigsawPieceMaker(size: 100, edges: [.Innie, .Outie, .Flat, .Innie]) let piece2 = jigsawPieceMaker(size: 100, edges: [.Innie, .Innie, .Innie, .Innie]) piece2.applyTransform(CGAffineTransformMakeRotation(CGFloat(M_PI/3)))
代码里有几点更有意思的事我想要阐明一下:
我们用枚举去定义不同的边缘形状。我们把这些点存在一个词典里,用枚举值作关键词。
我们把子路径用incrementalPathBuilder() 函数拼在一起(由四边拼图切块图形的每边组成),内部是由jigsawPieceMaker(size:edges:) 函数定义。
现在,拼图切块得以适当填充,就如我们在快速查看输出里看到的那样,我们可以安全地调用applyTransform(_:) 方法给图形做几何变形。作为例子,我已经对第二个切块进行了60度旋转。
总结
我希望我已经说服你相信以编程方式生成矢量图形的能力将是你武器库里的实用技能。也希望,你会受到启发(以及写代码)想到其他有意思的矢量图形应用,并在你自己的app里融会贯通。