《iOS 三问》 -- CALayer 基础

CALayer 知识点脑图

本章将介绍 iOS UI 编程的一个重要角色 CALayer,它是 UIView 即 iOS 各种控件之所以能显示在界面上的基础,负责把 View 绘制出来。

CALayer 的前缀 CA,就是 Core Animation,会让人感觉 CALayer 这个类主要是用来做动画的。但是实质上就 CALayer 这个类来说,其主要职责还是绘制界面(当然通过我们后面的介绍大家对整个 Core Animation 包掌握之后,就会了解到 UI 绘制其实是动画实现的基础)。所以,当我们学习 CALayer 时,主要精力还是先放在其绘制界面上来。

我为什么这么说呢,其实还是可以解释一下的。因为它是从一个叫做 Layer Kit 这么一个不怎么和动画有关的名字演变而来,可见其之前是专注于图层绘制的。

  • 绘图、布局、动画

我们之前说过,对于 UI 系统的视觉方面,我们可以从 “绘图、布局、动画” 几个维度进行学习。而 CALayer,就是一个涵盖了这几个维度的在 iOSUI 编程方面有重要地位的工具。

1 什么是 CALayer,它与 UIView 是什么关系

Core Animation 是一个复合引擎,它的职责就是尽可能快地组合屏幕上不同的可视内容,这个内容是被分解成独立的图层,存储在一个叫做图层树的体系之中。于是这个树形成了 UIKit 以及在 iOS 应用程序当中你所能在屏幕上看见的一切的基础。

  • CALayer 与 UIView 关系 -> CALayer 是怎样显示 UI -> 基本的显示属性与变型

首先我将介绍什么是 CALayer,它和 UIView 的关系是什么;接着介绍 CALayer 是怎样输出图像显示在屏幕上的。作为 CALayer 基础,本章不会说太多具体内容,对于 CALayer,我们仍然会以 “绘图、布局、动画” 几个维度进行详细的介绍。

CALayer 就是界面的图层,像是屏幕中叠加起来的多张画,它有许多方便的属性,通过设置这些属性,可以设置画的透明度、边框、阴影,对图层进行拉伸、几何变型;你可以管理多张这样的图层,对它们进行布局定位、排列、层级管理;还可以定义动画,来实现炫酷的效果与交互。

本章会详细介绍下 CALayer 的概念,介绍下它的绘图与布局,后续章节将介绍它的边框、阴影等特效属性,几何变型,几种专用的图层工具。

要知道什么是 CALayer,必须先搞清楚 CALayer 与 UIView 的关系(下面说到 UIView 与 CALayer 时,也会简写成 view 与 layer)。

1.1 CALayer 与 UIView 的关系

在看下面 UIView 与 CALayer 关系时,我们可以先作个比喻,UIView 是一幅 PS 画出的画,而实际上这幅画是由多个图层叠加而成的,这其中的 1 个或多个图层我们称之为 layer

从这个比喻可以看出:

  1. view 就像是一个像框,其实不是具体的一个绘制图层,更像是多张图层的载体,将它们层叠之后的结果展示,提供一个可视空间;
  2. 一个 View 起码得有一个 layer(UIView 确实 init 时就自动创建一个内置的 layer)。
  3. UIView 之所以能绘制到屏幕,靠的就是 CALayer。UIView 与 CALayer 实际上是共用同一个 graphics context, view 使用 layer 绘制图象到屏幕上,layer 会将绘制的结果缓存 (cache) 之(在创建 UIView 对象时,UIView 内部会自动创建一个层 (即 CALayer 对象),通过 UIView 的 layer 属性可以访问这个层。当 UIView 需要显示到屏幕上时,会调用 drawRect: 方法进行绘图,并且会将所有内容绘制在自己的层上,绘图完毕后,系统会将层拷贝到屏幕上,于是就完成了 UIView 的显示);
  4. UIView 是 CALayer 的载体或容器,给 layer 提供了可视空间。view 必关联一个或多个 layer (至少一个),我们可以通过 UIView 的 layer 属性获得关联 layer;反之,也可以通过 CALayer 的 delegate 属性获取容纳它的 UIView。
  5. UIView 的层级关系决定 Layer 的层级 (layer 层次是 view 层次的子集,也就是 view 层次改变会改变 layer 层次,但是 layer 层次改变不会改变 view 层次,想下多本相簿与相片的关系)。
  6. 相较于 Layer,UIView 多了事件处理功能。也就是说 layer 不能处理用户的触摸事件,但是 UIView 可以。

1.1.1 从代码上看 CALayer 与 UIView 之间的关系

  1. 我们可以通过 UIView 的 layer 属性获得关联 layer,也可以通过 view 的***+(Class) layerCalss***方法设置 view 所关联的具体 layer。layer 也可以通过其 delegate 方法获得关联的 view。
  2. view 是通过 layer 绘图的,比如你设置 view 的 backgroundColor,实际上是设置 layer 的 backgroundColor;
  3. view 是 layer 的容器,view 的 frame 就是 layer 的 frame,view 决定了 layer 的可视空间;
  4. view 使用 layer 绘制图象到屏幕上,layer 会将绘制的结果缓存 (cache) 之。比如,当 view 的 bounds 改变,实际上不需要对 view 进行重绘,而是拉伸其 layer 的 cached image。

1.1.2 为什么要用 UIView 共 CALayer 并存这样的设计

之前介绍 UIView 时我们知道,UIView 有层级关系,同样,CALayer 也有层级关系:

  1. layer 也可以通过 addSublayer 添加子层;
  2. layer 层级会继承 view 的层级关系。这是什么意思呢,就是如果有个视图层级关系 viewA 包含 viewB,那么同样,在关联图层中也会有这样的层级关系,layerA 包含 layerB。

为什么 iOS 要基于 UIView 和 CALayer 提供两个平行的层级关系呢?为什么不用一个简单的层级来处理所有事情呢?

原因在于要做职责分离,这样也能避免很多重复代码。在 iOS 和 Mac OS 两个平台上,事件和用户交互有很多地方的不同, 基于多点触控的用户界面和基于鼠标键盘有着本质的区别,这就是为什么 iOS 有 UIKit 和 UIView ,但是 Mac OS 有 AppKit 和 NSView 的原因。他们功能上很相似,但是在实现上有着显著的区别。

绘图,布局和动画,相比之下就是类似 Mac 笔记本和桌面系列一样应用于 iPhone 和 iPad 触屏的概念。把这种功能的逻辑分开并应用到独立的 Core Animation 框架, 苹果就能够在 iOS 和 Mac OS 之间共享代码,使得对苹果自己的 OS 开发团队和第三方开发者去开发两个平台的应用更加便捷。

实际上,这里并不是两个层级关系,而是四个,每一个都扮演不同的角色,除了 视图层级和图层树之外,还存在呈现树和渲染树.(后面介绍动画时会有提到这个)

1.2 为什么使用 CALayer,它有什么特性

  1. CALayer 提供了许多与绘图相关的属性 (property,比如加阴影、图角、添加边框等),可以方便地改变 View 的形态。
  2. CALayer 可以通过叠加组合出更多形态与方便管理 view 的绘制。
  3. CALayer 的 CA,是 Core Animation,它是动画的基础。

也就是说 layer 也可以通过addSublayer添加图层与使用相关方法管理 layer 层级。

2 CALayer 的主要属性与方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
--------------- 绘图与显示 -------------

・内容 (比如设置为图片 CGImageRef)
@property (retain) id contents;

- contentsGravity layer 上图片的拉伸方式
- contentsScale layer 显示的拉伸比例
- contentsRect 指定 layer 上显示图片的部分
- contentsCenter 控制 layer 可拉伸的范围


--------------- 布局与定位 --------------
・宽度和高度
@propertyCGRect bounds;

・位置 (默认指中点,具体由 anchorPoint 决定)
@property CGPoint position;

・锚点 (x,y 的范围都是 0-1),决定了 position 的含义
@property CGPoint anchorPoint;


--------------- 显示与特效 --------------

・背景颜色 (CGColorRef 类型)
@property CGColorRef backgroundColor;

・边框颜色 (CGColorRef 类型)
@property CGColorRef borderColor;

・边框宽度
@property CGFloat borderWidth;

・圆角半径
@property CGFloat cornerRadius;

- shadowColor 阴影颜色
- shadowOpacity 阴影透明度
- shadowRadius 阴影半径
- shadowOffset 阴影的位移
- shadowPath 阴影形状(比如圆形的阴影)


--------------- 几何变形 --------------

・形变属性
@property CATransform3D transform;

3 CALayer 是怎样显示的

CALayer 绘图显示在屏幕上有两种主要方式:

  • 一种是直接在图层上显示已准备好的图片;
  • 一种是自己在 Layer 上绘图;

3.1 contents 属性 - 在 layer 上显示图片

CALayer 有一个属性contents,通过给此属性赋值一个 CGImage,可以在图层上显示你赋予的图片。

layer 的显示主要是靠 content,此属性定义为 id,其实是需要一个 CGImage。

  • 为什么 content 类型为 id 呢

因为在这个库在早期 Mac OS 时代就已有之,那时可以显示 CGImage 与 NSImage,在 iOS 设备上,NSImage 已不使用了,所以只剩下赋之以 CGImage。我们要真正赋值的其实是一个 CGImageRef,一个指向 CGImage 的指针,但是如果你要赋值,还需要一个转换:

赋值图层 contents
1
2
layer.contents = (__bridge id)image.CGImage;

  • 示例 - 在 layer 上绘制一张图片
在 layer 上绘制一张图片
1
2
3
4
5
CALayer *contentLayer = [CALayer new];
UIImage *image = [UIImage imageNamed:@"earth"];
contentLayer.frame = CGRectMake(0, 0, image.size.width, image.size.height);
contentLayer.contents = (__bridge id) image.CGImage; // 注意,这里要使用 CGImage
[layerFrame.layer addSublayer:contentLayer];

3.2 与 contents 相关的其它属性

  • contentsGravity layer 上图片的拉伸方式
  • contentsScale layer 显示的拉伸比例
  • contentsRect 指定 layer 上显示图片的部分
  • contentsCenter 控制 layer 可拉伸的范围

与 UIView、UIImageView 一样,只要涉及设置图片的控件,一般都提供了属性设置他们的拉伸方式,CALayer 中的拉伸方式叫 contentGravity(其实和 contentMode 如出一徹,真不知苹果搞这么多定义出来干嘛~)。

layer 中 contents 图片拉伸方式
1
2
3
4
5
6
7
8
9
10
11
12
kCAGravityCenter
kCAGravityTop
kCAGravityBottom
kCAGravityLeft
kCAGravityRight
kCAGravityTopLeft
kCAGravityTopRight
kCAGravityBottomLeft
kCAGravityBottomRight
kCAGravityResize
kCAGravityResizeAspect
kCAGravityResizeAspectFill

contentsScale: 在示例中,我们可以看出当使用 kCAGravityCenter 显示时,如果没有设置contentsScale, 发现图片没有按照缩放比渲染到 layer 上,子 layer 的 contentsScale 默认是 1 (但是如果是 view 直接包含的 layer 的话,会自动设置它的 scale 为当前屏幕的 scale). 这个属性还比较重要,它建立了实际绘图与在屏幕上显示的映射比例。对于 view 自带的或直接关系的 layer 来说,系统会自动根据屏幕分辨率设置正确的比例。但是对于自己管理的自定义 layer,这个值是 1,需要手动地去管理。

常常会在初始化时将 layer 的 conentsScale 设置成屏幕的 Scale,或是将用于显示图片的 layer 设置为图片的 scaleSize

1
2
3
4
5
frameView.layer.contentsScale = [UIScreen mainScreen].scale;

or

frameView.layer.contentsScale = image.scale;

contentsRect: 这个属性适合用于使用拼接图,也就是把几个图片拼接成一张图片的方法,使用 contentsRect 去获取本 layer 要使用的图片。这个属性用的也是比例单位。默认是 {0,0,1,1}, 也就是整张图片的大小。

contentsCenter : 是一个 CGRect,就像 image 的 resizable 一样,控制 layer 可拉伸的范围。如图:

layer 拉伸范围

3.3 4 种绘制方法

CALayer 提供了生命周期的绘图回调方法,只要你实现了这些方法,在***[layer setNeedsDisplay]***调用后会自动调用这些方法来绘制自己。

使用 layer 绘图显示要注意,无论是 display 还是 drawInContext,CALayer 是不会主动去调用这些绘图回调去展示的,要显示时,需要手动调用***[layer setNeedsDisplay]***。

但是 UIView 初次展示时,系统会自动调用setNeedsDisplay,而且会传递给 view 自带的 layer,所以 view 自带的 layer 无需要调用此方法也可以展示。

  • display

在 display 方法中,无法获得 current graphics context,所以绘图的自由度一定程序会有限制。主要是用各种方法输出 image 后,通过设置 contents 的方式绘制 layer。

layer 使用 display 方法设置图片源
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

// 自定义 Layer
@implementation CALayerBasicDemoCustomLayer

- (void)display
{
// 使用绘图或其它方法得到 image,再利用设置 contents 输出图象
UIImage *image = [UIImage imageNamed:@"earth"];
self.contents = (id)image.CGImage;
}

@end

// 将自定义 Layer 添加入 view 中显示
CALayerBasicDemoCustomLayer *customLayer = [CALayerBasicDemoCustomLayer new];
customLayer.frame = CGRectMake(0, 0, 100, 100);
[customLayer setNeedsDisplay]; // 重要!
layerFrame = [UIView new];
[layerFrame.layer addSublayer:customLayer];

  • displayLayer

如果没有实现 display () 方法,或者调用了 super.display (),并且设置了 layer 的 delegate,那么 iOS 系统会调用 delegate 的 displayLayer () (本质上就是调用其所直属的 UIView 的 displayLayer 方法)。

  • drawInContext

如果没有设置 delegate,或者 delegate 没有实现 displayLayer () 方法,那么接下来会调用 layer 的 drawInContext 方法

layer 使用 drawInContext 绘图
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 自定义 Layer
@implementation CALayerBasicDemoCustomLayer2

- (void)drawInContext:(CGContextRef)context
{
CGContextAddArc (context, 20, 20, 10, 0, 2 * M_PI, 1); // 画圆
CGContextSetLineWidth (context, 3); // 设置线粗
CGContextSetStrokeColorWithColor (context, [UIColor redColor].CGColor); // 画线的颜色
CGContextStrokePath (context); // 画线
}

@end

// 将自定义 Layer 添加入 view 中显示
CALayerBasicDemoCustomLayer *customLayer = [CALayerBasicDemoCustomLayer new];
customLayer.frame = CGRectMake(0, 0, 100, 100);
[customLayer setNeedsDisplay]; // 重要!
layerFrame = [UIView new];
[layerFrame.layer addSublayer:customLayer];
  • drawLayerInContext

如果 layer 没有实现 drawInContext 方法,那么接下来就会调用 delegate 的 drawLayerInContext 方法

3.4 绘制时机

  1. 改变 bounds 一般不会触发重绘

如果需要改变 bounds 时重绘,可以设置needsDisplayOnBoundsChange属性。就像设置 UIVIew 的 contentMode 为UIViewContentModeRedraw一样。

一般改变 layer 大小是不重绘的,可以想象,无论是通过设置 contents 方法还是自己在 layer 上绘图,都会生成一个像素位图的后备存储,我们也可以称之为 layer 的图象缓存,当 layer 大小改变时,我们不需要重新渲染去画一遍,只要把这个图象缓存拉伸就行了。

  1. Layer 不会主动重绘

这个上面解释过了,无论是 display 还是 drawInContext,CALayer 是不会主动去调用这些绘图回调去展示的,要显示时,需要手动调用***[layer setNeedsDisplay]。但是 UIView 初次展示时,系统会自动调用setNeedsDisplay***,而且会传递给 view 自带的 layer,所以 view 自带的 layer 无需要调用此方法也可以展示。

  1. 绘制的实现

当 UIView 需要显示时,它内部的层会准备好一个 CGContextRef (图形上下文),然后调用 delegate (这里就是 UIView) 的 drawLayer:inContext: 方法,并且传入已经准备好的 CGContextRef 对象。而 UIView 在 drawLayer:inContext: 方法中又会调用自己的 drawRect: 方法。平时在 drawRect: 中通过 UIGraphicsGetCurrentContext () 获取的就是由层传入的 CGContextRef 对象,在 drawRect: 中完成的所有绘图都会填入层的 CGContextRef 中,然后被拷贝至屏幕。

3.5 绘制过程总结与绘制回调优先级

所以,一般来说,可以在 layer 的 display () 或者 drawInContext () 方法中来绘制
在 display () 中绘制的话,可以直接给 contents 属性赋值一个 CGImage,在 drawInContext () 里就是各种调用 CoreGraphics 的 API 。

假如绘制的逻辑特别复杂,希望能从 layer 中剥离出来,那么可以给 layer 设置 delegate,把相关的绘制代码写在 delegate 的 displayLayer () 和 drawLayerInContext () 方法。

  • 绘调优先级

主要是要明确下这些回调起作用的时间点。

contents 在设置时候马上就生效了。所以就算在 draw 回调里画了画,但是在设置 contents 时马上会覆盖掉。

同样,就算你调了 contents,如果在之后调用 setNeedsDisplay,也会重绘覆盖当前图象。

还有要注意的是,重绘方法只能实现一个,比如如果实现了 display, 那么 drawInContext 就不会调用了。

4 CALayer 的布局与定位

  • position 和 anchorPoint

layer 的定位主要靠 position 与 anchorPoint 两个属性

position 是本 layer 在父层 layer 坐标系中的位置,但是是本 layer 中那个点在父层 layer 的 position 呢?– 这个本 layer 上的点由 anchorPoint 来定。

anchorPoint 的 x、y 取值范围都是 0~1,默认值为(0.5, 0.5),表示在相应坐标轴(自身 layer 的坐标系)上的比例。
比如,(0.5, 0.5),表示在 x 轴的 0.5 倍上,y 轴的 0.5 倍上,所以这个 anchor 表示本 layer 的中点;(0,0) 表示本 layer 的左上点,(1,1) 表示右下点。

实现效果可以在我的 iOSOneDemo 中对应这篇文章的章节查看。

layer 定位的代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 把红色的 layer1 放在外层 layer 的中间
CALayer *layer1 = [CALayer new];
layer1.bounds = CGRectMake(0, 0, 70, 50);
layer1.position = CGPointMake (100, 100); // 设置定位在外层 layer 的中点
layer1.anchorPoint = CGPointMake (0.5, 0.5); // 设置锚点在本 layer 的中间
layer1.backgroundColor = [UIColor redColor].CGColor;
[view1.layer addSublayer:layer1];

// 把蓝色的 layer2 放在外层 layer 的左上角
CALayer *layer2 = [CALayer new];
layer2.bounds = CGRectMake(0, 0, 30, 20);
layer2.position = CGPointMake (0, 0); // 设置定位在外层 layer 的左上角
layer2.anchorPoint = CGPointMake (0, 0); // 设置锚点在 layer 的左上角
layer2.backgroundColor = [UIColor blueColor].CGColor;
[view1.layer addSublayer:layer2];
  • contentsScale

要注意的是,自定义 CALayer 的 contentsScale 默认是 1,这个需要在 layer 的初始化方法中自己看是否需要调整为手机屏幕对应的 scaleSize。

4.1 坐标系

和视图一样,图层在图层树当中也是相对于父图层按层级关系放置,一个图层的 position 依赖于它父图层的 bounds ,如果父图层发生了移动,它的所有子图层也会跟着移动

这样对于放置图层会更加方便,因为你可以通过移动根图层来将它的子图层作为一个整体来移动,但是有时候你需要知道一个图层的绝对位置,或者是相对于另一个图层的位置,而不是它当前父图层的位置。

CALayer 给不同坐标系之间的图层转换提供了一些工具类方法:

不同坐标系之间的图层转换提供了一些工具类方法
1
2
3
4
5
6
7
- (CGPoint)convertPoint:(CGPoint)point fromLayer:(CALayer *)layer;

- (CGPoint)convertPoint:(CGPoint)point toLayer:(CALayer *)layer;

- (CGRect)convertRect:(CGRect)rect fromLayer:(CALayer *)layer;

- (CGRect)convertRect:(CGRect)rect toLayer:(CALayer *)layer;

这些方法可以把定义在一个图层坐标系下的点或者矩形转换成另一个图层坐标系下 的点或者矩形.

4.2 Z 坐标轴

UIView 严格的二维坐标系不同, CALayer 存在于一个三维空间当中。除了我们已经讨论过的 positionanchorPoint 属性之外, CALayer 还有另外两个属性,zPositionanchorPointZ,二者都是在 Z 轴上描述图层位置的浮点

4.2.1 zPosition 和 anchorPointZ

zPosition 表示图层的显示顺序,越大的越靠前。

4.3 CALayer 的 Hit Test

我们之前说了,CALayer 并不关心用户的交互,所以不能处理触摸事件或手势。但是,通过 -containsPoint: 和 -hitTest:,CALayer 也可以简单地处理一些交互事件的判断。

4.3.1 -containsPoint:

-containsPoint: 接受一个在本图层坐标系下的 CGPoint,如果这个点在图层的 frame 范围内,则返回 YES

4.3.2 -hitTest:

-hitTest: 也接受一个 CGPoint,它返回图层本身,或者是包含这个坐标点的子图层。

hitTest 使用示例
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

// 在外层 UIView 的 touchesBegan 中处理用户交互事件
- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event
{
CGPoint point = [[touches anyObject] locationInView:self.view]; // 得到触摸点在 layer 中坐标
CALayer *layer = [self.layerView.layer hitTest:point]; // 直接得到用户点击的是哪个 layer

// 这样就可以判断点击的是哪个 layer,从而进一步处理逻辑
if (layer == self.blueLayer) {
...
} else {
...
}
}

5 引用

【1】[Matt Neuburg - 《Programming iOS deep into views,view controllers and frameworks》]
【2】[Nick LockWood - 《iOS Core Animation: Advanced Techniques》]