iOS 中 UIView 和 CALayer 的關係

一個絕望的氣純發表於2019-01-16

UIView 有一個名叫 layer ,型別為 CALayer 的物件屬性,它們的行為很相似,主要區別在於:CALayer 繼承自 NSObject ,不能夠響應事件

這是因為 UIView 除了負責響應事件 ( 繼承自 UIReponder ) 外,它還是一個對 CALayer 的底層封裝。可以說,它們的相似行為都依賴於 CALayer 的實現,UIView 只不過是封裝了它的高階介面而已。

CALayer 是什麼呢?

CALayer(圖層)

文件對它定義是:管理基於影象內容的物件,允許您對該內容執行動畫

概念

圖層通常用於為 view 提供後備儲存,但也可以在沒有 view 的情況下使用以顯示內容。圖層的主要工作是管理您提供的可視內容,但圖層本身可以設定可視屬性(例如背景顏色、邊框和陰影)。除了管理可視內容外,該圖層還維護有關內容幾何的資訊(例如位置、大小和變換),用於在螢幕上顯示該內容

和 UIView 之間的關係

示例1 - layer 影響檢視的變化:

let view = UIView(frame: CGRect(x: 44, y: 44, width: UIScreen.width - 88, height: 300))
view.backgroundColor = .red
view.layer.backgroundColor = UIColor.orange.cgColor

print("view: \(view.backgroundColor!)")
print("layer: \(view.layer.backgroundColor!)")

// Prints "view: 1 0.5 0 1"
// Prints "layer: 1 0.5 0 1"

view.layer.frame.origin.y = 100

print("view: \(view.frame.origin.y)")
print("layer: \(view.layer.frame.origin.y)")

// Prints "view: 100"
// Prints "layer: 100"
複製程式碼

可以看到,無論是修改了 layer 的可視內容或是幾何資訊,view 都會跟著變化,反之也是如此。這就證明:UIView 依賴於 CALayer 得以顯示。

既然他們的行為如此相似,為什麼不直接用一個 UIViewCALayer 處理所有事件呢?主要是基於兩點考慮:

  1. 職責不同

    UIVIew 的主要職責是負責接收並響應事件;而 CALayer 的主要職責是負責顯示 UI。

  2. 需要複用

    在 macOS 和 App 系統上,NSViewUIView 雖然行為相似,在實現上卻有著顯著的區別,卻又都依賴於 CALayer 。在這種情況下,只能封裝一個 CALayer 出來。

CALayerDelegate

你可以使用 delegate (CALayerDelegate) 物件來提供圖層的內容,處理任何子圖層的佈局,並提供自定義操作以響應與圖層相關的更改。如果圖層是由 UIView 建立的,則該 UIView 物件通常會自動指定為圖層的委託。

注意:

  1. 在 iOS 中,如果圖層與 UIView 物件關聯,則必須將此屬性設定為擁有該圖層的 UIView 物件。
  2. delegate 只是另一種為圖層提供處理內容的方式,並不是唯一的。UIView 的顯示跟它圖層委託沒有太大關係。
  1. func display(_ layer: CALayer)

    當圖層標記其內容為需要更新 ( setNeedsDisplay() ) 時,呼叫此方法。例如,為圖層設定 contents 屬性:

    let delegate = LayerDelegate()
         
    lazy var sublayer: CALayer = {
        let layer = CALayer()
        layer.delegate = self.delegate
        return layer
    }()
         
    // 呼叫 `sublayer.setNeedsDisplay()` 時,會呼叫 `sublayer.display(_:)`。
    class LayerDelegate: NSObject, CALayerDelegate {
        func display(_ layer: CALayer) {
            layer.contents = UIImage(named: "rabbit.png")?.cgImage
        }
    }
    複製程式碼

    那什麼是 contents 呢?contents 被定義為是一個 Any 型別,但實際上它只作用於 CGImage 。造成這種奇怪的原因是,在 macOS 系統上,它能接受 CGImageNSImage 兩種型別的物件。

    你可以把它想象中 UIImageView 中的 image 屬性,實際上是,UIImageView 在內部通過轉換,將 image.cgImage 賦值給了 contents

    注意:

    如果是 view 的圖層,應避免直接設定此屬性的內容。檢視和圖層之間的相互作用通常會導致檢視在後續更新期間替換此屬性的內容。

  2. func draw(_ layer: CALayer, in ctx: CGContext)

    display(_:) 一樣,但是可以使用圖層的 CGContext 來實現顯示的過程(官方示例):

    // sublayer.setNeedsDisplay()
    class LayerDelegate: NSObject, CALayerDelegate {
        func draw(_ layer: CALayer, in ctx: CGContext) {
            ctx.addEllipse(in: ctx.boundingBoxOfClipPath)
            ctx.strokePath()
        }
    }
    複製程式碼
    • 和 view 中 draw(_ rect: CGRect) 的關係

      文件對其的解釋大概是:

      此方法預設不執行任何操作。使用 Core Graphics 和 UIKit 等技術繪製檢視內容的子類應重寫此方法,並在其中實現其繪圖程式碼。 如果檢視以其他方式設定其內容,則無需覆蓋此方法。 例如,如果檢視僅顯示背景顏色,或是使用基礎圖層物件直接設定其內容等。

      呼叫此方法時,在呼叫此方法的時候,UIKit 已經配置好了繪圖環境。具體來說,UIKit 建立並配置用於繪製的圖形上下文,並調整該上下文的變換,使其原點與檢視邊界矩形的原點匹配。可以使用 UIGraphicsGetCurrentContext() 函式獲取對圖形上下文的引用(非強引用)。

      那它是如何建立並配置繪圖環境的?我在調查它們的關係時發現:

      /// 注:此方法預設不執行任何操作,呼叫 super.draw(_:) 與否並無影響。
      override func draw(_ rect: CGRect) {
          print(#function)
      }
      
      override func draw(_ layer: CALayer, in ctx: CGContext) {
          print(#function)
      }
      
      // Prints "draw(_:in:)"
      複製程式碼

      這種情況下,只輸出圖層的委託方法,而螢幕上沒有任何 view 的畫面顯示。而如果呼叫圖層的 super.draw(_:in:) 方法:

      /// 注:此方法預設不執行任何操作,呼叫 super.draw(_:) 與否並無影響。
      override func draw(_ rect: CGRect) {
          print(#function)
      }
      
      override func draw(_ layer: CALayer, in ctx: CGContext) {
          print(#function)
          super.draw(layer, in: ctx)
      }
      
      // Prints "draw(_:in:)"
      // Prints "draw"
      複製程式碼

      螢幕上有 view 的畫面顯示,為什麼呢?首先我們要知道,在呼叫 view 的 draw(_:in:) 時,它需要一個載體/畫板/圖形上下文 ( UIGraphicsGetCurrentContext ) 來進行繪製操作。所以我猜測是,這個 UIGraphicsGetCurrentContext 是在圖層的 super.draw(_:in:) 方法裡面建立和配置的。

      具體的呼叫順序是:

      1. 首先呼叫圖層的 draw(_:in:) 方法;
      2. 隨後在 super.draw(_:in:) 方法裡面建立並配置好繪圖環境;
      3. 通過圖層的 super.draw(_:in:) 呼叫 view 的 draw(_:) 方法。

      此外,還有另一種情況是:

      override func draw(_ layer: CALayer, in ctx: CGContext) {
          print(#function)
      }
      複製程式碼

      只實現一個圖層的 draw(_:in:) 方法,並且沒有繼續呼叫它的 super.draw(_:in:) 來建立繪圖環境。那在沒有繪圖環境的時候,view 能顯示嗎?答案是可以的!這是因為:view 的顯示不依賴於 UIGraphicsGetCurrentContext ,只有在繪製的時候才需要。

    • contents 之間的關係

      經過測試發現,呼叫 view 中 draw(_ rect: CGRect) 方法的所有繪製操作,都被儲存在其圖層的 contents 屬性中:

      // ------ LayerView.swift ------
      override func draw(_ rect: CGRect) {
          UIColor.brown.setFill()	// 填充
          UIRectFill(rect)
      
          UIColor.white.setStroke()	// 描邊
          let frame = CGRect(x: 20, y: 20, width: 80, height: 80)
          UIRectFrame(frame)
      }
      
      // ------ ViewController.swift ------
      DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
          print("contents: \(self.layerView.layer.contents)")
      }
      
      // Prints "Optional(<CABackingStore 0x7faf91f06e20 (buffer [480 256] BGRX8888)>)"
      複製程式碼

      這也是為什麼要 CALayer 提供繪圖環境、以及在上面介紹 contents 這個屬性時需要注意的地方。

      重要:

      如果委託實現了 display(_ :) ,將不會呼叫此方法。

    • display(_ layer: CALayer) 之間的關係

      前面說過,view 的 draw(_:) 方法是由它圖層的 draw(_:in:) 方法呼叫的。但是如果我們實現的是 display(_:) 而不是 draw(_:in:) 呢?這意味著 draw(_:in:) 失去了它的作用,在沒有上下文的支援下,螢幕上將不會有任何關於 view 的畫面顯示,而 display(_:) 也不會自動呼叫 view 的 draw(_:) ,view 的 draw(_:) 方法也失去了意義,那 display(_ layer: CALayer) 的作用是什麼?例如:

      override func draw(_ rect: CGRect) {
          print(#function)
      }
      
      override func display(_ layer: CALayer) {
          print(#function)
      }
      
      // Prints "display"
      複製程式碼

      這裡 draw(_:) 沒有被呼叫,螢幕上也沒有相關 view 的顯示。也就是說,此時除了在 display(_:) 上進行操作外,已經沒有任何相關的地方可以設定圖層的可視內容了(參考 "1. func display(_ layer: CALayer)",這裡不再贅述,當然也可以設定背景顏色等)。當然,你可能永遠都不會這麼做,除非你建立了一個單獨的圖層。

      至於為什麼不在 display(_ layer: CALayer) 方法裡面呼叫它的父類實現,這是因為如果呼叫了會崩潰:

      // unrecognized selector sent to instance 0x7fbcdad03ba0
      複製程式碼

      至於為什麼?根據我的參考資料,他們都沒有在此繼續呼叫 super ( UIView ) 的方法。我隨意猜測一下是這樣的:

      首先錯誤提示的意思翻譯過來就是:無法識別的選擇器(方法)傳送到例項。那我們來分析一下,是哪一個例項中?是什麼方法?

      1. super 例項;
      2. display(_ layer: CALayer)

      也就是說,在呼叫 super.display(_ layer: CALayer) 方法的時候,super 中找不到該方法。為什麼呢?請注意 UIView 預設已經遵循了 CALayerDelegate 協議(右鍵點選 UIView 檢視標頭檔案),但是應該沒有實現它的 display(_:) 方法,而是選擇交給了子類去實現。類似的實現應該是:

      // 示意 `CALayerDelegate`
      @objc protocol LayerDelegate: NSObjectProtocol {
          @objc optional func display()
          @objc optional func draw()
      }
      
      // 示意 `CALayer`
      class Layer: NSObject {
          var delegate: LayerDelegate?
      }
      
      // 示意 `UIView`
      class BaseView: NSObject, LayerDelegate {
          let layer = Layer()
          override init() {
              super.init()
              layer.delegate = self
          }
      }
      // 注意:並沒有實現委託的 `display()` 方法。
      extension BaseView: LayerDelegate {
          func draw() {}
      }
      
      // 示意 `UIView` 的子類
      class LayerView: BaseView {
          func display() {
              // 同樣的程式碼在OC上實現沒有問題。
              // 由於Swift是靜態編譯的關係,它會檢測在 `BaseView` 類中有沒有這個方法,
              // 如果沒有就會提示編譯錯誤。
              super.display()
          }
      }
      
      // ------ ViewController.swift ------
      let layerView = LayerView()
      // 如果在方法裡面呼叫了 `super.display()` 將引發崩潰。
      layerView.display()
      // 正常執行
      layerView.darw()
      複製程式碼

    注意:

    只有當系統在檢測到 view 的 draw(_:) 方法被實現時,才會自動呼叫圖層的 display(_:)draw(_ rect: CGRect) 方法。否則就必須通過手動呼叫圖層的 setNeedsDisplay() 方法來呼叫。

  3. func layerWillDraw(_ layer: CALayer)

    draw(_ layer: CALayer, in ctx: CGContext) 呼叫之前呼叫,可以使用此方法配置影響內容的任何圖層狀態(例如 contentsFormatisOpaque )。

  4. func layoutSublayers(of layer: CALayer)

    UIViewlayoutSubviews() 類似。當發現邊界發生變化並且其 sublayers 可能需要重新排列時(例如通過 frame 改變大小),將呼叫此方法。

  5. func action(for layer: CALayer, forKey event: String) -> CAAction?

    CALayer 之所以能夠執行動畫,是因為它被定義在 Core Animation 框架中,是 Core Animation 執行操作的核心。也就是說,CALayer 除了負責顯示內容外,還能執行動畫(其實是 Core Animation 與硬體之間的操作在執行,CALayer 負責儲存操作需要的資料,相當於 Model)。因此,使用 CALayer 的大部分屬性都附帶動畫效果。但是在 UIView 中,預設將這個效果給關掉了,可以通過它圖層的委託方法重新開啟 ( 在 view animation block 中也會自動開啟 ),返回決定它動畫特效的物件,如果返回的是 nil ,將使用預設隱含的動畫特效。

    示例 - 使用圖層的委託方法返回一個從左到右移動物件的基本動畫:

    final class CustomView: UIView {
        override func action(for layer: CALayer, forKey event: String) -> CAAction? {
            guard event == "moveRight" else {
                return super.action(for: layer, forKey: event)
            }
            let animation = CABasicAnimation()
            animation.valueFunction = CAValueFunction(name: .translateX)
            animation.fromValue = 1
            animation.toValue = 300
            animation.duration = 2
            return animation
        }
    }
    
    let view = CustomView(frame: CGRect(x: 44, y: 44, width: UIScreen.width - 88, height: 300))
    view.backgroundColor = .orange
    self.view.addSubview(view)
    
    let action = view.layer.action(forKey: "moveRight")
    action?.run(forKey: "transform", object: view.layer, arguments: nil)
    複製程式碼

    那怎麼知道它的哪些屬性是可以附帶動畫的呢?核心動畫程式設計指南列出了你可能需要考慮設定動畫的 CALayer 屬性:

    iOS 中 UIView 和 CALayer 的關係

CALayer 座標系

CALayer 具有除了 framebounds 之外區別於 UIView 的其他位置屬性。UIView 使用的所謂 frameboundscenter 等屬性,其實都是從 CALayer 中返回的,而 frame 只是 CALayer 中的一個計算型屬性而已。

這裡主要說一下 CALayer 中的 anchorPointposition 這兩個屬性,也是 CALayer 座標系中的主要依賴:

  • var anchorPoint: CGPoint ( 錨點 )

    圖層錨點示意圖
    iOS 中 UIView 和 CALayer 的關係

    看 iOS 部分即可。可以看出,錨點是基於圖層的內部座標,它取值範圍是 (0-1, 0-1) ,你可以把它想象成是 bounds 的縮放因子。中間的 (0.5, 0.5) 是每個圖層的 anchorPoint 預設值;而左上角的 (0.0, 0.0) 被視為是 anchorPoint 的起始點。

    任何基於圖層的幾何操作都發生在指定點附近。例如,將旋轉變換應用於具有預設錨點的圖層會導致圍繞其中心旋轉,錨點更改為其他位置將導致圖層圍繞該新點旋轉。

    錨點影響圖層變換示意圖
    iOS 中 UIView 和 CALayer 的關係
  • var position: CGPoint ( 錨點所處的位置 )

    錨點影響圖層的位置示意圖
    iOS 中 UIView 和 CALayer 的關係

    看 iOS 部分即可。圖1中的 position 被標記為了 (100, 100) ,怎麼來的?

    對於錨點來說,它在父圖層中有著更詳細的座標。對 position 通俗來解釋一下,就是錨點在父圖層中的位置

    一個圖層它的預設錨點是 (0.5, 0.5) ,既然如此,那就先看下錨點 x 在父圖層中的位置,可以看到,從父圖層 x 到錨點 x 的位置是 100,那麼此時的 position.x 就是 100;而 y 也是類似的,從父圖層 y 到錨點 y 的位置也是 100;則可以得出,此時錨點在父圖層中的座標是 (100, 100) ,也就是此時圖層中 position 的值。

    對圖2也是如此,此時的錨點處於起始點位置 (0.0, 0.0) ,從父圖層 x 到錨點 x 的位置是 40;而從父圖層 y 到錨點 y 的位置是 60 ,由此得出,此時圖層中 position 的值是 (40, 60)

    這裡其實計算 position 是有公式的,根據圖1可以套用如下公式:

    1. position.x = frame.origin.x + 0.5 * bounds.size.width
    2. position.y = frame.origin.y + 0.5 * bounds.size.height

    因為裡面的 0.5 是 anchorPoint 的預設值,更通用的公式應該是:

    1. position.x = frame.origin.x + anchorPoint.x * bounds.size.width
    2. position.y = frame.origin.y + anchorPoint.y * bounds.size.height

    注意:

    實際上,position 就是 UIView 中的 center 。如果我們修改了圖層的 position ,那麼 view 的 center 會隨之改變,反之也是如此。

anchorPoint 和 position 之間的關係

前面說過,position 處於錨點中的位置(相對於父圖層)。這裡就有一個問題,那就是,既然 position 相對於 anchorPoint ,那如果修改了 anchorPoint 會不會導致 position 的變化?結論是不會:

let redView = UIView(frame: CGRect(x: 40, y: 60, width: 120, height: 80))
print(self.redView.layer.position)	// Prints "(100.0, 100.0)"
redView.layer.anchorPoint = CGPoint(x: 0, y: 1)
print(self.redView.layer.position)	// Prints "(100.0, 100.0)"
複製程式碼

那修改了 position 會導致 anchorPoint 的變化嗎?結論是也不會:

let redView = UIView(frame: CGRect(x: 40, y: 60, width: 120, height: 80))
print(redView.layer.anchorPoint)	// Prints "(0.5, 0.5)"
redView.layer.anchorPoint = CGPoint(x: 0, y: 1)
print(redView.layer.anchorPoint)	// Prints "(0.5, 0.5)"
複製程式碼

經過測試,無論修改了誰另一方都不會受到影響,受到影響的只會是 frame.origin 。至於為什麼兩者互不影響,我暫時還沒想到。我隨意猜測一下是這樣的:

其實 anchorPoint 就是 anchorPointposition 就是 position 。他們本身其實是沒有關聯的,因為它們預設處在的位置正好重疊了,所以就給我們造成了一種誤區,認為 position 就一定是 anchorPoint 所在的那個點。

和 frame 之間的關係

CALayerframe文件中被描述為是一個計算型屬性,它是從 boundsanchorPointposition 的值中派生出來的。為此屬性指定新值時,圖層會更改其 positionbounds 屬性以匹配您指定的矩形。

那它們是如何決定 frame 的?根據圖片可以套用如下公式:

  1. frame.x = position.x - anchorPoint.x * bounds.size.width
  2. frame.y = position.y - anchorPoint.y * bounds.size.height

這就解釋了為什麼修改 positionanchorPoint 會導致 frame 發生變化,我們可以測試一下,假設把錨點改為處在左下角 (0.0, 1.0) :

let redView = UIView(frame: CGRect(x: 40, y: 60, width: 120, height: 80))
redView.layer.anchorPoint = CGPoint(x: 0, y: 1)
print(redView.frame.origin)	// Prints "(100.0, 20.0)"
複製程式碼

用公式來計算就是:frame.x (100) = 100 - 0 * 120frame.y (20) = 100 - 1 * 80 ;正好和列印的結果相符。反之,修改 position 屬性也會導致 frame.origin 發生如公式般的變化,這裡就不再贅述了。

注意:

如果修改了 frame 的值是會導致 position 發生變化的,因為 position 是基於父圖層定義的;frame 的改變意味著它自身的位置在父圖層中有所改變,position 也會因此改變。

但是修改了 frame 並不會導致 anchorPoint 發生變化,因為 anchorPoint 是基於自身圖層定義的,無論外部怎麼變,anchorPoint 都不會跟著變化。

修改 anchorPoint 所帶來的困惑

對於修改 position 來說其實就是修改它的 "center" ,這裡很容易理解。但是對於修改 anchorPoint ,相信很多人都有過同樣的困惑,為什麼修改了 anchorPoint 所帶來的變化往往和自己想象中的不太一樣呢?來看一個修改錨點 x 的例子 ( 0.5 → 0.2 ):

iOS 中 UIView 和 CALayer 的關係

仔細觀察一下 "圖2" 就會發現,不管是新錨點還是舊錨點,它們在自身圖層中的位置中都沒有變化。既然錨點本身不會變化,那變化的就只能是 x 了。x 是如何變化的?從圖片中可以很清楚地看到,是把新錨點移動到舊錨點的所在位置。這也是大部分人的誤區,以為修改 0.5 -> 0.2 就是把舊的錨點移動到新錨點的所在位置,結果恰恰相反,這就是造成修改 anchorPoint 往往和自己想象中不太一樣的原因。

還有一種比較好理解的方式就是,想象一下,假設 "圖1" 中的底部紅色圖層是一張紙,而中間的白點相當於一枚大頭釘固定在它中間,移動的時候,你就按住中間的大頭釘讓其保持不動。這時候假設你要開始移動到任意點了,那你會怎麼做呢?唯一的一種方式就是,移動整個圖層,讓新的錨點順著舊錨點中的位置靠攏,最終完全重合,就算移動完成了

參考

徹底理解position與anchorPoint

核心動畫程式設計指南

iOS 核心動畫:高階技巧

蘋果 UIView 文件

蘋果 CALayer 文件

相關文章