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,這一特性已經有所不同。 ?

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容