让来这的人对几何不再一无所知。——Plato's Academy入口的刻字
第2章“主图像”介绍了基于图层的图像以及在图层边界中控制它位置和缩放的相关属性。在这一章,我们将研究图层相对于父图层以及兄弟图层的位置和大小变化。我们也会讲述如何管理你的图层的几何以及自动尺寸和自动大小会产生什么影响。
布局
UIView
有三个主要的布局属性:frame
,bounds
以及center
,对应CALayer
中的frame
,bounds
和position
。为什么图层使用position
而视图使用center
马上会讲解清楚,但它们代表着同样的值。
frame
表示图层的外坐标(即它在父图层中占用的空间),bounds
属性表示内坐标(使用{0, 0}
通常等于图层的左上角,但这并不总是这样),而center
和position
同样表示anchorPoint
相对于父图层的位置。anchorPoint
稍后将会解释,在这就先将它理解成图层的中心。图3.1展示了这样属性之间的相关性。
视图的frmae
、bounds
以及center
属性实际上是相应的底图层的存取器(setter
和getter
方法)。当你手动修改视图的frame
时,你实际上是在修改其下CALayer
的frame
。你无法抛开它的图层而单独修改视图的frame
。
frame
其实并不是视图或图层中真正的值;它是一个通过计算bounds
、position
以及transform
得到的虚拟值,因此会随这些值的改变而改变。而改变frame
也会影响到这些值中的某些或全部。
在你开始使用变形前你应该牢记这点,因为当一个图层旋转或缩放,它的frame
反应被变形图层在父图层中所占用的矩形区域在总轴的映射,这意味着frame
的宽和高不再匹配bounds
(见图3.2)。
anchorPoint
正如先前提及的,视图的center
属性以及图层的position
属性指定了图层相对于其父图层的anchorPoint
的位置。图层的anchorPoint
属性控制图层的frame
相对于其position
属性的位置。你可以把anchorPoint
当作四处移动图层的把手。
默认情况下,anchorPoint
位于图层中心,这样无论图层在哪都会在其位置上居中。anchorPoint
并不在UIView
类接口中显露,这就是为什么视图的位置属性被叫做“中心”。但图层的anchorPoint
可以移动,例如你可以把它置于图层frame
的左上角,然后图层的内容会向它position
的右下角扩展(如图3.3)而不是以其为中心。
像第2章中介绍的contentsRect
和contentsCenter
属性一样,anchorPoint
采用单元坐标,这意味着它的坐标是相对于它图层的尺寸而言。图层的左上角是{0, 0}
,右下角是{1, 1}
,因此默认(中心)位置是{0.5, 0.5}
。anchorPoint
可以通过指x或y的值小于0或大于1来使其被放置在图层边界之外。
那么为什么我们会想要改变anchorPoint
?我们本来就可以将帧放在任何位置,那改变anchorPoint
只是为了制造疑惑吗?为了解释这个为什么有用,让我们一起做个有用的例子。让我们通过移动时针、分针和秒针模拟一个时钟。
表盘和指针用四副图像(如图3.4)组成。为了简单起见,我们将用传统方法显示并加载这些图像[1],我们使用四个独立的UIImageView
实例(尽管我们也可以使用正常的视图并设置它们的主图层的contents图像)。
时钟组件在Interface Builder中是这样排列的(见图3.5)。图像视图被置于另一个容器视图中,并且禁用所有的自动尺寸和自动布局。这是因为自动尺寸作用于视图的frame
,正如图3.2所示,frame
在视图旋转时会改变,如果旋转视图的frame
是自动尺寸的会导致布局失效。
我们会使用一个NSTimer
来更新时钟,并使用视图的transform
属性来旋转指针。(如果你对这个属性不熟悉,不要担心,我们将在第5章“变形”中讲解。)表3.1展示了我们时钟的代码。
表3.1 时钟
import UIKit
class ViewController: UIViewController {
@IBOutlet weak var hourHand: UIImageView!
@IBOutlet weak var minuteHand: UIImageView!
@IBOutlet weak var secondHand: UIImageView!
var timer: NSTimer!
override func viewDidLoad() {
super.viewDidLoad()
// 启动计时器
self.timer = NSTimer.scheduledTimerWithTimeInterval(1.0, target: self, selector: "tick", userInfo: nil, repeats: true)
// 设置初始指针位置
self.tick()
}
func tick() {
// 将时间转换成小时、分钟和秒
let calendar = NSCalendar(calendarIdentifier: NSCalendarIdentifierChinese)!
let units = NSCalendarUnit.CalendarUnitHour | NSCalendarUnit.CalendarUnitMinute | NSCalendarUnit.CalendarUnitSecond
let components = calendar.components(units, fromDate: NSDate())
// 计算时针角度
let hoursAngle: CGFloat = (CGFloat(components.hour) / 12.0) * CGFloat(M_PI * 2.0)
// 计算分针角度
let minsAngle: CGFloat = (CGFloat(components.minute) / 60.0) * CGFloat(M_PI * 2.0)
// 计算秒针角度
let secsAngle: CGFloat = (CGFloat(components.second) / 60.0) * CGFloat(M_PI * 2.0)
// 旋转指针
self.hourHand.transform = CGAffineTransformMakeRotation(hoursAngle)
self.minuteHand.transform = CGAffineTransformMakeRotation(minsAngle)
self.secondHand.transform = CGAffineTransformMakeRotation(secsAngle)
}
}
当我们运行这个时钟应用时,它看起来有点怪(如图3.6)。原因在于指针图像是绕着图像中心旋转的,这并不是我们想要的时钟指针的轴心。
你可能认为这可以通过在Interface Builder中调整指针图像的位置来修复,但这并不会起作用,如何图像不居中于表盘它们不会正确的旋转。
一种解决方法是在所有图像的底部增加额外的透明空间,但这会使得图像大于它们实际所需要的尺寸,它们会消耗更多的内存,这样十分不优雅。
更好的解决方案是使用anchorPoint
属性。让我们在-viewDidLoad
方法中加上一些代码来使得我们指针的anchorPoint
偏移(如表3.2)。图3.7展示了正确排列的指针。
表3.2 调整anchorPoint值后的时钟
override func viewDidLoad() {
super.viewDidLoad()
// 调整锚点
self.secondHand.layer.anchorPoint = CGPointMake(0.5, 0.9)
self.minuteHand.layer.anchorPoint = CGPointMake(0.5, 0.9)
self.hourHand.layer.anchorPoint = CGPointMake(0.5, 0.9)
// 启动计时器
self.timer = NSTimer.scheduledTimerWithTimeInterval(1.0, target: self, selector: "tick", userInfo: nil, repeats: true)
// 设置初始指针位置
self.tick()
}
坐标系统
图层如同视图一样,有位置的继承性,每一个图层会相对其在图层树中的父图层放置。一个图层的position
是相对其父图层的bounds
而言的。如果父图层移动了,所有的子图层也会移动。
这在移动图层位置时是十分方便的,因为它允许你移动根图层并将其子树的几个图层作为一个整体一起移动。但有时你需要知道一个图层的绝对位置或(更普遍情况下)它相对于其它图层而非直接父图层的位置。
CALayer
提供一些实用的方法来转换不同图层间的坐标系统:
objc:
- (CGPoint)convertPoint:(CGPoint)aPoint fromLayer:(CALayer *)layer
- (CGPoint)convertPoint:(CGPoint)aPoint toLayer:(CALayer *)layer
- (CGRect)convertRect:(CGRect)aRect fromLayer:(CALayer *)layer
- (CGRect)convertRect:(CGRect)aRect toLayer:(CALayer *)layer
swift:
func convertPoint(_ aPoint: CGPoint, fromLayer layer: CALayer!) -> CGPoint
func convertPoint(_ aPoint: CGPoint, toLayer layer: CALayer!) -> CGPoint
func convertRect(_ aRect: CGRect, fromLayer layer: CALayer!) -> CGRect
func convertRect(_ aRect: CGRect, toLayer layer: CALayer!) -> CGRect
这些方法让你可以某个图层中定义的点或矩形中的坐标系统转化成另一个坐标系统。
翻转几何
通常来说,iOS中图层的position
被指定为相对于父图层边界的左上角而言。而在Mac OS中则是相对于左下角而言。Core Animation
可以通过geometryFlipped
属性支持这两种情况。这是一个决定图层的几何是否会相对其父图层垂直翻转的BOOL
值。在iOS平台上设置图层这个值为YES
一位着它的子图层会垂直翻转,然后会根据下边界放置而非通常情况下得上边界(这适应于它们的所有子图层,除非子图层也将geometryFlipped
设置为YES
)。
Z轴
不同于UIView
是严格的二维图形,CALayer
存在于一个三维空间。除了我们早已讨论过的position
和ancholPoint
属性,CALayer
还有两个额外的属性,zPosition
和anchorPointZ
,这两个都是用来图层在Z轴上位置的浮点数。
注意并没有用来depth属性用来补充bounds
的宽和高,图层本质上是平面物体。你可以把它们想你成是独立的二维的硬纸壳但可以用胶水粘成中空的折纸似的三维结构。
zPosition
属性大多情况下并不是十分有用。在第5章中,我们将讨论CATransform3d
,你将学习如何在三维空间中移动和旋转图层。但除了变形之外,你可能发现zPosition
属性的唯一用途在于改变图层的显示顺序。
通常,图层是在它们父图层的sublayers
数组中的顺序显示的。这被称作画家理论,因为就像画家绘制一堵墙——后画的图层会覆盖先画的墙。但通过增加图层的zPosition
属性,你可以将其前移至镜头,这样它就会在物理上位于其它所有图层的前方(至少在其它有更低zPosition
值的图层之前)。
“镜头”在这里就是指代用户的视窗。对于内置于iPhone中的镜头我们并不能做什么(尽管它凑巧也指向同一方向)。
图3.8展示了一组排列在Interface Builder中的视图。正如你所见,这个先出现在视图层次中的绿色视图被画在后出现在视图层次中红色视图之下。
我们希望在真实的应用中同样可以反应这种层次。但如果我们增加绿色视图的zPosition
(如表3.3),我们发现顺序翻转了(如图3.9)。注意,我们并不需要增加太多;视图是无限薄的,所以即使zPosition
只有1像素的增加都会使绿色视图到红色视图前。更小的值如0.1或0.0001同样会起作用,但谨慎使用太小的值,因为这可能在浮点数计算时产生精度问题后导致视觉上的差异。
表3.3 调整zPosition来改变显示顺序
import UIKit
class ViewController: UIViewController {
@IBOutlet weak var greenView: UIView!
@IBOutlet weak var redView: UIView!
override func viewDidLoad() {
super.viewDidLoad()
// 将绿色视图的zPosition向镜头移近
self.greenView.layer.zPosition = 1.0
}
}
点击测试
第1章“图层树”说到使用有主图层的视图比构建独立的图层层次更好。其中一个原因是后者在处理触摸事件时会有额外的复杂性。
CALayer
并不能知晓响应者链,所以它不能直接处理触摸事件或手势识别。存在许多方法帮你自己实现触摸处理,比如:-containsPoint:
和-hitTest:
。-containsPoint:
方法接收一个图层自身坐标系统的CGPoint
,并且当点在图层自身frame
中时返回YES
。表3.4展示了使用了-containsPoint:
方法判断是否白色或蓝色图层被点击的第1章的项目的改版代码(如图3.10)。依次将触摸位置转换成每个图层的坐标系统显得十分不便。
表3.4 用containsPoint:判断被触摸的图层
import UIKit
class ViewController: UIViewController {
@IBOutlet weak var layerView: UIView!
var blueLayer: CALayer!
override func viewDidLoad() {
super.viewDidLoad()
// 创建子图层
self.blueLayer = CALayer()
self.blueLayer.frame = CGRectMake(50.0, 50.0, 100.0, 100.0)
self.blueLayer.backgroundColor = UIColor.blueColor().CGColor
// 将它加入到当前视图中
self.layerView.layer.addSublayer(self.blueLayer)
}
override func touchesBegan(touches: Set, withEvent event: UIEvent) {
// 获得相对于主视图的触摸位置
var point = (touches as NSSet).anyObject()!.locationInView(self.view)
// 将这个点转换为白色图层的坐标
point = self.layerView.layer.convertPoint(point, fromLayer: self.view.layer)
// 使用containsPoint获得图层
if (self.layerView.layer.containsPoint(point)) {
// 将点转换成蓝色图层的坐标
point = self.blueLayer.convertPoint(point, fromLayer: self.layerView.layer)
if (self.blueLayer.containsPoint(point)) {
UIAlertView(title: "点击蓝色视图", message: "检测到你点击了蓝色视图!", delegate: nil, cancelButtonTitle: "好的", otherButtonTitles: "取消").show()
}
}
}
}
-hitTest:
方法也接收一个CGPoint
;但它返回图层本身或者包含这个点的最深层的子图层而非BOOL
型。这意味着你不需要像使用-containsPoint:
方法一样依次手动转换、判断每个图层是否包含触摸点。如果这个点在最外层的图层边界之外,它将会返回nil
。表3.5展示了用-hitTest:
方法检测触摸图层的代码。
表3.5 用hitTest判断触摸图层
override func touchesBegan(touches: Set, withEvent event: UIEvent) {
// 获得相对于主视图的触摸位置
var point = (touches as NSSet).anyObject()!.locationInView(self.view)
// 获得触碰图层
let layer = self.layerView.layer.hitTest(point)
// 用hitTest获得图层
if (layer == self.blueLayer) {
UIAlertView(title: "点击蓝色视图", message: "检测到你点击了蓝色视图!", delegate: nil, cancelButtonTitle: "好的", otherButtonTitles: "取消").show()
} else if (layer == self.layerView.layer) {
UIAlertView(title: "点击白色视图", message: "检测到你点击了白色视图!", delegate: nil, cancelButtonTitle: "好的", otherButtonTitles: "取消").show()
}
}
你可能注意到当调用图层的-hitTest:
方法时(不幸的是这同样适应于UIView
的触摸处理),检测的顺序是严格基于图层树中的图层顺序的。我们先前提及的zPosition
属性可以影响显式的屏幕上的图层顺序,但不会影响触摸处理的顺序。
这意味着如果你改变图层的z顺序,你可能会发现自己无法检测最前面图层的触摸事件,这是因为它被另一个有更低zPosition
但在图层树更前位置的图层阻挡了。我们将在第5章深入探讨这个问题。
自动布局
你可能偶然见过UIViewAutoresizingMask
常量,这个是用于控制UIView frame
在其父视图改变大小时如何更新的(通常是响应屏幕从水平转向竖直或者反过来)。
在iOS 6中,Apple引入了自动布局机制。这与自动尺寸遮罩不同,但更为好用,通过指定约束结合来组成一个系统,这个系统是通过线性方程和不等式来定义视图的位置的大小的。
在Mac OS上,CALayer
有一个叫做layoutManager
的属性可以让你通过使用CALayoutManager
这一非正式协议和CAConstraintLayoutManager
类,得以使用这一自动布局机制。然而因为某些原因,在iOS上并不能使用。[2]
当使用基于图层的视图时,你可以利用UIView
提供的UIViewAutoresizingMask
和NSLayoutConstraint
的API。但如果你想直接控制CALayer
的布局,你需要手动操作。最简单的方法是用下面这个CALayerDelegate:
方法:
- (void)layoutSublayersOfLayer: (CALayer *)layer;
这个方法会在图层bounds
改变或者图层上的-setNeedsLayout
方法被调用时自动调用。这给你机会来程序化地对你的子图层进行重新改变位置和大小,但并没有像UIView
的autoresizingMask
和constrains
属性一样提供保持图层在屏幕旋转后保持对齐的默认行为。
这是尽可能尝试使用视图构建你的界面而不是使用管理图层的另一个好理由。
总结
这一章节介绍了CALayer
的几何学,包括它的frame
,position
以及bounds
,然后我们涉及了图层是存在于一个三维空间而非平面的知识。我们也讨论了在管理图层的方式中如何实现触摸事件的处理,以及iOS的'Core Animation'缺乏支持自动尺寸以及自动布局的机制。
在第4章“视觉特效”中我们将讲解一些'Core Animation'的图层表现特性。
-
文章中使用的钟表素材
↩ -
读者请注意原著是iOS6,译者翻译此书时已经出到iOS9,这一特性已经有所不同。 ↩