iOS CALayer图层漫谈(一)

CALayer与UIView的恩怨纠葛

在介绍CALayer之前,我们有必要先来聊一下iOS开发中我们最熟悉的UIView视图。在iOS中,所有的视图都是由UIView基类派生而来,UIView支持事件响应、CG绘图、仿射变换以及各种动画。

那么CALayer跟UIView有什么关系呢?其实CALayer跟UIView概念上很相似,同样都是被层级管理树管理的一些矩形块,同样可以包含内容,管理子图层,可以做动画和变换。但是最大的不同是UIView可以处理用户的交互,而CALayer是不能够响应事件的,即使它提供了一些判断触点是否在图层范围内的方法。

除了这些以外,还有一个最重要的关系是每一个UIView视图内部都封装了一个CALayer图层,我们也可以通过UIView的layer属性访问这个图层。其实对于UIView视图来说真正的负责内容展示的其实是它内部的CALayer,UIView只不过是将自身的展示任务交给了内部的CALayer完成,而它还肩负着一些其它的任务,比如说用户的交互响应,提供一些Core Animation底层方法的高级接口等。

那为什么不把这些任务放在一个类中处理而是把他们作为平行关系同时存在呢?很重要的原因是要将职责分离,这样可以避免很多重复的代码,要知道在iOS平台和MacOS平台上用户的交互方式有着本质的不同,在iOS系统中我们使用的是UIKit和UIView,而在MacOS系统中我们使用的是AppKit和NSView,所以在这种情况下将展示部分分离出来会给苹果的多平台系统开发带来便捷。

按照惯例我们将UIView称作视图,将CALayer称作图层,在接下来的文章中我们将继续秉承这种叫法。

CALayer的超能力

既然UIView已经为我们封装好了,为什么还要使用相对比较底层的CALayer呢?CALayer一定是有UIView做不到的一些超能力,比如:阴影、圆角、边框;3D变换;非矩形范围;透明遮罩;多级非线性动画等......

召唤CALayer

下面让我们召唤一灰一红两个图层,直接上代码吧

CALayer * layer = [CALayer layer];
layer.frame = CGRectMake(100, 100, 120, 80);
layer.backgroundColor = [UIColor grayColor].CGColor;
[self.view.layer addSublayer:layer];

CALayer * subLayer = [CALayer layer];
subLayer.frame = CGRectMake(20, 20, 80, 40);
subLayer.backgroundColor = [UIColor redColor].CGColor;
[layer addSublayer:subLayer];
两只“神兽”的自拍照😂

CALayer身上的“Tattoo” -- 寄宿图

CALayer除了上面看到可以为自己设置一个背景色以外,它还能包含一张图片,我们可以通过id类型的contents属性来设置这张图,虽然我们给contents属性任何值都能编译通过,但是CALayer还是比较任性的,它只接受CGImage类型的值,其它类型值它都将显示空白。

那为什么要把contents属性定义成泛型呢?因为在Mac OS系统上contents属性对CGImageNSImage都起作用,但是在iOS系统中将UIImage类型的值赋给它,得到的依然是一个空白图层。

有时候我们想把UIImage通过image.CGImage转换赋值给contents属性,但此时CALayer依然是拒绝的,它会向你抛出一个编译错误。因为我们给它的其实是一颗"糖衣药丸",UIImage的CGImage属性返回的并不是一个正宗的CGImage对象,而是CGImage的指针类型CGImageRef,所以我们需要再次加工一下这颗药丸,让它变成下面这个样子:

layer.contents = (__bridge id)image.CGImage;

摸清了CALayer的脾气之后,我们正式开始为它刺上这幅"Tattoo":

subLayer.contents = (__bridge id)[UIImage imageNamed:@"logo.jpeg"].CGImage;

被刺上纹身的红色图层

从效果图上看,我们设置了contents属性的红色图层上已经展示出了寄宿图,但是这幅图看上去已经拉扯变形了,因为我们给它的是一幅正方形的图。在使用UIImageView的时候我们也遇到过同样的问题,我们可以通过UIImageView的contentMode属性来解决图片的显示形式,同样CALayer也有一个跟contentMode长得很像的属性叫做contentsGravity,但它跟contentMode有着不一样的血统,它是一个NSString类型而不是枚举值,那我们来看看contentsGravity属性都有哪些值:

CA_EXTERN NSString * const kCAGravityCenter
    CA_AVAILABLE_STARTING (10.5, 2.0, 9.0, 2.0);
CA_EXTERN NSString * const kCAGravityTop
    CA_AVAILABLE_STARTING (10.5, 2.0, 9.0, 2.0);
CA_EXTERN NSString * const kCAGravityBottom
    CA_AVAILABLE_STARTING (10.5, 2.0, 9.0, 2.0);
CA_EXTERN NSString * const kCAGravityLeft
    CA_AVAILABLE_STARTING (10.5, 2.0, 9.0, 2.0);
CA_EXTERN NSString * const kCAGravityRight
    CA_AVAILABLE_STARTING (10.5, 2.0, 9.0, 2.0);
CA_EXTERN NSString * const kCAGravityTopLeft
    CA_AVAILABLE_STARTING (10.5, 2.0, 9.0, 2.0);
CA_EXTERN NSString * const kCAGravityTopRight
    CA_AVAILABLE_STARTING (10.5, 2.0, 9.0, 2.0);
CA_EXTERN NSString * const kCAGravityBottomLeft
    CA_AVAILABLE_STARTING (10.5, 2.0, 9.0, 2.0);
CA_EXTERN NSString * const kCAGravityBottomRight
    CA_AVAILABLE_STARTING (10.5, 2.0, 9.0, 2.0);
CA_EXTERN NSString * const kCAGravityResize
    CA_AVAILABLE_STARTING (10.5, 2.0, 9.0, 2.0);
CA_EXTERN NSString * const kCAGravityResizeAspect
    CA_AVAILABLE_STARTING (10.5, 2.0, 9.0, 2.0);
CA_EXTERN NSString * const kCAGravityResizeAspectFill
    CA_AVAILABLE_STARTING (10.5, 2.0, 9.0, 2.0);

现在我们通过contentsGravity属性调整一下寄宿图的显示形式

subLayer.contentsGravity = kCAGravityResizeAspect;

一副完美的纹身

在正确显示出寄宿图后,我们来思考一个更深入的问题:
我们所看到的这张寄宿图图片原本的尺寸是非常大的,大到足以覆盖整个手机屏幕,但它为什么会变得如此乖“小”呢?你可能会说‘你设置了contentsGravity属性为kCAGravityResizeAspect,图片当然会拉伸/缩放来适应容器图层的大小啦!’。是的没错!这时寄宿图会委曲求全的去适应容器图层的大小,但这真的是寄宿图的性格么?当然不是啦,只是kCAGravityResizeAspect困住了它,当我们把kCAGravityResizeAspect值换成kCAGravityCenter场面立马发生了变化
已经阻止不了寄宿图的小宇宙了

通过手机状态栏的对比可以看出,此时寄宿图完全按照自身大小显示,并不会理会容器图层的大小。

但是它实在是太大了,那么接下来介绍的这个属性可能会改变这个状况,它叫做contentsScale,默认值为1.0。这个属性可以控制寄宿图像素与实际屏幕像素之间的比例关系。拿一个大家熟知的例子来解释一下,我们知道Retain屏幕是2个物理像素点来展示1个逻辑像素点,而非非Retain屏的1:1比例关系。我们常用的UIImage其实是不存在这样的问题的,因为UIImage本身已经将这种显示比例关系处理好了,所以一张图片在不同设备上显示的大小是相同的,但是耿直的CGImage并没有做这种处理,这时就需要我们手动的控制contentsScale属性来达到想要的效果。

UIImage * image = [UIImage imageNamed:@"icon.jpeg"];
subLayer.contentsScale = image.scale;

还可以通过获取屏幕的scale属性值来进行设置

subLayer.contentsScale = [UIScreen mainScreen].scale;

如果对这个概念还是很模糊,编写代码在不同设备上测试一下,相信很快就理解了。

CALayer寄宿图的“裁切刀”

我们知道UIView会绘制超过其边界的子视图或内容,在CALayer下也是这样。UIView有个属性叫做clipsToBounds,把它设置为YES,超出视图边界的内容和子视图将不会显示。CALayer也有一个同样的“边界裁切刀”叫做masksToBounds

CALayer除了可以进行边界裁切,还可以任意裁切寄宿图,这个属性叫做contentsRect,要注意的是这个属性和boundsframe不同,contentsRect不是按照点来计算的,而是使用了单位坐标,单位坐标指定在0到1之间,是一个相对值,所以说是相对于寄宿图尺寸的。

CGRectMake(0,0,0.5,0.5)

CGRectMake(0,0,1,1)

这种剪裁加载方式在游戏开发中经常用到,一次性加载一张拼合图然后通过寄宿图剪裁显示需要显示的那部分,这样大大提高了载入性能,而这种做法在早期的web前端开发中也经常用到。

除了上面介绍到的两个属性,下面要介绍一个有关九分法拉伸的属性contentsCenter,我们一定不要被这个名字迷惑了,这个属性并不是设置寄宿图中心位置的,而是设置一个矩形形状的中心区域,通过这个区域来指定寄宿图被均匀拉伸的部分。其实这个属性跟UIImage的-resizableImageWithCapinsets:方法用法相似,这里大家可以参照我之前写过的一片关于UIImage拉伸的文章《iOS图片拉伸(聊天气泡拉伸、相框拉伸)》来理解,这里就不再过多的解释有关九分法拉伸的原理了。

CALayer寄宿图的“纹刺”方式

上文中我们介绍的寄宿图的设置方式是直接赋值一张CGImage图片,这好比我们通过一张纹身贴纸让CALayer获得一个纹身,除此之外我们还可以通过纹身枪一点一点绘制上去。

首先,我们要聊一聊CALayer的CALayerDelegate协议,CALayerDelegate协议中的代理方法其实都是可选的。当CALayer被重绘的时候,如果我们想给CALayer设置一个寄宿图就可以在下面这个代理方法中操作:

- (void)displayLayer:(CALayer *)layer{
    layer.contents = ...;
}

如果代理没有实现-displayLayer:方法,CALayer就会转而尝试调用下面这个代理方法:

- (void)drawLayer:(CALayer *)layer inContext:(CGContextRef)ctx{
    CGContextSetLineWidth(ctx, 10.f);
    CGContextSetStrokeColorWithColor(ctx, [UIColor redColor].CGColor);
    CGContextStrokeEllipseInRect(ctx, layer.bounds);
}

这时CALayer重绘的就不是一张寄宿图了,而是通过Core Graphics绘制的方式在CALayer上展示内容,这种方式就是我们上面说的用“纹身枪”给CALyaer绘制“纹身”。

说两个需要我们注意的地方:

(1)当我们提交绘制内容超出了CALayer的范围,即使我们没有设置masksToBounds=YES,超出CALayer的部分依然不会显示,因为通过CALayerDelegate这种方式绘图的时候,并没有对超出边界外的内容提供支持。

(2)上面提到的绘制代理方法是不是很像UIView的-drewRect:方法呢?是的,其实UIView内部负责绘制的layer图层的代理其实就是UIView本身,也是就是说view.layer.delegate = view,相当于这样一个逻辑。而-drewRect:只不过是layer代理方法封装的产物。
但是UIView与CALayer不同的地方是,当UIView视图显示在屏幕上时,会立刻隐式调用内容重绘,此时会调用-drewRect:方法;而CALayer重绘需要我们显式调用,它并不会在显示在屏幕上时进行重绘,而是让开发者通过[layer display];手动控制重绘。


下一篇《iOS CALayer图层漫谈(二)》我们将聊一聊CALayer几何学相关的一些事情。

版权声明:出自MajorLMJ技术博客的原创作品 ,转载时必须注明出处及相应链接!