一图胜千言,一接口胜千图。——Ben Shneiderman
第1章“图层树”介绍了CALayer
类并创建了一个简单有蓝色背景的图层。背景色非常棒,但如果图层只能显示一个颜色将会显得平庸无常。CALayer
可以容纳任何你喜欢的图片。这一章将讲述CALayer
的主图像。
内容图像
CALayer
有一个叫contents
的属性,这个属性被定义为id
类型[1],意味它可以作为任意类的对象。这是正确的,这意味着你可以给contents
属性定义为任何你喜欢的对象,而你的应用程序仍可以编译成功——然而,实际上如果你提供了一个不是CGImage
的对象,你的图层将变为一片空白。
contents
属性的这一奇怪特性是由于Core Animation
是继承于Mac OS的。在Mac OS上contents
被定义为id
的原因是你可以给这一属性赋值为CGImage
或者NSImage
而它会自动生效。如果你尝试在iOS上赋值一个UIImage
,你就只会得到一个空白的图层。这对于新接触Core Animation
的iOS开发者来说是一个普遍的困惑之处。
头疼之处不仅如此。事实上,你真正所需要提供的类型是CGImageRef
,这是一个指向CGImage
结构体的指针。UIImage
有一个CGImage
属性会返回CGImageRef
。如果你尝试直接赋值CALayer content
属性,会无法通过编译。这是因为CGImageRef
并不是一个真正的Cocoa
对象,它是一个Core Foundation类型
。
尽管Core Foundation
类型运行时表现得如同是Cocoa
对象(被称为toll-free bridging),除非你使用一个bridged转换,否则它们并不等同于id
。[2]赋值一个图层的图像只需要这样做:
layer.contents = (__bridge id)image.CGImage;
如果你不使用ARC(自动引用计数),你不需要加上bridge
,但是你为什么不用ARC?!
让我们修改在第1章中创建的项目来显示一张图像而非一个背景颜色。既然我们可以程序化地创建图层而我们不再需要额外的图层,所以我们直接将layerView
中的contents
属性直接设置为图像[3]。
表2.1显示了升级后的代码,图2.1显示了结果。
表2.1 设置CGImage作为图层contents
override func viewDidLoad() {
super.viewDidLoad()
// 加载图像
let image = UIImage(named: "Snowman.png")!
// 直接添加到我们的视图图层中
self.layerView.layer.contents = image.CGImage
}
图2.1 一张显示在UIView主图层中的图像
这是非常简单的代码,但我们已经在这做了一些相当有趣的事情:使用CALayer
的在一个普通的UIView
中显示一个图像。这不是一个UIImageView
,并不是正常设计来显示图像的。但通过直接操作图层,我们发现了新的方法并使得我们平庸的UIView
有趣了一点。
contentsGravity
你可能注意到我们的雪人看起来有一点...胖。我们加载的图片并不是一个准确的正方形,但它被拉伸以适应这个视图。你可能在使用UIImageView
中碰见过相似的情形,解决方法是给这个视图设置更为合适contentMode
属性。就像这样:
view.contentMode = UIViewContentMode.ScaleAspectFit
这个方法效果不错(自己试一下),但大多UIView
的视觉属性,例如contentMode
就是直接操作底层图层的相应属性。
CALayer
中的相应属性叫contentsGravity
,而且它是一个NSString
而不是像UIKit中是一个enum
。这个contentsGravity
字符串应该被设为如下几个常量:
- kCAGravityCenter
- kCAGravityTop
- kCAGravityBottom
- kCAGravityLeft
- kCAGravityRight
- kCAGravityTopLeft
- kCAGravityTopRight
- kCAGravityBottomLeft
- kCAGravityBottomRight
- kCAGravityResize
- kCAGravityResizeAspect
- kCAGravityResizeAspectFill
正如contentMode
一样,contentsGravity
的目的在于确定内容应该怎么和图层边界对齐。我们将使用kCAGravityResizeAspect
这等同于UIViewContentMode.ScaleAspectFit
,它可以缩放图像使其适合图层的边界而不会改变它的比例:
self.layerView.layer.contentsGravity = kCAGravityResizeAspect
图2.2显示这个结果。
图2.2 用正确的contentsGravity显示的雪人图像
contentsScale
contentsScale
属性定义了图层主图像的像素尺寸和视图大小的比例。它是一个默认为1.0的浮点数。
contentsScale
属性的目的并不是很直接,它并不是总能够影响到屏幕上的主图像的缩放;如果你尝试在我们的雪人例子中将它设置为不同的值,你会发现它并没有任何效果,因为contents
图像早已经通过contentsGravity
属性缩放成适合图层边界。
如果你想简单的缩放图层的contents
图像,你可以使用图层的transform
或是affineTransform
属性(看第5章“变形”,用于于解释变形),但这并不是`contentsScale的目的。
contentsScale
属性实际上是用来支持高分辨率(也被称为Hi-DPI或者Retina)屏幕实现机制的一部分。它被用于确认主图像的大小,当绘制时这个图层应当自动创建,以及contents
图像应该被显示的缩放尺寸(假设这还没有被contentsGravity
设置缩放)。UIView
有一个相同但很少使用的属性叫作contentScaleFactor
。
如果contentsScale
被设为1.0,将以每点1像素的分辨率绘制。如果被设为2.0,将以没电2像素的分辨率绘制,也被称作Retina分辨率。(如果你不是很清楚像素和点之间的差异,稍后将解释。)
这与使用kCAGravityResizeAspect
并不会有任何不同,因为无论什么分辨率,它将缩放图像以适应图层。但如果我们将我们的contentsGravity
设为kCAGravityCenter
(这个并不会缩放图像),这之间的差异会更加明显(见图2.3)。
正如你所见,我们的雪人非常大而且像素化。这是因为CGImage
(不像UIImage
)一样并没有缩放的内部概念。当我们用UIImage
类来加载我们的雪人图像,它会正确地加载为高质量的Retina版本。但当我们用CGImage
作为我们的图层contents
的图像,缩放因子在变形中丢失。我们可以通过手动的设置contentsScale
来修复它,这样可以匹配_UIImage scale
属性(见表2.2)。图2.4显示了结果。
override func viewDidLoad() {
super.viewDidLoad()
// 加载图像
let image = UIImage(named: "Snowman.png")!
// 直接添加到我们的视图图层中
self.layerView.layer.contents = image.CGImage
// 将图片放在正中间
self.layerView.layer.contentsGravity = kCAGravityCenter
// 改变contentsScale来适应图像的缩放
self.layerView.layer.contentsScale = image.scale
}
图2.4 显示同一张设置了正确contentsScale的Retina图像
当程序化生成主图像时,你应当时常记得手动设置图层的contentsScale
来匹配屏幕缩放;否则,你的图像将会在Retina设备上显示成像素化。你可以像这样做:
layer.contentsScale = UIScreen.mainScreen().scale
masksToBounds
既然我们的雪人已经按正常的大小显示,你可能注意到了其它东西——他沿伸到视图的边界之外。默认情况下,UIView
将会很开心地绘制它指定边界之外的内容和子视图,而这同样适用于CALayer
。
在UIView
中有一个属性叫做clipsToBounds
用来开关裁剪(这是用来控制是否一个视图的内容可以蔓延到它们的帧之外)。CALayer
有一个相同的属性叫做masksToBounds
。当设为真
时,我们可以限定我们的雪人在视图之内(见图2.5)。
contentsRect
CALayer
中的contentsRect
属性允许我们指定一个子矩形来在图层帧中显示主图像。这比contentsGravity
提供更多的自由性来决定图像是如何被裁剪或拉伸。
而与bounds
以及frame
不同的是contentsRect
并不是以点度量的;它使用单元坐标。单元坐标被限定为0到1之间,是相对值(不同于点和像素等绝对值)。在这个侄子中,它们相对于主图像的尺寸。下面这些坐标类型被用于iOS:
- 点——在iOS和Mac OS最广泛使用的坐标类型。点是虚拟的像素,也被称作逻辑像素。在标准定义的设备上,一点相当于一个像素,但在Retina设备上,一点相当于2*2个物理像素。iOS在所有的屏幕坐标度量中使用点来使得布局可以无缝地使用于Retina和非Retina屏的设备上。
-
像素——物理像素坐标并不用于屏幕布局,但它们通常与图像处理相关。
UIImage
是屏幕分辨相关的,并且以点来限定大小,但有些底层图像显示例如CGImage
使用像素尺寸,因此你应当记住它们的状态尺寸并不会匹配它们在一个Retina设备上的显示尺寸。 -
单元——单元坐标是一个用来确切度量相对的图像大小或图层边界的简便方法,因此当大小改变时不需要调整。单元坐标在OpenGL广泛用于纹理坐标等,它们也常常用于
Core Animation
中。
默认的contentsRect
是{0, 0, 1, 1}
,这意味着整个主图像是默认可见的,如果我们指定一个小一点的矩形,这个图像会被裁剪(如图2.6)。
将contentsRect
指定为一个负数或者大于{1,1}
的尺寸是被允许的。在这种情况下,图像最外的像素会被拉伸来填充指定区域。
contentsRect
的一个有趣之处在于它可以被用于叫做图像精灵的东西。如果你曾做过任何游戏编程,你会对精灵的概念感到熟悉。这其实是一种可以独立在屏幕到处移动的图像。但在游戏世界之外,这个术语通常用于指代一种用于加载精灵图像的通用技术,而不是使一切事物动起来。
通常,许多精灵会被打包成一张大图片来一次性加载。这比使用多张独立的照片在内存使用、加载时间和渲染表现上等上更多的优点。
精灵被用于2D游戏引擎如Cocos2d,在其中用OpenGL来显示图像。但我们可以通过借助contentsRect
的力量在一个普通的UIKit应用程序中使用精灵。
在开始前,我们需要一个精灵图集[4]——一张包含我们小一点的精灵图像的大图像。图2.7展示了一个精灵图集的案例。
图2.7 一个精灵图集接下来,我们要在我们的应用中加载并显示这些精灵。原则十分简单:我们正常加载我们的大图像,将它赋值给四个分离图层的contents
(每一个对应一个精灵),然后设置它们每个的contentsRect
来遮盖我们不需要的部分。
我们需要在我们的项目中为我们的精灵图层增加一些额外的视图。(这些视图是用Interface Builder定位的,这样可以防止产生杂乱的代码,但如果你喜欢也可以用程序化的方式创建它们。)表2.3展示了代码,图2.8展示了最终的结果。
表2.3 使用contentsRect切割精灵图集
import UIKit
class ViewController: UIViewController {
@IBOutlet weak var coneView: UIView!
@IBOutlet weak var shipView: UIView!
@IBOutlet weak var iglooView: UIView!
@IBOutlet weak var anchorView: UIView!
func addSpriteImage(image: UIImage, withContentRect rect: CGRect, toLayer layer: CALayer) {
// 设置图像
layer.contents = image.CGImage
// 缩放内容以适应
layer.contentsGravity = kCAGravityResizeAspectFill
// 设置内容矩形
layer.contentsRect = rect
}
override func viewDidLoad() {
super.viewDidLoad()
// 加载精灵图集
let image = UIImage(named: "Sprites.png")!
// 设置冰屋
self.addSpriteImage(image, withContentRect: CGRectMake(0, 0, 0.5, 0.5), toLayer: self.iglooView.layer)
// 设置圆锥
self.addSpriteImage(image, withContentRect: CGRectMake(0.5, 0, 0.5, 0.5), toLayer: self.coneView.layer)
// 设置锚
self.addSpriteImage(image, withContentRect: CGRectMake(0, 0.5, 0.5, 0.5), toLayer: self.anchorView.layer)
// 设置飞船
self.addSpriteImage(image, withContentRect: CGRectMake(0.5, 0.5, 0.5, 0.5), toLayer: self.shipView.layer)
}
}
图2.8 四个精灵,随机分布在屏幕上
精灵图集是一个减少应用大小和优化加载表现(一张大图比多张小图压缩更好且加载更快)的简洁的方式,但人工设置它们会比较笨拙,当精灵图集创建完成之后,增加新精灵或修改某个已有精灵的尺寸一直是一个大问题。
一些商业应用可以在你的Mac上自动创建精灵图集。这些工具通过生成XML或Plist文件来存储精灵坐标来简化精灵的使用。这些文件可以随图像一起加载并用于给每个精灵设置contentsRect
,而非让开发者们不得不去手工用代码在应用中布置它们。
这些文件通常被设计为应用于OpenGL的游戏中,但如果你对在一个常规的应用中使用精灵图集,这个开源图层精灵库可以用流行的Cocos2d
格式读入精灵图集并用正常的Core Animation
图层显示它们。
contentsCenter
这一章我们讲解的最后一个内容相关的属性是contentsCenter
。你可能根据名字推测contentsCenter
这一属性将会与contents
图像的位置有关,但事实上这个名字是有误导性的。contentsCenter
实际上是一个定义图层可拉伸区域和一个边缘固定边框的CGRect
。改变contentsCenter
对于主图像如何显示毫无影响,除非图层被重新定义大小,那么它的目的就变得清晰了。默认情况下contentsCenter
被设为{0, 0, 1, 1}
,这意味着当图层被重新定义大小时(由contentsGravity
决定)主图像将会均匀拉伸。但如果我们增加开始的值并减少大小,我们可以在图像四周创建边框。图2.9展示了{0.25, 0.25, 0.5, 0.5}
这组数的缩放效果的。
这意味着当我们直接重新调整视图大小的时候,边框将保持一致(如图2.10)。这与使用UIImage
的-resizableImageWithCapInsets:
方法相似,但可以应用于任一图层的主图像,甚至包换在运行时用Core Graphics
绘制的图层(正如这章后面将讲述的一样)。[5]
表2.4展示了用于程序化设置这些可拉伸图像的代码。然而,contentsCenter
另一个很棒的额外特性是,它可以不用写任何代码,而在Interface Builder的检查器窗口中使用拉伸控制来设置,正如图2.11所示。
表2.4 使用contentsCenter设置可拉伸视图
import UIKit
class ViewController: UIViewController {
@IBOutlet weak var button1: UIView!
@IBOutlet weak var button2: UIView!
func addStretchableImage(image: UIImage, withContentCenter rect: CGRect, toLayer layer: CALayer) {
// 设置图像
layer.contents = image.CGImage
// 设置内容中心
layer.contentsCenter = rect
}
override func viewDidLoad() {
super.viewDidLoad()
// 加载按钮图像
let image = UIImage(named: "Button.png")!
// 设置button1
self.addStretchableImage(image, withContentCenter: CGRectMake(0.25, 0.25, 0.5, 0.5), toLayer: self.button1.layer)
// 设置button2
self.addStretchableImage(image, withContentCenter: CGRectMake(0.25, 0.25, 0.5, 0.5), toLayer: self.button2.layer)
}
}
图2.11 Interface Builder检查器中contentsCenter的控制(最下方的Stretching)
自定义绘图
用一张CGImage
设置图层的contents
并不是唯一设置主图像的方法。你也可以通过使用Core Graphics
来直接绘制主图像。-drawRect:
方法可以在一个UIView
的子类中实现自定义绘图。
-drawRect:
方法没有默认的实现,这是因为如果仅是用一个纯色填充或着底图层的contents
属性包含一个已存在的图像实例,UIView
并不需要一个自定义的主图像。如果UIView
检测到-drawRect:
方法出现,它将为这个视图分配一个新的主图像,其像素大小等同于视图大小乘以contentsScale
。
如果你不需要这个主图像,创建它将浪费内存和CPU。这就是为什么Apple建议你,如果不是想自定义一些绘制的时候,不要在你的图层子类中存在一个空的-drawRect:
方法。
-drawRect:
方法会在视图第一次出现在屏幕上时自动执行。-drawRect:
方法中的代码会使用Core Graphics
来绘制主图像,其结果在被缓存直至视图需要更新(通常因为开发者调用了-setNeedsDisplay
方法,然而某些视图类型在当一个会影响它们显示的属性被改变时会自动重绘[如bounds
])。尽管-drawRect:
是一个UIView
的方法,它实际上是在CALayer
下安排绘制并存储结果图像的。
CALayer
有一个可选的遵守CALayerDelegate
协议的delegate
属性。当CALayer
需要特定内容的信息时,它将向这个delegate
中请求相关信息。CALayerDelegate
是一个非正式的协议,这是说明事实上并没有CALayerDelegate @protocol
可以让你在类接口的引用。你只需要加上你需要的方法,而CALayer
会在出现时调用它们。(这个delegate
属性被声明为id
并且所有的委托方法被当作可选的对待。)
当它需要被重绘时,CALayer
会让它的委托提供一个主图像用于显示。为这实现这一功能它会尝试调用下面这个方法:
objc:
- (void)displayLayer: (CALayer nonnull *)layer;
swift:
func displayLayer(_ layer: CALayer)
这个时候委托可以直接设置图层的contents
,之后不会再有其它方法被调用。如果委托没有实现-displayLayer:
方法,CALayer
会尝试调用如上方法来代替:
objc:
- (void)drawLayer: (CALayer * nonnull)layer inContext: (CGContextRef nonnull)ctx
swift:
func drawLayer(_ layer: CALayer, inContext ctx: CGContext)
在调用这一方法前,CALayer
会创建一个合适大小(基于图层的bounds
以及contentsScale
)的空主图像和一个合适的Core Graphics
绘制上下文来绘制这个图像,这就是它传递的ctx
参数。
让我们修改第1章的测试程序来实现CALayerDelegate
协议,然后绘制一些图像(如表2.5)。图2.12展示了结果。
表2.5 实现CALayerDelegate
import UIKit
class ViewController: UIViewController {
@IBOutlet weak var layerView: UIView!
override func viewDidLoad() {
super.viewDidLoad()
// 创建子图层
let blueLayer = CALayer()
blueLayer.frame = CGRectMake(50.0, 50.0, 100.0, 100.0)
blueLayer.backgroundColor = UIColor.blueColor().CGColor
// 设置当前控制器作为图层代理
blueLayer.delegate = self
// 确保图层的主图像使用正确的缩放
blueLayer.contentsScale = UIScreen.mainScreen().scale
// 将图层加入视图
self.layerView.layer.addSublayer(blueLayer)
// 强制图层重新绘制
blueLayer.display()
}
override func drawLayer(layer: CALayer!, inContext ctx: CGContext!) {
// 绘制一个粗的红色圆圈
CGContextSetLineWidth(ctx, 10.0)
CGContextSetStrokeColorWithColor(ctx, UIColor.redColor().CGColor)
CGContextStrokeEllipseInRect(ctx, layer.bounds)
}
}
图2.12 使用CALayerDelegate绘制主图像的图层
一些有趣的注意事项:
- 我们不得不手动调用
blueLayer
的-display
方法来强制其重新绘制。不同于UIView
,CALayer
在显示在屏幕上时并不会自动重新绘制里面的内容;它留给开发者去谨慎决定是否图层需要重新绘制。 - 即使我们没有允许
masksToBounds
属性,我们绘制的圆也会被裁剪成图层的bounds
。这是因为当你用CALayerDelegate
绘制主图像时,CALayer
会创建一个与图层尺寸一样大小的上下文。并不存在可以画出边界的选择。
到现在你应该理解了CALayerDelegate
并知道如何去使用它。但除非你在创建独立的图层,你几乎不会需要去实现CALayerDelegate
协议。其原因在于当UIView
创建主图像时,它会自动将自己设为图层的delegate
并提供-displayLayer:
抽象问题的一种实现。
当使用基于视图的图层时,你并不需要去实现-displayLayer:
或者-drawLayer:inContext:
来绘制你图层的主图像;你可以直接用正常方式实现UIView
的-drawRect:
方法,而UIView
将会管理一切,这包括当图层需要被重新绘制时自动调用其中的-display
方法。
总结
这一章节讲述了图层的主图像以及它的相关属性。你学习了如何移动和裁剪图像、从一个精灵图集中裁剪出独立的图像以及在使用CALayerDelegate
和Core Graphics
时绘制图层内容。
在第3章“图层几何”中,我们将查看一个图层的几何并测试相关图层之间如何重新设置位置和大小。
网友评论