iOS 绘画学习
本文翻译自:《iOS 7编程》 Matt Neuburg 著,OREILLY出版。
很多UIView的子类,例如UIButton或者UIlabel,都知道如何绘制自己;不过迟早,你都会想绘制一些自己想要的效果。你可以通过一些已有的类在代码中绘制一幅图片,然后在自己的界面上展示出来,例如UIImageVIew和UIButton。单纯一个UIView就是只与绘制有关,它给你了很大的空间来绘画;你的代码决定了这个视图怎么绘制自己,最终怎么在你界面上展示。
UIImage和UIImageView
iOS系统支持很多标准的图片格式:TIFF、JPEG、GIF、PNG等。当一张图片被包含在我们的app 包内,iOS系统特别地,会对PNG文件提供更加友好的支持,不只是因为系统会对它进行压缩处理,还有在不同分辨率下的对图片的选取和展示都做了很多工作,所以我们应该优先选择PNG格式图片。我们可以通过 imageNamed: 这个UIImage类提供的方法获取app包内的图片,这个方法会从两个地方寻找指定的图片:
app包顶级目录
系统会通过提供的图片名字,名字是大小写敏感的,以及包括图片的类型,在app包中寻找。如果没有提供类型,默认是png格式。
Asset catalog 资源目录
它会通过提供的名字,在这个资源目录中寻找匹配的图片集。如果名字带有文件后缀,就不会在这里查找,以便旧代 码中,如果把图片移动到这个目录仍然能够正常工作。这个目录的查找优先级比上面的查找高,也就意味着,如果在这个 资源目录下找到了匹配的图片,方法就会返回,而不会再去app包顶级目录中查找。
可调整大小的Images
可以通过向一个UIImage发送 resizableImageWithCapInsets:resizingMode: 消息,来把图片转换成可调整大小的图片。capInsets参数是一个UIEdgeInsets类型的结构体,由四个浮点型数字组成:top,left,bottom,right。它们代表着从图片边缘向内的距离。在一个比图片要大的上下文中,可调整大小的Image有两种工作模式,通过 resizingMode: value: 指定
UIImageResizingModeTile
在上面capInsets 指定的内部矩形区域会平铺在内部,每一个边缘由对应边的矩形区域平铺而成,而外面的四个角落的矩形不变。
UIImageResizingModeStretch
内部的矩形会被拉伸一次来填充内部,每个边缘由对应变的矩形区域拉伸而成,而外面的四个角落的矩形不变。
例如:假设 self.iv 是一个有固定长宽的UIImageView,contentMode是UIViewContentModeScaleToFill。
(1)设置capInsets 为 UIEdgeInsetsZero
1
2
3
4 |
UIImage* mars = [UIImage imageNamed:@ "Mars" ]; UIImage* marsTiled = [mars resizableImageWithCapInsets: UIEdgeInsetsZero resizingMode: UIImageResizingModeTile]; self .iv.image = marsTiled; |
(2)
1
2
3
4
5
6 |
UIImage* marsTiled = [mars resizableImageWithCapInsets: UIEdgeInsetsMake(mars.size.height/4.0, mars.size.width/4.0, mars.size.height/4.0, mars.size.width/4.0) resizingMode: UIImageResizingModeTile]; |
(3)常用的拉伸策略是把几乎是原始图片的一半作为capinset,仅仅在中间留出1到2像素来填充整个内部。
1
2
3
4
5
6 |
UIImage* marsTiled = [mars resizableImageWithCapInsets: UIEdgeInsetsMake(mars.size.height/2.0 - 1, mars.size.width/2.0 - 1, mars.size.height/2.0 - 1, mars.size.width/2.0 - 1) resizingMode: UIImageResizingModeStretch]; |
在最新的Xcode5 中,我们可以不用代码来配置一个可调整大小的图片,仅仅通过Xcode5提供的一个 asset catalogs 功能,而不用多次编写同样的代码,这个功能仅在ios7.0以上版本可用。
图片的渲染模式
1
2
3
4
5
6 |
- ( void ) drawRect: (CGRect) rect { UIBezierPath* p = [UIBezierPath bezierPathWithOvalInRect:CGRectMake(0,0,100,100)]; [[UIColor blueColor] setFill]; [p fill]; } |
现在我用Core Graphics 实现同样的效果;这样需要我首先拿到一个当前上下文的引用:
1
2
3
4
5
6 |
- ( void ) drawRect: (CGRect) rect { CGContextRef con = UIGraphicsGetCurrentContext(); CGContextAddEllipseInRect(con, CGRectMake(0,0,100,100)); CGContextSetFillColorWithColor(con, [UIColor blueColor].CGColor); CGContextFillPath(con); } |
接下来,我会在UIView子类中实现 drawLayer:inContext:。这种情况下,我们手中的上下文引用并不是当前上下文,所以我需要用UIKit把它转换成当前上下文:
1
2
3
4
5
6
7
8 |
- ( void )drawLayer:(CALayer*)lay inContext:(CGContextRef)con { UIGraphicsPushContext(con); UIBezierPath* p = [UIBezierPath bezierPathWithOvalInRect:CGRectMake(0,0,100,100)]; [[UIColor blueColor] setFill]; [p fill]; UIGraphicsPopContext(); } |
为了在drawLayer:inContext:中使用Core Graphics,我仅仅需要简单地保留一个我持有的上下文即可:
1
2
3
4
5 |
- ( void )drawLayer:(CALayer*)lay inContext:(CGContextRef)con { CGContextAddEllipseInRect(con, CGRectMake(0,0,100,100)); CGContextSetFillColorWithColor(con, [UIColor blueColor].CGColor); CGContextFillPath(con); } |
最后,为了完整性,让我们创建一个蓝色圆形的UIImage对象。我们可以在任何时间(我们不需要等待某些特定方法被调用)以及在任何类(我们不需要在UIView的子类)中创建。创建的UIImage你可以在任何地方正常使用,例如,你可以把它放到一个可见的UIImageView中当做图片展示,或者你可以把它保存在一个文件中,或者你可以在其他的绘制中使用(下一节介绍)。
首先,我使用UIKit绘制我的图片:
1
2
3
4
5
6
7
8 |
UIGraphicsBeginImageContextWithOptions(CGSizeMake(100,100), NO , 0); UIBezierPath* p = [UIBezierPath bezierPathWithOvalInRect:CGRectMake(0,0,100,100)]; [[UIColor blueColor] setFill]; [p fill]; UIImage* im = UIGraphicsGetImageFromCurrentImageContext(); UIGraphicsEndImageContext(); // im is the blue circle image, do something with it here ... |
下面是使用Core Graphics实现的:
1
2
3
4
5
6
7 |
UIGraphicsBeginImageContextWithOptions(CGSizeMake(100,100), NO , 0); CGContextRef con = UIGraphicsGetCurrentContext(); CGContextAddEllipseInRect(con, CGRectMake(0,0,100,100)); CGContextSetFillColorWithColor(con, [UIColor blueColor].CGColor); CGContextFillPath(con); UIImage* im = UIGraphicsGetImageFromCurrentImageContext(); UIGraphicsEndImageContext(); // im is the blue circle image, do something with it here ... |
你可能会对UIGraphicsBeginImageContextWithOptions这个方法的参数感到疑惑,其实第一个参数显然是将要创建的图片的大小。第二个参数表明这个图片是否是不透明的,如果我在上面的方法中传递YES而是不是NO,我的图片将会有一个黑色背景,而我不想要这种效果。第三个参数指定图片的缩放比例,传递0是告诉系统根据当前屏幕的尺寸为我自动设置压缩比例,这样我的图片就会在单分辨率和双分辨率屏幕下都能完美显示。
你不必完全使用UIKit或者Core Graphics,相反地,你可以混合UIKit 调用和Core Graphics调用来操作同样的图形上下文。它们仅仅只是表示两种不同的方式对同样的图形上下文通信而已。
CGImage绘画
UIImage在Core Graphics中的版本是CGImage(实际上是CGImageRef)。它们可以很容易地互相转换:UIImage有一个CGImage的属性,可以访问它的Quartz 的图片数据,你也可以把CGImage 转换成UIImage,使用imageWithCGImage:或者initWithCGImage:(在实战中,你会更偏向使用更加可配置性的姐妹方法:imageWithCGImage:scale:orientation: 以及 initWithCGImage:scale:orientation:)。
一个CGImage可以让你从一个原始图片的一个矩形区域中创建一个新的图片,而UIImage是做不到的。(一个CGImage还有其他强大的功能而UIImage没有的,例如你可以将图片的遮罩应用到CGImage中)。我将会通过分隔一张火星图片为两半,并分开单独绘制每一边。
注意,我们现在是在CFTypeRef范围下操作,必须自动管理好内容:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18 |
UIImage* mars = [UIImage imageNamed:@ "Mars" ]; // extract each half as a CGImage CGSize sz = mars.size; CGImageRef marsLeft = CGImageCreateWithImageInRect([mars CGImage], CGRectMake(0,0,sz.width/2.0,sz.height)); CGImageRef marsRight = CGImageCreateWithImageInRect([mars CGImage], CGRectMake(sz.width/2.0,0,sz.width/2.0,sz.height)); // draw each CGImage into an image context UIGraphicsBeginImageContextWithOptions( CGSizeMake(sz.width*1.5, sz.height), NO , 0); CGContextRef con = UIGraphicsGetCurrentContext(); CGContextDrawImage(con, CGRectMake(0,0,sz.width/2.0,sz.height), marsLeft); CGContextDrawImage(con, CGRectMake(sz.width,0,sz.width/2.0,sz.height), marsRight); UIImage* im = UIGraphicsGetImageFromCurrentImageContext(); UIGraphicsEndImageContext(); CGImageRelease(marsLeft); CGImageRelease(marsRight) |
但是这里的例子有个问题:绘制的东西上下颠倒了! 它不是被旋转了,而是从上到下映射,或者用专业的术语,翻转。这种想象会发生在你创建了一个CGImage,然后通过CGContextDrawImage绘制时,是由于源和目标上下文的本地坐标系统不匹配。
有多种的方式补偿这种不同坐标系统之间的不匹配。其中一种就是把CGImage绘制成一个中间的UIImage,然后从UIImage中获取CGImage,下面展示一个通用的函数来实现这种转换:
1
2
3
4
5
6
7
8
9
10 |
// Utility for flipping an image drawing CGImageRef flip (CGImageRef im) { CGSize sz = CGSizeMake(CGImageGetWidth(im), CGImageGetHeight(im)); UIGraphicsBeginImageContextWithOptions(sz, NO , 0); CGContextDrawImage(UIGraphicsGetCurrentContext(), CGRectMake(0, 0, sz.width, sz.height), im); CGImageRef result = [UIGraphicsGetImageFromCurrentImageContext() CGImage]; UIGraphicsEndImageContext(); return
result; } |
我们可以使用这个工具函数来修复我们上面例子中调用CGContextDrawImage产生的问题,让它们正确画出火星的一半。
1
2
3
4 |
CGContextDrawImage(con, CGRectMake(0,0,sz.width/2.0,sz.height), flip(marsLeft)); CGContextDrawImage(con, CGRectMake(sz.width,0,sz.width/2.0,sz.height), flip(marsRight)); |
但是,我们仍然有一个问题:在双分辨率设备上,如果我们的图片有一个双分辨率的版本(@2x.png),这个绘制就会出错。原因就是我们使用 imageNamed:来获取原始的火星图片,这样就会返回一个为了适配双分辨率而设置自己的缩放比例来产生双倍分辨率的图片。但是CGImage没有scale属性,同时对这张图片为原始分辨率两倍一无所知!因此,我们在双分辨率设备上,我们通过调用 [mars CGImage]获得到的火星CGImage图片,是火星图片大小的两倍,那么我们所有的计算都是错的。
所以,为了在CGImage提取想要的片,我们必须把所有适当的值乘以缩放比例scale,或者以CGImage的尺寸来描述大小。下面是我们在单分屏和双分屏都正确绘制的一个代码版本,并且补偿了翻转效果:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23 |
UIImage* mars = [UIImage imageNamed:@ "Mars" ]; CGSize sz = mars.size; // Derive CGImage and use its dimensions to extract its halves CGImageRef marsCG = [mars CGImage]; CGSize szCG = CGSizeMake(CGImageGetWidth(marsCG), CGImageGetHeight(marsCG)); CGImageRef marsLeft = CGImageCreateWithImageInRect( marsCG, CGRectMake(0,0,szCG.width/2.0,szCG.height)); CGImageRef marsRight = CGImageCreateWithImageInRect( marsCG, CGRectMake(szCG.width/2.0,0,szCG.width/2.0,szCG.height)); UIGraphicsBeginImageContextWithOptions( CGSizeMake(sz.width*1.5, sz.height), NO , 0); // The rest is as before, calling flip() to compensate for flipping CGContextRef con = UIGraphicsGetCurrentContext(); CGContextDrawImage(con, CGRectMake(0,0,sz.width/2.0,sz.height), flip(marsLeft)); CGContextDrawImage(con, CGRectMake(sz.width,0,sz.width/2.0,sz.height), flip(marsRight)); UIImage* im = UIGraphicsGetImageFromCurrentImageContext(); UIGraphicsEndImageContext(); CGImageRelease(marsLeft); CGImageRelease(marsRight); |
另一种方案就是:在UIImage里面包装一个CGImage,绘制这个UIImage。UIImage可以通过调用 imageWithCGImage:scale:orientation:来实现这种方式,补偿缩放带来的影响。此外,通过绘制一个UIImage,而不是一个的CGImage,我们避免了翻转问题。下面是一种同时处理翻转和缩放的方法(没有调用我们上面的公用类):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25 |
UIImage* mars = [UIImage imageNamed:@ "Mars" ]; CGSize sz = mars.size; // Derive CGImage and use its dimensions to extract its halves CGImageRef marsCG = [mars CGImage]; CGSize szCG = CGSizeMake(CGImageGetWidth(marsCG), CGImageGetHeight(marsCG)); CGImageRef marsLeft = CGImageCreateWithImageInRect( marsCG, CGRectMake(0,0,szCG.width/2.0,szCG.height)); CGImageRef marsRight = CGImageCreateWithImageInRect( marsCG, CGRectMake(szCG.width/2.0,0,szCG.width/2.0,szCG.height)); UIGraphicsBeginImageContextWithOptions( CGSizeMake(sz.width*1.5, sz.height), NO , 0); [[UIImage imageWithCGImage:marsLeft scale:mars.scale orientation:UIImageOrientationUp] drawAtPoint:CGPointMake(0,0)]; [[UIImage imageWithCGImage:marsRight scale:mars.scale orientation:UIImageOrientationUp] drawAtPoint:CGPointMake(sz.width,0)]; UIImage* im = UIGraphicsGetImageFromCurrentImageContext(); UIGraphicsEndImageContext(); CGImageRelease(marsLeft); CGImageRelease(marsRight); |
是的,另一种方案解决翻转,就是在绘制CGImage之前,对图形上下文进行线性转换,有效地翻转图形上下文中内部的坐标系统。这种方式很简洁,但是当有其他的线性转换时会变得难以理解。我会在下面的章节中谈论更多图形上下文转换的内容。
为什么会发生翻转??
Core Graphics 会意外发生翻转的历史,来源于OS X世界,OS X 里的坐标系统的原点默认是在左下角,正Y方向是向上的,而在iOS中,坐标原点默认在左上角,正Y方向是向下的。在大多数的绘画中没有问题,因为图形上下文的坐标系统会自动适应的。另外,在iOS的Core Graphics框架中的上下文绘画时,上下文的坐标系统原点是左上角,我们都知道,但是,创建和绘制CGImage在两个坐标系统之间,互不匹配。