核心動畫(3)圖層幾何學
- 圖層幾何學
-
- 布局
- 錨點
- 坐标系
-
- 翻轉的幾何結構
- Z坐标軸
- 自動布局
- 總結
圖層幾何學
不熟悉幾何學的人就不要來這裡了 --柏拉圖學院入口的簽名
在第二章裡面,我們介紹了圖層背後的圖檔,和一些控制圖層坐标和旋轉的屬性。在這一章中,我們将要看一看圖層内部是如何根據父圖層和兄弟圖層來控制位置和尺寸的。另外我們也會涉及如何管理圖層的幾何結構,以及它是如何被自動調整和自動布局影響的。
布局
UIView
有三個比較重要的布局屬性:
frame
,
bounds
和
center
,
CALayer
對應地叫做
frame
,
bounds
和
position
。為了能清楚區分,圖層用了“position”,視圖用了“center”,但是他們都代表同樣的值。
frame
代表了圖層的外部坐标(也就是在父圖層上占據的空間),
bounds
是内部坐标({0, 0}通常是圖層的左上角),
center
和
position
都代表了相對于父圖層
anchorPoint
所在的位置。
anchorPoint
的屬性将會在後續介紹到,現在把它想成圖層的中心點就好了。圖3.1顯示了這些屬性是如何互相依賴的。
![](https://img.laitimes.com/img/9ZDMuAjOiMmIsIjOiQnIsICM38FdsYkRGZkRG9lcvx2bjxiNx8VZ6l2cs0TP31EMVR1T0cmaNRTSEJWN0JDTwYVbiVHNHpleO1GTulzRilWO5xkNNh0YwIFSh9Fd4VGdsATMfd3bkFGazxyaHRGcWdUYuVzVa9GczoVdG1mWfVGc5RHLrJXYtJXZ0F2dvwVZnFWbp1zczV2YvJHctM3cv1Ce-cWZwpmLyQzN4MDNxUTM0ITOwkTMwIzLc52YucWbp5GZzNmLn9Gbi1yZtl2Lc9CX6MHc0RHaiojIsJye.jpeg)
圖3.1
UIView
和
CALayer
的坐标系
視圖的
frame
,
bounds
和
center
屬性僅僅是存取方法,當操縱視圖的
frame
,實際上是在改變位于視圖下方
CALayer
的
frame
,不能夠獨立于圖層之外改變視圖的
frame
。
對于視圖或者圖層來說,
frame
并不是一個非常清晰的屬性,它其實是一個虛拟屬性,是根據
bounds
,
position
和
transform
計算而來,是以當其中任何一個值發生改變,frame都會變化。相反,改變frame的值同樣會影響到他們當中的值
記住當對圖層做變換的時候,比如旋轉或者縮放,
frame
實際上代表了覆寫在圖層旋轉之後的整個軸對齊的矩形區域,也就是說
frame
的寬高可能和
bounds
的寬高不再一緻了(圖3.2)
圖3.2 旋轉一個視圖或者圖層之後的
frame
屬性
錨點
之前提到過,視圖的
center
屬性和圖層的
position
屬性都指定了
anchorPoint
相對于父圖層的位置。圖層的
anchorPoint
通過
position
來控制它的
frame
的位置,你可以認為
anchorPoint
是用來移動圖層的把柄。
預設來說,
anchorPoint
位于圖層的中點,是以圖層的将會以這個點為中心放置。
anchorPoint
屬性并沒有被
UIView
接口暴露出來,這也是視圖的position屬性被叫做“center”的原因。但是圖層的
anchorPoint
可以被移動,比如你可以把它置于圖層
frame
的左上角,于是圖層的内容将會向右下角的
position
方向移動(圖3.3),而不是居中了。
圖3.3 改變
anchorPoint
的效果
和第二章提到的
contentsRect
和
contentsCenter
屬性類似,
anchorPoint
用機關坐标來描述,也就是圖層的相對坐标,圖層左上角是{0, 0},右下角是{1, 1},是以預設坐标是{0.5, 0.5}。
anchorPoint
可以通過指定x和y值小于0或者大于1,使它放置在圖層範圍之外。
注意在圖3.3中,當改變了
anchorPoint
,
position
屬性保持固定的值并沒有發生改變,但是
frame
卻移動了。
那在什麼場合需要改變
anchorPoint
呢?既然我們可以随意改變圖層位置,那改變
anchorPoint
不會造成困惑麼?為了舉例說明,我們來舉一個實用的例子,建立一個模拟鬧鐘的項目。
鐘面和鐘表由四張圖檔組成(圖3.4),為了簡單說明,我們還是用傳統的方式來裝載和加載圖檔,使用四個
UIImageView
執行個體(當然你也可以用正常的視圖,設定他們圖層的
contents
圖檔)。
圖3.4 組成鐘面和鐘表的四張圖檔
鬧鐘的元件通過IB來排列(圖3.5),這些圖檔視圖嵌套在一個容器視圖之内,并且自動調整和自動布局都被禁用了。這是因為自動調整會影響到視圖的
frame
,而根據圖3.2的示範,當視圖旋轉的時候,
frame
是會發生改變的,這将會導緻一些布局上的失靈。
我們用
NSTimer
來更新鬧鐘,使用視圖的
transform
屬性來旋轉鐘表(如果你對這個屬性不太熟悉,不要着急,我們将會在第5章“變換”當中詳細說明),具體代碼見清單3.1
圖3.5 在Interface Builder中布局鬧鐘視圖
清單3.1 Clock
@interface ViewController ()
@property (nonatomic, weak) IBOutlet UIImageView *hourHand;
@property (nonatomic, weak) IBOutlet UIImageView *minuteHand;
@property (nonatomic, weak) IBOutlet UIImageView *secondHand;
@property (nonatomic, weak) NSTimer *timer;
@end
@implementation ViewController
- (void)viewDidLoad
{
[super viewDidLoad];
//start timer
self.timer = [NSTimer scheduledTimerWithTimeInterval:1.0 target:self selector:@selector(tick) userInfo:nil repeats:YES];

//set initial hand positions
[self tick];
}
- (void)tick
{
//convert time to hours, minutes and seconds
NSCalendar *calendar = [[NSCalendar alloc] initWithCalendarIdentifier:NSGregorianCalendar];
NSUInteger units = NSHourCalendarUnit | NSMinuteCalendarUnit | NSSecondCalendarUnit;
NSDateComponents *components = [calendar components:units fromDate:[NSDate date]];
CGFloat hoursAngle = (components.hour / 12.0) * M_PI * 2.0;
//calculate hour hand angle //calculate minute hand angle
CGFloat minsAngle = (components.minute / 60.0) * M_PI * 2.0;
//calculate second hand angle
CGFloat secsAngle = (components.second / 60.0) * M_PI * 2.0;
//rotate hands
self.hourHand.transform = CGAffineTransformMakeRotation(hoursAngle);
self.minuteHand.transform = CGAffineTransformMakeRotation(minsAngle);
self.secondHand.transform = CGAffineTransformMakeRotation(secsAngle);
}
@end
運作項目,看起來有點奇怪(圖3.6),因為鐘表的圖檔在圍繞着中心旋轉,這并不是我們期待的一個支點。
圖3.6 鐘面,和不對齊的鐘指針
你也許會認為可以在Interface Builder當中調整指針圖檔的位置來解決,但其實并不能達到目的,因為如果不放在鐘面中間的話,同樣不能正确的旋轉。
也許在圖檔末尾添加一個透明空間也是個解決方案,但這樣會讓圖檔變大,也會消耗更多的記憶體,這樣并不優雅。
更好的方案是使用
anchorPoint
屬性,我們來在
-viewDidLoad
方法中添加幾行代碼來給每個鐘指針的
anchorPoint
做一些平移(清單3.2),圖3.7顯示了正确的結果。
清單3.2
- (void)viewDidLoad
{
[super viewDidLoad];
// adjust anchor points
self.secondHand.layer.anchorPoint = CGPointMake(0.5f, 0.9f);
self.minuteHand.layer.anchorPoint = CGPointMake(0.5f, 0.9f);
self.hourHand.layer.anchorPoint = CGPointMake(0.5f, 0.9f);
// start timer
}
圖3.7 鐘面,和正确對齊的鐘指針
坐标系
和視圖一樣,圖層在圖層樹當中也是相對于父圖層按層級關系放置,一個圖層的
position
依賴于它父圖層的
bounds
,如果父圖層發生了移動,它的所有子圖層也會跟着移動。
這樣對于放置圖層會更加友善,因為你可以通過移動根圖層來将它的子圖層作為一個整體來移動,但是有時候你需要知道一個圖層的絕對位置,或者是相對于另一個圖層的位置,而不是它目前父圖層的位置。
CALayer
給不同坐标系之間的圖層轉換提供了一些工具類方法:
- (CGPoint)convertPoint:(CGPoint)point fromLayer:(CALayer *)layer;
- (CGPoint)convertPoint:(CGPoint)point toLayer:(CALayer *)layer;
- (CGRect)convertRect:(CGRect)rect fromLayer:(CALayer *)layer;
- (CGRect)convertRect:(CGRect)rect toLayer:(CALayer *)layer;
這些方法可以把定義在一個圖層坐标系下的點或者矩形轉換成另一個圖層坐标系下的點或者矩形
翻轉的幾何結構
正常說來,在iOS上,一個圖層的
position
位于父圖層的左上角,但是在Mac OS上,通常是位于左下角。Core Animation可以通過
geometryFlipped
屬性來适配這兩種情況,它決定了一個圖層的坐标是否相對于父圖層垂直翻轉,是一個
BOOL
類型。在iOS上通過設定它為
YES
意味着它的子圖層将會被垂直翻轉,也就是将會沿着底部排版而不是通常的頂部(它的所有子圖層也同理,除非把它們的
geometryFlipped
屬性也設為
YES
)。
Z坐标軸
和
UIView
嚴格的二維坐标系不同,
CALayer
存在于一個三維空間當中。除了我們已經讨論過的
position
和
anchorPoint
屬性之外,
CALayer
還有另外兩個屬性,
zPosition
和
anchorPointZ
,二者都是在Z軸上描述圖層位置的浮點類型。
注意這裡并沒有更深的屬性來描述由寬和高做成的
bounds
了,圖層是一個完全扁平的對象,你可以把它們想象成類似于一頁二維的堅硬的紙片,用膠水粘成一個空洞,就像三維結構的折紙一樣。
zPosition
屬性在大多數情況下其實并不常用。在第五章,我們将會涉及
CATransform3D
,你會知道如何在三維空間移動和旋轉圖層,除了做變換之外,
zPosition
最實用的功能就是改變圖層的顯示順序了。
通常,圖層是根據它們子圖層的
sublayers
出現的順序來類繪制的,這就是所謂的畫家的算法–就像一個畫家在牆上作畫–後被繪制上的圖層将會遮蓋住之前的圖層,但是通過增加圖層的
zPosition
,就可以把圖層向相機方向前置,于是它就在所有其他圖層的前面了(或者至少是小于它的
zPosition
值的圖層的前面)。
這裡所謂的“相機”實際上是相對于使用者是視角,這裡和iPhone背後的内置相機沒任何關系。
圖3.8顯示了在Interface Builder内的一對視圖,正如你所見,首先出現在視圖層級綠色的視圖被繪制在紅色視圖的後面。
圖3.8 在視圖層級中綠色視圖被繪制在紅色視圖的後面
我們希望在真實的應用中也能顯示出繪圖的順序,同樣地,如果我們提高綠色視圖的
zPosition
(清單3.3),我們會發現順序就反了(圖3.9)。其實并不需要增加太多,視圖都非常地薄,是以給
zPosition
提高一個像素就可以讓綠色視圖前置,當然0.1或者0.0001也能夠做到,但是最好不要這樣,因為浮點類型四舍五入的計算可能會造成一些不便的麻煩。
清單3.3
@interface ViewController ()
@property (nonatomic, weak) IBOutlet UIView *greenView;
@property (nonatomic, weak) IBOutlet UIView *redView;
@end
@implementation ViewController
- (void)viewDidLoad
{
[super viewDidLoad];

//move the green view zPosition nearer to the camera
self.greenView.layer.zPosition = 1.0f;
}
@end
圖3.9 綠色視圖被繪制在紅色視圖的前面
##Hit Testing
第一章“圖層樹”證明了最好使用圖層相關視圖,而不是建立獨立的圖層關系。其中一個原因就是要處理額外複雜的觸摸事件。
CALayer
對響應鍊一無所知,是以它不能直接處理觸摸事件或者手勢。但是它有一系列的方法幫你處理事件:
-containsPoint:
和
-hitTest:
。
-containsPoint:
接受一個在本圖層坐标系下的
CGPoint
,如果這個點在圖層
frame
範圍内就傳回
YES
。如清單3.4所示第一章的項目的另一個合适的版本,也就是使用
-containsPoint:
方法來判斷到底是白色還是藍色的圖層被觸摸了
(圖3.10)。這需要把觸摸坐标轉換成每個圖層坐标系下的坐标,結果很不友善。
清單3.4 使用containsPoint判斷被點選的圖層
@interface ViewController ()
@property (nonatomic, weak) IBOutlet UIView *layerView;
@property (nonatomic, weak) CALayer *blueLayer;
@end
@implementation ViewController
- (void)viewDidLoad
{
[super viewDidLoad];
//create sublayer
self.blueLayer = [CALayer layer];
self.blueLayer.frame = CGRectMake(50.0f, 50.0f, 100.0f, 100.0f);
self.blueLayer.backgroundColor = [UIColor blueColor].CGColor;
//add it to our view
[self.layerView.layer addSublayer:self.blueLayer];
}
- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event
{
//get touch position relative to main view
CGPoint point = [[touches anyObject] locationInView:self.view];
//convert point to the white layer's coordinates
point = [self.layerView.layer convertPoint:point fromLayer:self.view.layer];
//get layer using containsPoint:
if ([self.layerView.layer containsPoint:point]) {
//convert point to blueLayer’s coordinates
point = [self.blueLayer convertPoint:point fromLayer:self.layerView.layer];
if ([self.blueLayer containsPoint:point]) {
[[[UIAlertView alloc] initWithTitle:@"Inside Blue Layer"
message:nil
delegate:nil
cancelButtonTitle:@"OK"
otherButtonTitles:nil] show];
} else {
[[[UIAlertView alloc] initWithTitle:@"Inside White Layer"
message:nil
delegate:nil
cancelButtonTitle:@"OK"
otherButtonTitles:nil] show];
}
}
}
@end
圖3.10 點選圖層被正确辨別
-hitTest:
方法同樣接受一個
CGPoint
類型參數,而不是
BOOL
類型,它傳回圖層本身,或者包含這個坐标點的葉子節點圖層。這意味着不再需要像使用
-containsPoint:
那樣,人工地在每個子圖層變換或者測試點選的坐标。如果這個點在最外面圖層的範圍之外,則傳回nil。具體使用
-hitTest:
方法被點選圖層的代碼如清單3.5所示。
清單3.5 使用hitTest判斷被點選的圖層
- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event
{
//get touch position
CGPoint point = [[touches anyObject] locationInView:self.view];
//get touched layer
CALayer *layer = [self.layerView.layer hitTest:point];
//get layer using hitTest
if (layer == self.blueLayer) {
[[[UIAlertView alloc] initWithTitle:@"Inside Blue Layer"
message:nil
delegate:nil
cancelButtonTitle:@"OK"
otherButtonTitles:nil] show];
} else if (layer == self.layerView.layer) {
[[[UIAlertView alloc] initWithTitle:@"Inside White Layer"
message:nil
delegate:nil
cancelButtonTitle:@"OK"
otherButtonTitles:nil] show];
}
}
注意當調用圖層的
-hitTest:
方法時,測算的順序嚴格依賴于圖層樹當中的圖層順序(和UIView處理事件類似)。之前提到的
zPosition
屬性可以明顯改變螢幕上圖層的順序,但不能改變觸摸事件被處理的順序。
這意味着如果改變了圖層的z軸順序,你會發現将不能夠檢測到最前方的視圖點選事件,這是因為被另一個圖層遮蓋住了,雖然它的
zPosition
值較小,但是在圖層樹中的順序靠前。我們将在第五章詳細讨論這個問題。
自動布局
你可能用過
UIViewAutoresizingMask
類型的一些常量,應用于當父視圖改變尺寸的時候,相應
UIView
的
frame
也跟着更新的場景(通常用于橫豎屏切換)。
在iOS6中,蘋果介紹了自動布局機制,它和自動調整不同,并且更加複雜,它通過指定組合形成線性方程組和不等式的限制,定義視圖的位置和大小。
在Mac OS平台,
CALayer
有一個叫做
layoutManager
的屬性可以通過
CALayoutManager
協定和
CAConstraintLayoutManager
類來實作自動排版的機制。但由于某些原因,這在iOS上并不适用。
當使用視圖的時候,可以充分利用
UIView
類接口暴露出來的
UIViewAutoresizingMask
和
NSLayoutConstraint
API,但如果想随意控制
CALayer
的布局,就需要手工操作。最簡單的方法就是使用
CALayerDelegate
如下函數:
- (void)layoutSublayersOfLayer:(CALayer *)layer;
當圖層的
bounds
發生改變,或者圖層的
-setNeedsLayout
方法被調用的時候,這個函數将會被執行。這使得你可以手動地重新擺放或者重新調整子圖層的大小,但是不能像
UIView
的
autoresizingMask
和
constraints
屬性做到自适應螢幕旋轉。
這也是為什麼最好使用視圖而不是單獨的圖層來建構應用程式的另一個重要原因之一。
總結
本章涉及了
CALayer
的幾何結構,包括它的
frame
,
position
和
bounds
,介紹了三維空間内圖層的概念,以及如何在獨立的圖層内響應事件,最後簡單說明了在iOS平台中,Core Animation對自動調整和自動布局支援的缺乏。
在第四章“視覺效果”當中,我們接着介紹一些圖層外表的特性。