《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
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

// 自定义 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

// 在外层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》]

坚持原创技术分享,您的支持将鼓励我继续创作!