I.3 图层几何

让来这的人对几何不再一无所知。——Plato's Academy入口的刻字

第2章“主图像”介绍了基于图层的图像以及在图层边界中控制它位置和缩放的相关属性。在这一章,我们将研究图层相对于父图层以及兄弟图层的位置和大小变化。我们也会讲述如何管理你的图层的几何以及自动尺寸和自动大小会产生什么影响。

布局

UIView有三个主要的布局属性:framebounds以及center,对应CALayer中的frameboundsposition。为什么图层使用position而视图使用center马上会讲解清楚,但它们代表着同样的值。

frame表示图层的坐标(即它在父图层中占用的空间),bounds属性表示坐标(使用{0, 0}通常等于图层的左上角,但这并不总是这样),而centerposition同样表示anchorPoint相对于父图层的位置。anchorPoint稍后将会解释,在这就先将它理解成图层的中心。图3.1展示了这样属性之间的相关性。
视图的frmaebounds以及center属性实际上是相应的底图层的存取器(settergetter方法)。当你手动修改视图的frame时,你实际上是在修改其下CALayerframe。你无法抛开它的图层而单独修改视图的frame

图3.1 UIView和CALayer坐标系统(以及示范数据)

frame其实并不是视图或图层中真正的值;它是一个通过计算boundsposition以及transform得到的虚拟值,因此会随这些值的改变而改变。而改变frame也会影响到这些值中的某些或全部。

在你开始使用变形前你应该牢记这点,因为当一个图层旋转或缩放,它的frame反应被变形图层在父图层中所占用的矩形区域在总轴的映射,这意味着frame的宽和高不再匹配bounds(见图3.2)。

图3.2 旋转视图或图层对其frame属性的影响

anchorPoint

正如先前提及的,视图的center属性以及图层的position属性指定了图层相对于其父图层的anchorPoint的位置。图层的anchorPoint属性控制图层的frame相对于其position属性的位置。你可以把anchorPoint当作四处移动图层的把手

默认情况下,anchorPoint位于图层中心,这样无论图层在哪都会在其位置上居中。anchorPoint并不在UIView类接口中显露,这就是为什么视图的位置属性被叫做“中心”。但图层的anchorPoint可以移动,例如你可以把它置于图层frame的左上角,然后图层的内容会向它position的右下角扩展(如图3.3)而不是以其为中心。

像第2章中介绍的contentsRectcontentsCenter属性一样,anchorPoint采用单元坐标,这意味着它的坐标是相对于它图层的尺寸而言。图层的左上角是{0, 0},右下角是{1, 1},因此默认(中心)位置是{0.5, 0.5}anchorPoint可以通过指x或y的值小于0或大于1来使其被放置在图层边界之

图3.3 改变anchorPoint对其帧的影响

那么为什么我们会想要改变anchorPoint?我们本来就可以将帧放在任何位置,那改变anchorPoint只是为了制造疑惑吗?为了解释这个为什么有用,让我们一起做个有用的例子。让我们通过移动时针、分针和秒针模拟一个时钟。

表盘和指针用四副图像(如图3.4)组成。为了简单起见,我们将用传统方法显示并加载这些图像[1],我们使用四个独立的UIImageView实例(尽管我们也可以使用正常的视图并设置它们的主图层的contents图像)。

图3.4 用于组成表盘的指针的四个图像

时钟组件在Interface Builder中是这样排列的(见图3.5)。图像视图被置于另一个容器视图中,并且禁用所有的自动尺寸和自动布局。这是因为自动尺寸作用于视图的frame,正如图3.2所示,frame在视图旋转时会改变,如果旋转视图的frame是自动尺寸的会导致布局失效。

我们会使用一个NSTimer来更新时钟,并使用视图的transform属性来旋转指针。(如果你对这个属性不熟悉,不要担心,我们将在第5章“变形”中讲解。)表3.1展示了我们时钟的代码。

图3.5 在Interface Builder中放置时钟视图

表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中调整指针图像的位置来修复,但这并不会起作用,如何图像不居中于表盘它们不会正确的旋转。

图3.6 有混乱指针的表盘

一种解决方法是在所有图像的底部增加额外的透明空间,但这会使得图像大于它们实际所需要的尺寸,它们会消耗更多的内存,这样十分不优雅。

更好的解决方案是使用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()
}
图3.7 指针正确排列的表盘

坐标系统

图层如同视图一样,有位置的继承性,每一个图层会相对其在图层树中的父图层放置。一个图层的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存在于一个三维空间。除了我们早已讨论过的positionancholPoint属性,CALayer还有两个额外的属性,zPositionanchorPointZ,这两个都是用来图层在Z轴上位置的浮点数。

注意并没有用来depth属性用来补充bounds的宽和高,图层本质上是平面物体。你可以把它们想你成是独立的二维的硬纸壳但可以用胶水粘成中空的折纸似的三维结构。

zPosition属性大多情况下并不是十分有用。在第5章中,我们将讨论CATransform3d,你将学习如何在三维空间中移动和旋转图层。但除了变形之外,你可能发现zPosition属性的唯一用途在于改变图层的显示顺序

通常,图层是在它们父图层的sublayers数组中的顺序显示的。这被称作画家理论,因为就像画家绘制一堵墙——后画的图层会覆盖先画的墙。但通过增加图层的zPosition属性,你可以将其前移至镜头,这样它就会在物理上位于其它所有图层的前方(至少在其它有更低zPosition值的图层之前)。

“镜头”在这里就是指代用户的视窗。对于内置于iPhone中的镜头我们并不能做什么(尽管它凑巧也指向同一方向)。

图3.8展示了一组排列在Interface Builder中的视图。正如你所见,这个先出现在视图层次中的绿色视图被画在后出现在视图层次中红色视图之下。


图3.8 绿色视图在红色视图视图层次之下

我们希望在真实的应用中同样可以反应这种层次。但如果我们增加绿色视图的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
    }
}
图3.9 绿色视图画在红色视图前

点击测试

第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<NSObject>, 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()
            }
        }
    }

}
图3.10 正确识别被点击的图层

-hitTest:方法也接收一个CGPoint;但它返回图层本身或者包含这个点的最深层的子图层而非BOOL型。这意味着你不需要像使用-containsPoint:方法一样依次手动转换、判断每个图层是否包含触摸点。如果这个点在最外层的图层边界之外,它将会返回nil。表3.5展示了用-hitTest:方法检测触摸图层的代码。

表3.5 用hitTest判断触摸图层
override func touchesBegan(touches: Set<NSObject>, 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提供的UIViewAutoresizingMaskNSLayoutConstraint的API。但如果你想直接控制CALayer的布局,你需要手动操作。最简单的方法是用下面这个CALayerDelegate:方法:

- (void)layoutSublayersOfLayer: (CALayer *)layer;

这个方法会在图层bounds改变或者图层上的-setNeedsLayout方法被调用时自动调用。这给你机会来程序化地对你的子图层进行重新改变位置和大小,但并没有像UIViewautoresizingMaskconstrains属性一样提供保持图层在屏幕旋转后保持对齐的默认行为。

这是尽可能尝试使用视图构建你的界面而不是使用管理图层的另一个好理由。

总结

这一章节介绍了CALayer的几何学,包括它的frameposition以及bounds,然后我们涉及了图层是存在于一个三维空间而非平面的知识。我们也讨论了在管理图层的方式中如何实现触摸事件的处理,以及iOS的'Core Animation'缺乏支持自动尺寸以及自动布局的机制。

在第4章“视觉特效”中我们将讲解一些'Core Animation'的图层表现特性。


  1. 文章中使用的钟表素材


    SecondHand.png

    MinuteHand.png

    HourHand.png

    ClockFace.png

  2. 读者请注意原著是iOS6,译者翻译此书时已经出到iOS9,这一特性已经有所不同。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,482评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,377评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,762评论 0 342
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,273评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,289评论 5 373
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,046评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,351评论 3 400
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,988评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,476评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,948评论 2 324
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,064评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,712评论 4 323
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,261评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,264评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,486评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,511评论 2 354
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,802评论 2 345

推荐阅读更多精彩内容