iOS 中 UIView 和 CALayer 的关系
Posted free-thinker
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了iOS 中 UIView 和 CALayer 的关系相关的知识,希望对你有一定的参考价值。
UIView
有一个名叫 layer
,类型为 CALayer
的对象属性,它们的行为很相似,主要区别在于:CALayer
继承自 NSObject
,不能够响应事件。
这是因为 UIView
除了负责响应事件 ( 继承自 UIReponder
) 外,它还是一个对 CALayer
的底层封装。可以说,它们的相似行为都依赖于 CALayer
的实现,UIView
只不过是封装了它的高级接口而已。
那 CALayer
是什么呢?
CALayer(图层)
文档对它定义是:管理基于图像内容的对象,允许您对该内容执行动画。
概念
图层通常用于为 view 提供后备存储,但也可以在没有 view 的情况下使用以显示内容。图层的主要工作是管理您提供的可视内容,但图层本身可以设置可视属性(例如背景颜色、边框和阴影)。除了管理可视内容外,该图层还维护有关内容几何的信息(例如位置、大小和变换),用于在屏幕上显示该内容。
和 UIView 之间的关系
示例1 - CALayer
影响 UIVIew
的变化:
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" 中的底部红色图层是一张纸,而中间的白点相当于一枚大头钉固定在它中间,移动的时候,你就按住中间的大头钉让其保持不动。这时候假设你要开始移动到任意点了,那你会怎么做呢?唯一的一种方式就是,移动整个图层,让新的锚点顺着旧锚点中的位置靠拢,最终完全重合,就算移动完成了。
参考
彻底理解position与anchorPoint
核心动画编程指南
iOS 核心动画:高级技巧
苹果 UIView 文档
苹果 CALayer 文档
以上是关于iOS 中 UIView 和 CALayer 的关系的主要内容,如果未能解决你的问题,请参考以下文章
iOS:为啥 UIView 的 drawRect 比 CALayer 的 drawInContext 性能好
iOS动画:UIView动画和CALayer动画(CABasicAnimationCAKeyframeAnimation的使用)