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
得以顯示。
既然他們的行為如此相似,為什麼不直接用一個 UIView
或 CALayer
處理所有事件呢?主要是基於兩點考慮:
-
職責不同
UIVIew
的主要職責是負責接收並響應事件;而CALayer
的主要職責是負責顯示 UI。 -
需要複用
在 macOS 和 App 系統上,
NSView
和UIView
雖然行為相似,在實現上卻有著顯著的區別,卻又都依賴於CALayer
。在這種情況下,只能封裝一個CALayer
出來。
CALayerDelegate
你可以使用 delegate (CALayerDelegate)
物件來提供圖層的內容,處理任何子圖層的佈局,並提供自定義操作以響應與圖層相關的更改。如果圖層是由 UIView
建立的,則該 UIView
物件通常會自動指定為圖層的委託。
注意:
- 在 iOS 中,如果圖層與
UIView
物件關聯,則必須將此屬性設定為擁有該圖層的UIView
物件。delegate
只是另一種為圖層提供處理內容的方式,並不是唯一的。UIView
的顯示跟它圖層委託沒有太大關係。
-
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 系統上,它能接受CGImage
和NSImage
兩種型別的物件。你可以把它想象中
UIImageView
中的image
屬性,實際上是,UIImageView
在內部通過轉換,將image.cgImage
賦值給了contents
。注意:
如果是 view 的圖層,應避免直接設定此屬性的內容。檢視和圖層之間的相互作用通常會導致檢視在後續更新期間替換此屬性的內容。
-
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:)
方法裡面建立和配置的。具體的呼叫順序是:
- 首先呼叫圖層的
draw(_:in:)
方法; - 隨後在
super.draw(_:in:)
方法裡面建立並配置好繪圖環境; - 通過圖層的
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
) 的方法。我隨意猜測一下是這樣的:首先錯誤提示的意思翻譯過來就是:無法識別的選擇器(方法)傳送到例項。那我們來分析一下,是哪一個例項中?是什麼方法?
- 是
super
例項; - 是
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()
方法來呼叫。 -
-
func layerWillDraw(_ layer: CALayer)
在
draw(_ layer: CALayer, in ctx: CGContext)
呼叫之前呼叫,可以使用此方法配置影響內容的任何圖層狀態(例如contentsFormat
和isOpaque
)。 -
func layoutSublayers(of layer: CALayer)
和
UIView
的layoutSubviews()
類似。當發現邊界發生變化並且其sublayers
可能需要重新排列時(例如通過frame
改變大小),將呼叫此方法。 -
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
屬性:
CALayer 座標系
CALayer
具有除了 frame
、bounds
之外區別於 UIView
的其他位置屬性。UIView
使用的所謂 frame
、bounds
、center
等屬性,其實都是從 CALayer
中返回的,而 frame
只是 CALayer
中的一個計算型屬性而已。
這裡主要說一下 CALayer
中的 anchorPoint
和 position
這兩個屬性,也是 CALayer
座標系中的主要依賴:
-
var anchorPoint: CGPoint ( 錨點 )
圖層錨點示意圖 看 iOS 部分即可。可以看出,錨點是基於圖層的內部座標,它取值範圍是 (0-1, 0-1) ,你可以把它想象成是
bounds
的縮放因子。中間的 (0.5, 0.5) 是每個圖層的anchorPoint
預設值;而左上角的 (0.0, 0.0) 被視為是anchorPoint
的起始點。任何基於圖層的幾何操作都發生在指定點附近。例如,將旋轉變換應用於具有預設錨點的圖層會導致圍繞其中心旋轉,錨點更改為其他位置將導致圖層圍繞該新點旋轉。
錨點影響圖層變換示意圖 -
var position: CGPoint ( 錨點所處的位置 )
錨點影響圖層的位置示意圖 看 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可以套用如下公式:position.x = frame.origin.x + 0.5 * bounds.size.width
;position.y = frame.origin.y + 0.5 * bounds.size.height
。
因為裡面的 0.5 是
anchorPoint
的預設值,更通用的公式應該是:position.x = frame.origin.x + anchorPoint.x * bounds.size.width
;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
就是 anchorPoint
;position
就是 position
。他們本身其實是沒有關聯的,因為它們預設處在的位置正好重疊了,所以就給我們造成了一種誤區,認為 position
就一定是 anchorPoint
所在的那個點。
和 frame 之間的關係
CALayer
的 frame
在文件中被描述為是一個計算型屬性,它是從 bounds
、anchorPoint
和 position
的值中派生出來的。為此屬性指定新值時,圖層會更改其 position
和 bounds
屬性以匹配您指定的矩形。
那它們是如何決定 frame
的?根據圖片可以套用如下公式:
frame.x = position.x - anchorPoint.x * bounds.size.width
;frame.y = position.y - anchorPoint.y * bounds.size.height
。
這就解釋了為什麼修改 position
和 anchorPoint
會導致 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 * 120
、frame.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 ):
仔細觀察一下 "圖2" 就會發現,不管是新錨點還是舊錨點,它們在自身圖層中的位置中都沒有變化。既然錨點本身不會變化,那變化的就只能是 x
了。x
是如何變化的?從圖片中可以很清楚地看到,是把新錨點移動到舊錨點的所在位置。這也是大部分人的誤區,以為修改 0.5 -> 0.2 就是把舊的錨點移動到新錨點的所在位置,結果恰恰相反,這就是造成修改 anchorPoint
往往和自己想象中不太一樣的原因。
還有一種比較好理解的方式就是,想象一下,假設 "圖1" 中的底部紅色圖層是一張紙,而中間的白點相當於一枚大頭釘固定在它中間,移動的時候,你就按住中間的大頭釘讓其保持不動。這時候假設你要開始移動到任意點了,那你會怎麼做呢?唯一的一種方式就是,移動整個圖層,讓新的錨點順著舊錨點中的位置靠攏,最終完全重合,就算移動完成了。