讓來這的人對幾何不再一無所知。——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<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()
}
}
}
}
-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
提供的UIViewAutoresizingMask
和NSLayoutConstraint
的API。但如果你想直接控制CALayer
的布局,你需要手動操作。最簡單的方法是用下面這個CALayerDelegate:
方法:
- (void)layoutSublayersOfLayer: (CALayer *)layer;
這個方法會在圖層bounds
改變或者圖層上的-setNeedsLayout
方法被調用時自動調用。這給你機會來程序化地對你的子圖層進行重新改變位置和大小,但并沒有像UIView
的autoresizingMask
和constrains
屬性一樣提供保持圖層在屏幕旋轉后保持對齊的默認行為。
這是盡可能嘗試使用視圖構建你的界面而不是使用管理圖層的另一個好理由。
總結
這一章節介紹了CALayer
的幾何學,包括它的frame
,position
以及bounds
,然后我們涉及了圖層是存在于一個三維空間而非平面的知識。我們也討論了在管理圖層的方式中如何實現觸摸事件的處理,以及iOS的'Core Animation'缺乏支持自動尺寸以及自動布局的機制。
在第4章“視覺特效”中我們將講解一些'Core Animation'的圖層表現特性。