在這一章中,我們將要研究可以用來對圖層旋轉,擺放或者扭曲的CGAffineTransform
,以及可以將扁平物體轉換成三維空間對象的 CATransform3D
(而不是僅僅對圓角矩形添加下沉陰影)。
仿射變換
實際上UIView
的transform
屬性是一個CGAffineTransform
類型,用于在二維空間做旋轉,縮放和平
移。CGAffineTransform
是一個可以和二維空間向量(例如 CGPoint
)做乘法 的3X2的矩陣
用 CGPoint
的每一列和 矩陣的每一行對應元素相乘再求和,就形成了一個新的 類型的結果。要解釋一下圖中顯示的灰色元素,為了能讓矩陣做乘法,左邊矩陣的列數一定要和右邊矩陣的行數個數相同,所以要給矩陣填充一些標志值,使得既可以讓矩陣做乘法,又不改變運算結果,并且沒必要存儲這些添加的值,因為它們的值不會發生變化,但是要用來做運算。
當對圖層應用變換矩陣,圖層矩形內的每一個點都被相應地做變換,從而形成一個 新的四邊形的形狀。 CGAffineTransform
中的“仿射”的意思是無論變換矩陣用什么值,圖層中平行的兩條線在變換之后任然保持平行, CGAffineTransform 可以做出任意符合上述標注的變換
創建一個 CGAffineTransform
對矩陣數學做一個全面的闡述就超出本書的討論范圍了,不過如果你對矩陣完全不熟悉的話,矩陣變換可能會使你感到畏懼。幸運的是,Core Graphics
提供了一系列函數,對完全沒有數學基礎的開發者也能夠簡單地做一些變換。如下幾個函數都 創建了一個 CGAffineTransform 實例:
CGAffineTransformMakeRotation(CGFloat angle)
CGAffineTransformMakeScale(CGFloat sx, CGFloat sy)
CGAffineTransformMakeTranslation(CGFloat tx, CGFloat ty)
旋轉和縮放變換都可以很好解釋--分別旋轉或者縮放一個向量的值。平移變換是指 每個點都移動了向量指定的x
或者y
值--所以如果向量代表了一個點,那它就平移了 這個點的距離。
UIView
可以通過設置transform
屬性做變換,但實際上它只是封裝了內部圖層的變換。
CALayer
同樣也有一個transform
的屬性,但它的類型是 CATransform3D
,而不是CGAffineTransform
。CALayer
對應于UIView
的transform
屬性叫做affineTransform
。
- (void)viewDidLoad
{
[super viewDidLoad];
//rotate the layer 45 degrees
CGAffineTransform transform = CGAffineTransformMakeRotation(M_PI_4);
self.layerView.layer.affineTransform = transform;
}
注意我們使用的旋轉常量是 M_PI_4
,而不是你想象的45
,因為iOS
的變換函數使 用弧度而不是角度作為單位?;《扔脭祵W常量pi
的倍數表示,一個pi
代表180
度,所以四分之一的pi
就是45
度。
C
的數學函數庫(iOS
會自動引入)提供了pi
的一些簡便的換算, M_PI_4
于是就 是pi
的四分之一,如果對換算不太清楚的話,可以用如下的宏做換算:
#define RADIANS_TO_DEGREES(x) ((x)/M_PI*180.0)
混合變換
Core Graphics
提供了一系列的函數可以在一個變換的基礎上做更深層次的變換, 如果做一個既要縮放又要旋轉的變換,這就會非常有用了。例如下面幾個函數:
CGAffineTransformRotate(CGAffineTransform t, CGFloat angle)
CGAffineTransformScale(CGAffineTransform t, CGFloat sx, CGFloat sy)
CGAffineTransformTranslate(CGAffineTransform t, CGFloat tx, CGFloat ty)
當操縱一個變換的時候,初始生成一個什么都不做的變換很重要--也就是創建一 個 CGAffineTransform
類型的空值,矩陣論中稱作單位矩陣,Core Graphics
同 樣也提供了一個方便的常量:CGAffineTransformIdentity
最后,如果需要混合兩個已經存在的變換矩陣,就可以使用如下方法,在兩個變換
的基礎上創建一個新的變換:
CGAffineTransformConcat(CGAffineTransform t1, CGAffineTransform t2)
Demo
我們來用這些函數組合一個更加復雜的變換,先縮小50%
,再旋轉30
度,最后向右移動200
個像素
- (void)viewDidLoad
{
[super viewDidLoad];
//create a new transform
CGAffineTransform transform = CGAffineTransformIdentity;
//scale by 50%
transform = CGAffineTransformScale(transform, 0.5, 0.5);
//rotate by 30 degrees
transform = CGAffineTransformRotate(transform, M_PI / 180.0 * 30);
//translate by 200 points
transform = CGAffineTransformTranslate(transform, 200, 0);
//apply transform to layer
self.layerView.layer.affineTransform = transform;
}
3D變換
CG
的前綴告訴我們, CGAffineTransform
類型屬于Core Graphics
框架,Core Graphics
實際上是一個嚴格意義上的2D
繪圖API
,并且 CGAffineTransform
僅僅 對2D
變換有效。
我們提到了zPosition
屬性,可以用來讓圖層靠近或者遠離相機 (用戶視角),transform
屬性( CATransfrom3D
類型)可以真正做到這點,即讓圖層在3D
空間內移動或者旋轉。
和 CGAffineTransform
類似, CATransform3D
也是一個矩陣,但是和2x3
的矩 陣不同,CATransform3D
是一個可以在3
維空間內做變換的4x4
的矩陣。
和CGAffineTransform
矩陣類似,Core Animation
提供了一系列的方法用來創建和組合CATransform3D
類型的矩陣, 和Core Graphics
的函數類似, 但是3D
的平移和旋轉多出了一個z
參數,并且旋轉函數除了angle
之外多出了x
,y
,z
三個參數,分別決定了每個坐標方向上的旋轉:
CATransform3DMakeRotation(CGFloat angle, CGFloat x, CGFloat y, CGFloat z)
CATransform3DMakeScale(CGFloat sx, CGFloat sy, CGFloat sz)
CATransform3DMakeTranslation(Gloat tx, CGFloat ty, CGFloat tz)
你應該對X
軸和Y
軸比較熟悉了,分別以右和下為正方向,Z
軸和這兩個軸分別垂直,指向視角外為正方向。
Demo
使用代碼利用CATransform3DMakeRotation
對視圖內的圖層 繞Y軸做了45度角的旋轉,我們可以把視圖向右傾斜,這樣會看得更清晰。
- (void)viewDidLoad
{
[super viewDidLoad];
//rotate the layer 45 degrees along the Y axis
CATransform3D transform = CATransform3DMakeRotation(M_PI_4, 0,1,0);
self.layerView.layer.transform = transform;
}
透視投影
在真實世界中,當物體遠離我們的時候,由于視角的原因看起來會變小,理論上說遠離我們的視圖的邊要比靠近視角的邊跟短,但實際上并沒有發生,而我們當前的 視角是等距離的,也就是在3D變換中任然保持平行,和之前提到的仿射變換類似。
在等距投影中,遠處的物體和近處的物體保持同樣的縮放比例,這種投影也有它自 己的用處(例如建筑繪圖,顛倒,和偽3D
視頻),但當前我們并不需要。
為了做一些修正,我們需要引入投影變換(又稱作 z
變換)來對除了旋轉之外的變 換矩陣做一些修改,Core Animation
并沒有給我們提供設置透視變換的函數,因此 我們需要手動修改矩陣值,幸運的是,很簡單:
CATransform3D
的透視效果通過一個矩陣中一個很簡單的元素來控制:m34
。 用于按比例縮放X
和Y
的值來計算到底要離視角多遠。
m34
的默認值是0
,我們可以通過設置m34
為-1.0 / d
來應用透視效果, d
代 表了想象中視角相機和屏幕之間的距離,以像素為單位,那應該如何計算這個距離 呢?實際上并不需要,大概估算一個就好了。
因為視角相機實際上并不存在,所以可以根據屏幕上的顯示效果自由決定它的防止 的位置。通常500-1000就已經很好了,但對于特定的圖層有時候更小或者更大的值會看起來更舒服,減少距離的值會增強透視效果,所以一個非常微小的值會讓它看起來更加失真,然而一個非常大的值會讓它基本失去透視效果。
- (void)viewDidLoad
{
[super viewDidLoad];
//create a new transform
CATransform3D transform = CATransform3DIdentity;
//apply perspective
transform.m34 = - 1.0 / 500.0;
//rotate by 45 degrees along the Y axis
transform = CATransform3DRotate(transform, M_PI_4, 0, 1, 0);
//apply to layer
self.layerView.layer.transform = transform;
}
滅點
當在透視角度繪圖的時候,遠離相機視角的物體將會變小變遠,當遠離到一個極限
距離,它們可能就縮成了一個點,于是所有的物體最后都匯聚消失在同一個點。
Core Animation
定義了這個點位于變換圖層的anchorPoint
(通常位于圖層中 心,但也有例外)。這就是說,當圖層發生變換時,這個點永遠位于圖 層變換之前 anchorPoint 的位置。
當改變一個圖層的 position
,你也改變了它的滅點,做3D
變換的時候要時刻記住這一點,當你視圖通過調整 m34
來讓它更加有3D
效果,應該首先把它放置于屏 幕中央,然后通過平移來把它移動到指定位置(而不是直接改變它的position
),這樣所有的3D
圖層都共享一個滅點。
sublayerTransform 屬性
如果有多個視圖或者圖層,每個都做3D
變換,那就需要分別設置相同的m34
值,并且確保在變換之前都在屏幕中央共享同一個 position
,如果用一個函數封裝這些 操作的確會更加方便,但仍然有限制(例如,你不能在Interface Builde
r中擺放視 圖),這里有一個更好的方法。
CALayer
有一個屬性叫做 sublayerTransform
。它也是 CATransform3D
類型,但和對一個圖層的變換不同,它影響到所有的子圖層。這意味著你可以一次性 對包含這些圖層的容器做變換,于是所有的子圖層都自動繼承了這個變換方法。
相較而言,通過在一個地方設置透視變換會很方便,同時它會帶來另一個顯著的優 勢:滅點被設置在容器圖層的中點,從而不需要再對子圖層分別設置了。這意味著 你可以隨意使用 position
和frame
來放置子圖層,而不需要把它們放置在屏幕中點,然后為了保證統一的滅點用變換來做平移。
@interface ViewController ()
@property (nonatomic, weak) IBOutlet UIView *containerView;
@property (nonatomic, weak) IBOutlet UIView *layerView1;
@property (nonatomic, weak) IBOutlet UIView *layerView2;
@end
@implementation ViewController
- (void)viewDidLoad
{
[super viewDidLoad];
//apply perspective transform to container
CATransform3D perspective = CATransform3DIdentity;
perspective.m34 = - 1.0 / 500.0;
self.containerView.layer.sublayerTransform = perspective; // 重點
//rotate layerView1 by 45 degrees along the Y axis
CATransform3D transform1 = CATransform3DMakeRotation(M_PI_4, 0,1,0);
self.layerView1.layer.transform = transform1;
//rotate layerView2 by 45 degrees along the Y axis
CATransform3D transform2 = CATransform3DMakeRotation(-M_PI_4,0,1,0);
self.layerView2.layer.transform = transform2;
}
背面
我們既然可以在3D
場景下旋轉圖層,那么也可以從背面去觀察它。如果我們把角度修改為 M_PI (180度)而不是當前的 M_PI_4 (45度),那么將會把圖層完全旋轉一個半圈,于是完全背對了相機視角。
如你所見,圖層是雙面繪制的,反面顯示的是正面的一個鏡像圖片。
但這并不是一個很好的特性,因為如果圖層包含文本或者其他控件,那用戶看到這 些內容的鏡像圖片當然會感到困惑。另外也有可能造成資源的浪費:想象用這些圖 層形成一個不透明的固態立方體,既然永遠都看不見這些圖層的背面,那為什么浪 費GPU來繪制它們呢?
CALayer
有一個叫做doubleSided
的屬性來控制圖層的背面是否要被繪制。這是一個BOOL
類型,默認為YES
,如果設置為 NO
,那么當圖層正面從相機視角消失的時候,它將不會被繪制。
扁平化圖層
如果內部圖層相對外部圖層做了相反的變換(這里是繞Z
軸的旋轉),那么按照邏 輯這兩個變換將被相互抵消。
@interface ViewController ()
@property (nonatomic, weak) IBOutlet UIView *outerView;
@property (nonatomic, weak) IBOutlet UIView *innerView;
@end
@implementation ViewController
- (void)viewDidLoad
{
[super viewDidLoad];
//rotate the outer layer 45 degrees
CATransform3D outer = CATransform3DMakeRotation(M_PI_4, 0, 0,1);
self.outerView.layer.transform = outer;
//rotate the inner layer -45 degrees
CATransform3D inner = CATransform3DMakeRotation(-M_PI_4, 0, 0,1);
self.innerView.layer.transform = inner;
}
運行結果和我們預期的一致?,F在在3D
情況下再試一次。修改代碼,讓內外兩個視 圖繞Y
軸旋轉而不是Z
軸,再加上透視效果,以便我們觀察。注意不能用 sublayerTransform
屬性,因為內部的圖層并不直接是容器圖層的子圖層,所以這里分別對圖層設置透視變換。
- (void)viewDidLoad
{
[super viewDidLoad];
//rotate the outer layer 45 degrees
CATransform3D outer = CATransform3DIdentity;
outer.m34 = -1.0 / 500.0;
outer = CATransform3DRotate(outer, M_PI_4, 0, 1, 0);
self.outerView.layer.transform = outer;
//rotate the inner layer -45 degrees
CATransform3D inner = CATransform3DIdentity;
inner.m34 = -1.0 / 500.0;
inner = CATransform3DRotate(inner, -M_PI_4, 0, 1, 0);
self.innerView.layer.transform = inner;
}
但其實這并不是我們所看到的,這是由于盡管Core Animation
圖層存在于3D
空間之內,但它們并不都存在同一個 3D
空間。每個圖層的3D
場景其實是扁平化的,當你從正面觀察一個圖層,看到的 實際上由子圖層創建的想象出來的3D
場景,但當你傾斜這個圖層,你會發現實際上 這個3D
場景僅僅是被繪制在圖層的表面。
這使得用Core Animation
創建非常復雜的3D
場景變得十分困難。你不能夠使用圖層樹去創建一個3D
結構的層級關系--在相同場景下的任何3D
表面必須和同樣的圖層保持一致,這是因為每個的父視圖都把它的子視圖扁平化了。
至少當你用正常的CALayer
的時候是這樣, CALayer
有一個叫做CATransformLayer
的子類來解決這個問題。具體在“特殊的圖層”中將會 具體討論。
固體對象
現在你懂得了在3D
空間的一些圖層布局的基礎,我們來試著創建一個固態的3D
對 象(實際上是一個技術上所謂的空洞對象,但它以固態呈現)。我們用六個獨立的視圖來構建一個立方體的各個面。
光亮和陰影
如果想讓立方體看起來更加真實,需要自己做一個陰影效果。你可以通過改變每個面的背景顏色或者 直接用帶光亮效果的圖片來調整。
如果需要動態地創建光線效果,你可以根據每個視圖的方向應用不同的alpha
值做出半透明的陰影圖層,但為了計算陰影圖層的不透明度,你需要得到每個面的正太向量(垂直于表面的向量),然后根據一個想象的光源計算出兩個向量叉乘結果。 叉乘代表了光源和圖層之間的角度,從而決定了它有多大程度上的光亮。
我們用GLKit
框架來做向量的計算(你需要引入 GLKit
庫來運行代碼),每個面的 CATransform3D
都被轉換成 GLKMatrix4
,然后通過GLKMatrix4GetMatrix3
函數得出一個3×3
的旋轉矩陣。這個旋轉矩陣指定了圖層的方向,然后可以用它來得到正太向量的值。
點擊事件
點擊事件的處理由視圖在父視圖中的順序決定的,并不是3D
空間中的Z
軸順序。
你也許認為把 doubleSided
設置成 NO
可以解決這個問題,因為它不再渲染視圖 后面的內容,但實際上并不起作用。因為背對相機而隱藏的視圖仍然會響應點擊事 件(這和通過設置 hidden
屬性或者設置 alpha
為0
而隱藏的視圖不同,那兩種 方式將不會響應事件)。所以即使禁止了雙面渲染仍然不能解決這個問題(雖然由于性能問題,還是需要把它設置成 NO
)。
這里有幾種正確的方案:把其他視圖 userInteractionEnabled
屬性 都設置成 NO
來禁止事件傳遞?;蛘吆唵瓮ㄟ^代碼把修改視圖在父視圖的順序。
總結
這一章涉及了一些2D
和3D
的變換。你學習了一些矩陣計算的基礎,以及如何用Core Animation
創建3D
場景。你看到了圖層背后到底是如何呈現的,并且知道了不能把扁平的圖片做成真實的立體效果,最后我們用demo
說明了觸摸事件的處理,視圖中圖層添加的層級順序會比屏幕上顯示的順序更有意義。