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 得以显示。

既然他们的行为如此相似,为什么不直接用一个 UIViewCALayer 处理所有事件呢?主要是基于两点考虑:

  1. 职责不同
    UIVIew 的主要职责是负责接收并响应事件;而 CALayer 的主要职责是负责显示 UI。

  2. 需要复用
    在 macOS 和 App 系统上,NSViewUIView 虽然行为相似,在实现上却有着显著的区别,却又都依赖于 CALayer 。在这种情况下,只能封装一个 CALayer 出来。

CALayerDelegate

你可以使用 delegate (CALayerDelegate) 对象来提供图层的内容,处理任何子图层的布局,并提供自定义操作以响应与图层相关的更改。如果图层是由 UIView 创建的,则该 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

  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(_ 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()
  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 的关系-LMLPHP

CALayer 坐标系

CALayer 具有除了 framebounds 之外区别于 UIView 的其他位置属性。UIView 使用的所谓 frameboundscenter 等属性,其实都是从 CALayer 中返回的,而 frame 只是 CALayer 中的一个计算型属性而已。

这里主要说一下 CALayer 中的 anchorPointposition 这两个属性,也是 CALayer 坐标系中的主要依赖:

  • var anchorPoint: CGPoint ( 锚点 )

    iOS 中 UIView 和 CALayer 的关系-LMLPHP

    看 iOS 部分即可。可以看出,锚点是基于图层的内部坐标,它取值范围是 (0-1, 0-1) ,你可以把它想象成是 bounds 的缩放因子。中间的 (0.5, 0.5) 是每个图层的 anchorPoint 默认值;而左上角的 (0.0, 0.0) 被视为是 anchorPoint 的起始点。

    任何基于图层的几何操作都发生在指定点附近。例如,将旋转变换应用于具有默认锚点的图层会导致围绕其中心旋转,锚点更改为其他位置将导致图层围绕该新点旋转。

    iOS 中 UIView 和 CALayer 的关系-LMLPHP
  • var position: CGPoint ( 锚点所处的位置 )

    iOS 中 UIView 和 CALayer 的关系-LMLPHP

    看 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

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 发生如公式般的变化,这里就不再赘述了。

修改 anchorPoint 所带来的困惑

对于修改 position 来说其实就是修改它的 "center" ,这里很容易理解。但是对于修改 anchorPoint ,相信很多人都有过同样的困惑,为什么修改了 anchorPoint 所带来的变化往往和自己想象中的不太一样呢?来看一个修改锚点 x 的例子 ( 0.5 → 0.2 ):

iOS 中 UIView 和 CALayer 的关系-LMLPHP

仔细观察一下 "图2" 就会发现,不管是新锚点还是旧锚点,它们在自身图层中的位置中都没有变化。既然锚点本身不会变化,那变化的就只能是 x 了。x 是如何变化的?从图片中可以很清楚地看到,是把新锚点移动到旧锚点的所在位置。这也是大部分人的误区,以为修改 0.5 -> 0.2 就是把旧的锚点移动到新锚点的所在位置,结果恰恰相反,这就是造成修改 anchorPoint 往往和自己想象中不太一样的原因。

还有一种比较好理解的方式就是,想象一下,假设 "图1" 中的底部红色图层是一张纸,而中间的白点相当于一枚大头钉固定在它中间,移动的时候,你就按住中间的大头钉让其保持不动。这时候假设你要开始移动到任意点了,那你会怎么做呢?唯一的一种方式就是,移动整个图层,让新的锚点顺着旧锚点中的位置靠拢,最终完全重合,就算移动完成了

参考

彻底理解position与anchorPoint
核心动画编程指南
iOS 核心动画:高级技巧
苹果 UIView 文档
苹果 CALayer 文档

05-11 00:19