(一)图层的树状结构
Core Animation其实是一个令人误解的命名。你可能认为它只是用来做动画的,但实际上它是从一个叫做Layer Kit这么一个不怎么和动画有关的名字演变而来,所以做动画这只是Core Animation特性的冰山一角。
Core Animation是一个复合引擎,它的职责就是尽可能快地组合屏幕上不同的可视内容,这个内容是被分解成独立的图层,存储在一个叫做图层树的体系之中。于是这个树形成了UIKit以及在iOS应用程序当中你所能在屏幕上看见的一切的基础。
在我们讨论动画之前,我们将从图层树开始,涉及一下Core Animation的静态组合以及布局特性。
图层和视图
如果你曾经在iOS或者Mac OS平台上写过应用程序,你可能会对视图的概念比较熟悉。一个视图就是在屏幕上显示的一个矩形块(比如图片,文字或者视频),它能够拦截类似于鼠标点击或者触摸手势等用户输入。视图在层级关系中可以互相嵌套,一个视图可以管理它的所有子视图的位置。图1.1显示了一种典型的视图层级关系
图1.1 一种典型的iOS屏幕(左边)和形成视图的层级关系(右边)
在iOS当中,所有的视图都从一个叫做UIVIew的基类派生而来,UIView可以处理触摸事件,可以支持基于Core Graphics绘图,可以做仿射变换(例如旋转或者缩放),或者简单的类似于滑动或者渐变的动画。
CALayer
CALayer类在概念上和UIView类似,同样也是一些被层级关系树管理的矩形块,同样也可以包含一些内容(像图片,文本或者背景色),管理子图层的位置。它们有一些方法和属性用来做动画和变换。和UIView最大的不同是CALayer不处理用户的交互。
CALayer并不清楚具体的响应链(iOS通过视图层级关系用来传送触摸事件的机制),于是它并不能够响应事件,即使它提供了一些方法来判断是否一个触点在图层的范围之内
平行的层级关系
每一个UIview都有一个CALayer实例的图层属性,也就是所谓的backing layer,视图的职责就是创建并管理这个图层,以确保当子视图在层级关系中添加或者被移除的时候,他们关联的图层也同样对应在层级关系树当中有相同的操作
实际上这些背后关联的图层才是真正用来在屏幕上显示和做动画,UIView仅仅是对它的一个封装,提供了一些iOS类似于处理触摸的具体功能,以及Core Animation底层方法的高级接口。
但是为什么iOS要基于UIView和CALayer提供两个平行的层级关系呢?为什么不用一个简单的层级来处理所有事情呢?原因在于要做职责分离,这样也能避免很多重复代码。在iOS和Mac OS两个平台上,事件和用户交互有很多地方的不同,基于多点触控的用户界面和基于鼠标键盘有着本质的区别,这就是为什么iOS有UIKit和UIView,但是Mac OS有AppKit和NSView的原因。他们功能上很相似,但是在实现上有着显著的区别。
绘图,布局和动画,相比之下就是类似Mac笔记本和桌面系列一样应用于iPhone和iPad触屏的概念。把这种功能的逻辑分开并应用到独立的Core Animation框架,苹果就能够在iOS和Mac OS之间共享代码,使得对苹果自己的OS开发团队和第三方开发者去开发两个平台的应用更加便捷。
实际上,这里并不是两个层级关系,而是四个,每一个都扮演不同的角色,除了视图层级和图层树之外,还存在呈现树和渲染树,这将在以后分别讨论。
图层的能力
如果说CALayer是UIView内部实现细节,那我们为什么要全面地了解它呢?苹果当然为我们提供了优美简洁的UIView接口,那么我们是否就没必要直接去处理Core Animation的细节了呢?
某种意义上说的确是这样,对一些简单的需求来说,我们确实没必要处理CALayer,因为苹果已经通过UIView的高级API间接地使得动画变得很简单。
但是这种简单会不可避免地带来一些灵活上的缺陷。如果你略微想在底层做一些改变,或者使用一些苹果没有在UIView上实现的接口功能,这时除了介入Core Animation底层之外别无选择。
我们已经证实了图层不能像视图那样处理触摸事件,那么他能做哪些视图不能做的呢?这里有一些UIView没有暴露出来的CALayer的功能:
- 阴影,圆角,带颜色的边框
- 3D变换
- 非矩形范围
- 透明遮罩
- 多级非线性动画
我们将会在后续章节中探索这些功能,首先我们要关注一下在应用程序当中CALayer是怎样被利用起来的。
使用图层
首先我们来创建一个简单的项目,来操纵一些layer的属性。打开Xcode,使用Single View Application模板创建一个工程。
在屏幕中央创建一个小视图(大约200 X 200的尺寸),当然你可以手工编码,或者使用Interface Builder(随你方便)。确保你的视图控制器要添加一个视图的属性以便可以直接访问它。我们把它称作layerView。
运行项目,应该能在浅灰色屏幕背景中看见一个白色方块(图1.3),如果没看见,可能需要调整一下背景window或者view的颜色
这并没有什么令人激动的地方,我们来添加一个色块,在白色方块中间添加一个小的蓝色块。
我们当然可以简单地在已经存在的UIView上添加一个子视图(随意用代码或者IB),但这不能真正学到任何关于图层的东西。
于是我们来创建一个CALayer,并且把它作为我们视图相关图层的子图层。
在代码中,我们用创建了一个CALayer,设置了它的backgroundColor属性,然后添加到layerView背后相关图层的子图层(这段代码的前提是通过IB创建了layerView并做好了连接)
#import "ViewController.h" #import @interface ViewController () @property (nonatomic, weak) IBOutlet UIView *layerView; ? @end @implementation ViewController - (void)viewDidLoad { [super viewDidLoad]; //create sublayer CALayer *blueLayer = [CALayer layer]; blueLayer.frame = CGRectMake(50.0f, 50.0f, 100.0f, 100.0f); blueLayer.backgroundColor = [UIColor blueColor].CGColor; //add it to our view [self.layerView.layer addSublayer:blueLayer]; } @end
图1.5 白色UIView内部嵌套的蓝色CALayer
一个视图只有一个相关联的图层(自动创建),同时它也可以支持添加无数多个子图层,从上面代码可以看出,你可以显示创建一个单独的图层,并且把它直接添加到视图关联图层的子图层。尽管可以这样添加图层,但往往我们只是见简单地处理视图,他们关联的图层并不需要额外地手动添加子图层。
在Mac OS平台,10.8版本之前,一个显著的性能缺陷就是由于用了视图层级而不是单独在一个视图内使用CALayer树状层级。但是在iOS平台,使用轻量级的UIView类并没有显著的性能影响(当然在Mac OS 10.8之后,NSView的性能同样也得到很大程度的提高)。
使用图层关联的视图而不是CALayer的好处在于,你能在使用所有CALayer底层特性的同时,也可以使用UIView的高级API(比如自动排版,布局和事件处理)。
然而,当满足以下条件的时候,你可能更需要使用CALayer而不是UIView
- 开发同时可以在Mac OS上运行的跨平台应用
- 使用多种CALayer的子类(见第六章,“特殊的图层“),并且不想创建额外的UIView去包封装它们所有
- 做一些对性能特别挑剔的工作,比如对UIView一些可忽略不计的操作都会引起显著的不同(尽管如此,你可能会直接想使用OpenGL绘图)
但是这些例子都很少见,总的来说,处理视图会比单独处理图层更加方便。
总结
这一章阐述了图层的树状结构,说明了如何在iOS中由UIView的层级关系形成的一种平行的CALayer层级关系,在后面的实验中,我们创建了自己的CALayer,并把它添加到图层树中。
(二)寄宿图
CALyer 有一个属性叫做contents,这个属性的类型被定义为id,意味着它可以是任何类型的对象。在这种情况下,你可以给contents属性赋任何值,你的app仍然能够编译通过。但是,在实践中,如果你给contents赋的不是CGImage,那么你得到的图层将是空白的。
contents这个奇怪的表现是由Mac OS的历史原因造成的。它之所以被定义为id类型,是因为在Mac OS系统上,这个属性对CGImage和NSImage类型的值都起作用。如果你试图在iOS平台上将UIImage的值赋给它,只能得到一个空白的图层。一些初识Core Animation的iOS开发者可能会对这个感到困惑。
头疼的不仅仅是我们刚才提到的这个问题。事实上,你真正要赋值的类型应该是CGImageRef,它是一个指向CGImage结构的指针。UIImage有一个CGImage属性,它返回一个"CGImageRef",如果你想把这个值直接赋值给CALayer的contents,那你将会得到一个编译错误。因为CGImageRef并不是一个真正的Cocoa对象,而是一个Core Foundation类型。
尽管Core Foundation类型跟Cocoa对象在运行时貌似很像(被称作toll-free bridging),他们并不是类型兼容的,不过你可以通过bridged关键字转换。如果要给图层的寄宿图赋值,你可以按照以下这个方法:
1 |
|
如果你没有使用ARC(自动引用计数),你就不需要__bridge这部分。但是,你干嘛不用ARC?!
让我们来继续修改我们在第一章新建的工程,以便能够展示一张图片而不仅仅是一个背景色。我们已经用代码的方式建立一个图层,那我们就不需要额外的图层了。那么我们就直接把layerView的宿主图层的contents属性设置成图片。
清单2.1 更新后的代码。
@implementation ViewController - (void)viewDidLoad { [super viewDidLoad]; //load an image UIImage *image = [UIImage imageNamed:@"Snowman.png"]; //add it directly to our view‘s layer self.layerView.layer.contents = (__bridge id)image.CGImage; } @end
图表2.1 在UIView的宿主图层中显示一张图片
我们用这些简单的代码做了一件很有趣的事情:我们利用CALayer在一个普通的UIView中显示了一张图片。这不是一个UIImageView,它不是我们通常用来展示图片的方法。通过直接操作图层,我们使用了一些新的函数,使得UIView更加有趣了。
contentGravity
你可能已经注意到了我们的雪人看起来有点。。。胖 ==! 我们加载的图片并不刚好是一个方的,为了适应这个视图,它有一点点被拉伸了。在使用UIImageView的时候遇到过同样的问题,解决方法就是把contentMode属性设置成更合适的值,像这样:
view.contentMode = UIViewContentModeScaleAspectFit;
这个方法基本和我们遇到的情况的解决方法已经接近了(你可以试一下 :) ),不过UIView大多数视觉相关的属性比如contentMode,对这些属性的操作其实是对对应图层的操作。
CALayer与contentMode对应的属性叫做contentsGravity,但是它是一个NSString类型,而不是像对应的UIKit部分,那里面的值是枚举。contentsGravity可选的常量值有以下一些:
kCAGravityCenter kCAGravityTop kCAGravityBottom kCAGravityLeft kCAGravityRight kCAGravityTopLeft kCAGravityTopRight kCAGravityBottomLeft kCAGravityBottomRight kCAGravityResize kCAGravityResizeAspect kCAGravityResizeAspectFill
和cotentMode一样,contentsGravity的目的是为了决定内容在图层的边界中怎么对齐,我们将使用kCAGravityResizeAspect,它的效果等同于UIViewContentModeScaleAspectFit, 同时它还能在图层中等比例拉伸以适应图层的边界。
self.layerView.layer.contentsGravity = kCAGravityResizeAspect;
图2.2 可以看到结果
contentsScale
contentsScale属性定义了寄宿图的像素尺寸和视图大小的比例,默认情况下它是一个值为1.0的浮点数。
contentsScale的目的并不是那么明显。它并不是总会对屏幕上的寄宿图有影响。如果你尝试对我们的例子设置不同的值,你就会发现根本没任何影响。因为contents由于设置了contentsGravity属性,所以它已经被拉伸以适应图层的边界。
如果你只是单纯地想放大图层的contents图片,你可以通过使用图层的transform和affineTransform属性来达到这个目的,这(指放大)也不是contengsScale的目的所在.
contentsScale属性其实属于支持高分辨率(又称Hi-DPI或Retina)屏幕机制的一部分。它用来判断在绘制图层的时候应该为寄宿图创建的空间大小,和需要显示的图片的拉伸度(假设并没有设置contentsGravity属性)。UIView有一个类似功能但是非常少用到的contentScaleFactor属性。
如果contentsScale设置为1.0,将会以每个点1个像素绘制图片,如果设置为2.0,则会以每个点2个像素绘制图片,这就是我们熟知的Retina屏幕。(如果你对像素和点的概念不是很清楚的话,这个章节的后面部分将会对此做出解释)。
这并不会对我们在使用kCAGravityResizeAspect时产生任何影响,因为它就是拉伸图片以适应图层而已,根本不会考虑到分辨率问题。但是如果我们把contentsGravity设置为kCAGravityCenter(这个值并不会拉伸图片),那将会有很明显的变化(如图2.3)
如你所见,我们的雪人不仅有点大还有点像素的颗粒感。那是因为和UIImage不同,CGImage没有拉伸的概念。当我们使用UIImage类去读取我们的雪人图片的时候,他读取了高质量的Retina版本的图片。但是当我们用CGImage来设置我们的图层的内容时,拉伸这个因素在转换的时候就丢失了。不过我们可以通过手动设置contentsScale来修复这个问题(如下),图2.4是结果
@implementation ViewController - (void)viewDidLoad { [super viewDidLoad]; //load an image UIImage *image = [UIImage imageNamed:@"Snowman.png"]; //add it directly to our view‘s layer self.layerView.layer.contents = (__bridge id)image.CGImage; //center the image self.layerView.layer.contentsGravity = kCAGravityCenter; //set the contentsScale to match image self.layerView.layer.contentsScale = image.scale; } @end
当用代码的方式来处理寄宿图的时候,一定要记住要手动的设置图层的contentsScale属性,否则,你的图片在Retina设备上就显示得不正确啦。代码如下:
layer.contentsScale = [UIScreen mainScreen].scale;
maskToBounds
现在我们的雪人总算是显示了正确的大小,不过你也许已经发现了另外一些事情:他超出了视图的边界。默认情况下,UIView仍然会绘制超过边界的内容或是子视图,在CALayer下也是这样的。
UIView有一个叫做clipsToBounds的属性可以用来决定是否显示超出边界的内容,CALayer对应的属性叫做masksToBounds,把它设置为YES,雪人就在边界里啦~(如图2.5)
contentsRect
CALayer的contentsRect属性允许我们在图层边框里显示寄宿图的一个子域。这涉及到图片是如何显示和拉伸的,所以要比contentsGravity灵活多了
和bounds,frame不同,contentsRect不是按点来计算的,它使用了单位坐标,单位坐标指定在0到1之间,是一个相对值(像素和点就是绝对值)。所以他们是相对与寄宿图的尺寸的。iOS使用了以下的坐标系统:
- 点 —— 在iOS和Mac OS中最常见的坐标体系。点就像是虚拟的像素,也被称作逻辑像素。在标准设备上,一个点就是一个像素,但是在Retina设备上,一个点等于2*2个像素。iOS用点作为屏幕的坐标测算体系就是为了在Retina设备和普通设备上能有一致的视觉效果。
- 像素 —— 物理像素坐标并不会用来屏幕布局,但是仍然与图片有相对关系。UIImage是一个屏幕分辨率解决方案,所以指定点来度量大小。但是一些底层的图片表示如CGImage就会使用像素,所以你要清楚在Retina设备和普通设备上,他们表现出来了不同的大小。
- 单位 —— 对于与图片大小或是图层边界相关的显示,单位坐标是一个方便的度量方式, 当大小改变的时候,也不需要再次调整。单位坐标在OpenGL这种纹理坐标系统中用得很多,Core Animation中也用到了单位坐标。
默认的contentsRect是{0, 0, 1, 1},这意味着整个寄宿图默认都是可见的,如果我们指定一个小一点的矩形,图片就会被裁剪(如图2.6)
事实上给contentsRect设置一个负数的原点或是大于{1, 1}的尺寸也是可以的。这种情况下,最外面的像素会被拉伸以填充剩下的区域。
contentsRect在app中最有趣的地方在于一个叫做image sprites(图片拼合)的用法。如果你有游戏编程的经验,那么你一定对图片拼合的概念很熟悉,图片能够在屏幕上独立地变更位置。抛开游戏编程不谈,这个技术常用来指代载入拼合的图片,跟移动图片一点关系也没有。
典型地,图片拼合后可以打包整合到一张大图上一次性载入。相比多次载入不同的图片,这样做能够带来很多方面的好处:内存使用,载入时间,渲染性能等等
2D游戏引擎入Cocos2D使用了拼合技术,它使用OpenGL来显示图片。不过我们可以使用拼合在一个普通的UIKit应用中,对!就是使用contentsRect
首先,我们需要一个拼合后的图表 —— 一个包含小一些的拼合图的大图片。如图2.7所示:
接下来,我们要在app中载入并显示这些拼合图。规则很简单:像平常一样载入我们的大图,然后把它赋值给四个独立的图层的contents,然后设置每个图层的contentsRect来去掉我们不想显示的部分。
我们的工程中需要一些额外的视图。(为了避免太多代码。我们将使用Interface Builder来拜访他们的位置,如果你愿意还是可以用代码的方式来实现的)。清单2.3有需要的代码,图2.8展示了结果
@interface ViewController () @property (nonatomic, weak) IBOutlet UIView *coneView; @property (nonatomic, weak) IBOutlet UIView *shipView; @property (nonatomic, weak) IBOutlet UIView *iglooView; @property (nonatomic, weak) IBOutlet UIView *anchorView; @end @implementation ViewController - (void)addSpriteImage:(UIImage *)image withContentRect:(CGRect)rect ?toLayer:(CALayer *)layer //set image { layer.contents = (__bridge id)image.CGImage; //scale contents to fit layer.contentsGravity = kCAGravityResizeAspect; //set contentsRect layer.contentsRect = rect; } - (void)viewDidLoad { [super viewDidLoad]; //load sprite sheet UIImage *image = [UIImage imageNamed:@"Sprites.png"]; //set igloo sprite [self addSpriteImage:image withContentRect:CGRectMake(0, 0, 0.5, 0.5) toLayer:self.iglooView.layer]; //set cone sprite [self addSpriteImage:image withContentRect:CGRectMake(0.5, 0, 0.5, 0.5) toLayer:self.coneView.layer]; //set anchor sprite [self addSpriteImage:image withContentRect:CGRectMake(0, 0.5, 0.5, 0.5) toLayer:self.anchorView.layer]; //set spaceship sprite [self addSpriteImage:image withContentRect:CGRectMake(0.5, 0.5, 0.5, 0.5) toLayer:self.shipView.layer]; } @end
拼合不仅给app提供了一个整洁的载入方式,还有效地提高了载入性能(单张大图比多张小图载入地更快),但是如果有手动安排的话,他们还是有一些不方便的,如果你需要在一个已经创建好的品和图上做一些尺寸上的修改或者其他变动,无疑是比较麻烦的。
Mac上有一些商业软件可以为你自动拼合图片,这些工具自动生成一个包含拼合后的坐标的XML或者plist文件,拼合图片的使用大大简化。这个文件可以和图片一同载入,并给每个拼合的图层设置contentsRect,这样开发者就不用手动写代码来摆放位置了。
这些文件通常在OpenGL游戏中使用,不过呢,你要是有兴趣在一些常见的app中使用拼合技术,那么一个叫做LayerSprites的开源库(https://github.com/nicklockwood/LayerSprites),它能够读取Cocos2D格式中的拼合图并在普通的Core Animation层中显示出来。
contentsCenter
本章我们介绍的最后一个和内容有关的属性是contentsCenter,看名字你可能会以为它可能跟图片的位置有关,不过这名字着实误导了你。contentsCenter其实是一个CGRect,它定义了一个固定的边框和一个在图层上可拉伸的区域。 改变contentsCenter的值并不会影响到寄宿图的显示,除非这个图层的大小改变了,你才看得到效果。
默认情况下,contentsCenter是{0, 0, 1, 1},这意味着如果大小(由conttensGravity决定)改变了,那么寄宿图将会均匀地拉伸开。但是如果我们增加原点的值并减小尺寸。我们会在图片的周围创造一个边框。图2.9展示了contentsCenter设置为{0.25, 0.25, 0.5, 0.5}的效果。
这意味着我们可以随意重设尺寸,边框仍然会是连续的。他工作起来的效果和UIImage里的-resizableImageWithCapInsets: 方法效果非常类似,只是它可以运用到任何寄宿图,甚至包括在Core Graphics运行时绘制的图形
清单2.4 演示了如何编写这些可拉伸视图。不过,contentsCenter的另一个很酷的特性就是,它可以在Interface Builder里面配置,根本不用写代码。如图2.11
@interface ViewController () @property (nonatomic, weak) IBOutlet UIView *button1; @property (nonatomic, weak) IBOutlet UIView *button2; @end @implementation ViewController - (void)addStretchableImage:(UIImage *)image withContentCenter:(CGRect)rect toLayer:(CALayer *)layer { //set image layer.contents = (__bridge id)image.CGImage; //set contentsCenter layer.contentsCenter = rect; } - (void)viewDidLoad { [super viewDidLoad]; //load button image UIImage *image = [UIImage imageNamed:@"Button.png"]; //set button 1 [self addStretchableImage:image withContentCenter:CGRectMake(0.25, 0.25, 0.5, 0.5) toLayer:self.button1.layer]; //set button 2 [self addStretchableImage:image withContentCenter:CGRectMake(0.25, 0.25, 0.5, 0.5) toLayer:self.button2.layer]; } @end
Custome Drawing
给contents赋CGImage的值不是唯一的设置寄宿图的方法。我们也可以直接用Core Graphics直接绘制寄宿图。能够通过继承UIView并实现-drawRect:方法来自定义绘制。
-drawRect: 方法没有默认的实现,因为对UIView来说,寄宿图并不是必须的,它不在意那到底是单调的颜色还是有一个图片的实例。如果UIView检测到-drawRect: 方法被调用了,它就会为视图分配一个寄宿图,这个寄宿图的像素尺寸等于视图大小乘以 contentsScale的值。
如果你不需要寄宿图,那就不要创建这个方法了,这会造成CPU资源和内存的浪费,这也是为什么苹果建议:如果没有自定义绘制的任务就不要在子类中写一个空的-drawRect:方法。
当视图在屏幕上出现的时候 -drawRect:方法就会被自动调用。-drawRect:方法里面的代码利用Core Graphics去绘制一个寄宿图,然后内容就会被缓存起来直到它需要被更新(通常是因为开发者调用了-setNeedsDisplay方法,尽管影响到表现效果的属性值被更改时,一些视图类型会被自动重绘,如bounds属性)。虽然-drawRect:方法是一个UIView方法,事实上都是底层的CALayer安排了重绘工作和保存了因此产生的图片。
CALayer有一个可选的delegate属性,实现了CALayerDelegate协议,当CALayer需要一个内容特定的信息时,就会从协议中请求。CALayerDelegate是一个非正式协议,其实就是说没有CALayerDelegate @protocol可以让你在类里面引用啦。你只需要调用你想调用的方法,CALayer会帮你做剩下的。(delegate属性被声明为id类型,所有的代理方法都是可选的)。
当需要被重绘时,CALayer会请求它的代理给他一个寄宿图来显示。它通过调用下面这个方法做到的:
(void)displayLayer:(CALayerCALayer *)layer;
趁着这个机会,如果代理想直接设置contents属性的话,它就可以这么做,不然没有别的方法可以调用了。如果代理不实现-displayLayer:方法,CALayer就会转而尝试调用下面这个方法:
- (void)drawLayer:(CALayer *)layer inContext:(CGContextRef)ctx;
在调用这个方法之前,CALayer创建了一个合适尺寸的空寄宿图(尺寸由bounds和contentsScale决定)和一个Core Graphics的绘制上下文环境,为绘制寄宿图做准备,他作为ctx参数传入。
@implementation ViewController - (void)viewDidLoad { [super viewDidLoad]; ? //create sublayer CALayer *blueLayer = [CALayer layer]; blueLayer.frame = CGRectMake(50.0f, 50.0f, 100.0f, 100.0f); blueLayer.backgroundColor = [UIColor blueColor].CGColor; //set controller as layer delegate blueLayer.delegate = self; //ensure that layer backing image uses correct scale blueLayer.contentsScale = [UIScreen mainScreen].scale; //add layer to our view [self.layerView.layer addSublayer:blueLayer]; //force layer to redraw [blueLayer display]; } - (void)drawLayer:(CALayer *)layer inContext:(CGContextRef)ctx { //draw a thick red circle CGContextSetLineWidth(ctx, 10.0f); CGContextSetStrokeColorWithColor(ctx, [UIColor redColor].CGColor); CGContextStrokeEllipseInRect(ctx, layer.bounds); } @end
注意一下一些有趣的事情:
- 我们在blueLayer上显式地调用了-display。不同于UIView,当图层显示在屏幕上时,CALayer不会自动重绘它的内容。它把重绘的决定权交给了开发者。
- 尽管我们没有用masksToBounds属性,绘制的那个圆仍然沿边界被裁剪了。这是因为当你使用CALayerDelegate绘制寄宿图的时候,并没有对超出边界外的内容提供绘制支持。
现在你理解了CALayerDelegate,并知道怎么使用它。但是除非你创建了一个单独的图层,你几乎没有机会用到CALayerDelegate协议。因为当UIView创建了它的宿主图层时,它就会自动地把图层的delegate设置为它自己,并提供了一个-displayLayer:的实现,那所有的问题就都没了。
当使用寄宿了视图的图层的时候,你也不必实现-displayLayer:和-drawLayer:inContext:方法咦绘制你的寄宿图。通常做法是实现UIView的-drawRect:方法,UIView就会帮你做完剩下的工作,包括在需要重绘的时候调用-display方法。
(三)图层几何学
布局
UIView有三个比较重要的布局属性:frame,bounds和center,CALayer对应地叫做frame,bounds和position。为了能清楚区分,图层用了“position”,视图用了“center”,但是他们都代表同样的值。
frame代表了图层的外部坐标(也就是在父图层上占据的空间),bounds是内部坐标({0, 0}通常是图层的左上角),center和position都代表了相对于父图层anchorPoint所在的位置。anchorPoint的属性将会在后续介绍到,现在把它想成图层的中心点就好了。图3.1显示了这些属性是如何相互依赖的。
视图的frame,bounds和center属性仅仅是存取方法,当操纵视图的frame,实际上是在改变位于视图下方CALayer的frame,不能够独立于图层之外改变视图的frame。
对于视图或者图层来说,frame并不是一个非常清晰的属性,它其实是一个虚拟属性,是根据bounds,position和transform计算而来,所以当其中任何一个值发生改变,frame都会变化。相反,改变frame的值同样会影响到他们当中的值
记住当对图层做变换的时候,比如旋转或者缩放,frame实际上代表了覆盖在图层旋转之后的整个轴对齐的矩形区域,也就是说frame的宽高可能和bounds的宽高不再一致了(图3.2)
锚点
之前提到过,视图的center属性和图层的position属性都指定了相对于父图层anchorPoint的位置。图层的anchorPoint通过position来控制它的frame的位置,你可以认为anchorPoint是用来移动图层的把柄。
默认来说,anchorPoint位于图层的中点,所以图层的将会以这个点为中心放置。anchorPoint属性并没有被UIView接口暴露出来,这也是视图的position属性被叫做“center”的原因。但是图层的anchorPoint可以被移动,比如你可以把它置于图层frame的左上角,于是图层的内容将会向右下角的position方向移动(图3.3),而不是居中了。
和第二章提到的contentsRect和contentsCenter属性类似,anchorPoint用单位坐标来描述,也就是图层的相对坐标,图层左上角是{0, 0},右下角是{1, 1},因此默认坐标是{0.5, 0.5}。anchorPoint可以通过指定x和y值小于0或者大于1,使它放置在图层范围之外。
注意在图3.3中,当改变了anchorPoint,position属性保持固定的值并没有发生改变,但是frame却移动了。
那在什么场合需要改变anchorPoint呢?既然我们可以随意改变图层位置,那改变anchorPoint不会造成困惑么?为了举例说明,我们来举一个实用的例子,创建一个模拟闹钟的项目。
钟面和钟表由四张图片组成(图3.4),为了简单说明,我们还是用传统的方式来装载和加载图片,使用四个UIImageView实例(当然你也可以用正常的视图,设置他们图层的contents图片)。
闹钟的组件通过IB来排列(图3.5),这些图片视图嵌套在一个容器视图之内,并且自动调整和自动布局都被禁用了。这是因为自动调整会影响到视图的frame,而根据图3.2的演示,当视图旋转的时候,frame是会发生改变的,这将会导致一些布局上的失灵。
我们用NSTimer来更新闹钟,使用视图的transform属性来旋转钟表,具体代码见清单3.1
@interface ViewController () @property (nonatomic, weak) IBOutlet UIImageView *hourHand; @property (nonatomic, weak) IBOutlet UIImageView *minuteHand; @property (nonatomic, weak) IBOutlet UIImageView *secondHand; @property (nonatomic, weak) NSTimer *timer; @end @implementation ViewController - (void)viewDidLoad { [super viewDidLoad]; //start timer self.timer = [NSTimer scheduledTimerWithTimeInterval:1.0 target:self selector:@selector(tick) userInfo:nil repeats:YES]; ? //set initial hand positions [self tick]; } - (void)tick { //convert time to hours, minutes and seconds NSCalendar *calendar = [[NSCalendar alloc] initWithCalendarIdentifier:NSGregorianCalendar]; NSUInteger units = NSHourCalendarUnit | NSMinuteCalendarUnit | NSSecondCalendarUnit; NSDateComponents *components = [calendar components:units fromDate:[NSDate date]]; CGFloat hoursAngle = (components.hour / 12.0) * M_PI * 2.0; //calculate hour hand angle //calculate minute hand angle CGFloat minsAngle = (components.minute / 60.0) * M_PI * 2.0; //calculate second hand angle CGFloat secsAngle = (components.second / 60.0) * M_PI * 2.0; //rotate hands self.hourHand.transform = CGAffineTransformMakeRotation(hoursAngle); self.minuteHand.transform = CGAffineTransformMakeRotation(minsAngle); self.secondHand.transform = CGAffineTransformMakeRotation(secsAngle); } @end
运行项目,看起来有点奇怪(图3.6),因为钟表的图片在围绕着中心旋转,这并不是我们期待的一个支点。
图3.6 钟面,和不对齐的钟指针
你也许会认为可以在Interface Builder当中调整指针图片的位置来解决,但其实并不能达到目的,因为如果不放在钟面中间的话,同样不能正确的旋转。
也许在图片末尾添加一个透明空间也是个解决方案,但这样会让图片变大,也会消耗更多的内存,这样并不优雅。
更好的方案是使用anchorPoint属性,我们来在-viewDidLoad方法中添加几行代码来给每个钟指针的anchorPoint做一些平移(清单3.2),图3.7显示了正确的结果。
- (void)viewDidLoad { [super viewDidLoad]; // adjust anchor points self.secondHand.layer.anchorPoint = CGPointMake(0.5f, 0.9f); self.minuteHand.layer.anchorPoint = CGPointMake(0.5f, 0.9f); self.hourHand.layer.anchorPoint = CGPointMake(0.5f, 0.9f); // start timer }
坐标系
和视图一样,图层在图层树当中也是相对于父图层按层级关系放置,一个图层的position依赖于它父图层的bounds,如果父图层发生了移动,它的所有子图层也会跟着移动。
这样对于放置图层会更加方便,因为你可以通过移动根图层来将它的子图层作为一个整体来移动,但是有时候你需要知道一个图层的绝对位置,或者是相对于另一个图层的位置,而不是它当前父图层的位置。
CALayer给不同坐标系之间的图层转换提供了一些工具类方法
- (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;
这些方法可以把定义在一个图层坐标系下的点或者矩形转换成另一个图层坐标系下的点或者矩形
翻转的几何结构
常规说来,在iOS上,一个图层的position位于父图层的左上角,但是在Mac OS上,通常是位于左下角。Core Animation可以通过geometryFlipped属性来适配这两种情况,它决定了一个图层的坐标是否相对于父图层垂直翻转,是一个BOOL类型。在iOS上通过设置它为YES意味着它的子图层将会被垂直翻转,也就是将会沿着底部排版而不是通常的顶部(它的所有子图层也同理,除非把它们的geometryFlipped属性也设为YES)。
Z坐标轴
和UIView严格的二维坐标系不同,CALayer存在于一个三维空间当中。除了我们已经讨论过的position和anchorPoint属性之外,CALayer还有另外两个属性,zPosition和anchorPointZ,二者都是在Z轴上描述图层位置的浮点类型。
注意这里并没有更深的属性来描述由宽和高做成的bounds了,图层是一个完全扁平的对象,你可以把它们想象成类似于一页二维的坚硬的纸片,用胶水粘成一个空洞,就像三维结构的折纸一样。
zPosition属性在大多数情况下其实并不常用。在第五章,我们将会涉及CATransform3D,你会知道如何在三维空间移动和旋转图层,除了做变换之外,zPosition最实用的功能就是改变图层的显示顺序了。
通常,图层是根据它们子图层的sublayers出现的顺序来类绘制的,这就是所谓的画家的算法--就像一个画家在墙上作画--后被绘制上的图层将会遮盖住之前的图层,但是通过增加图层的zPosition,就可以把图层向相机方向前置,于是它就在所有其他图层的前面了(或者至少是小于它的zPosition值的图层的前面)。
这里所谓的“相机”实际上是相对于用户是视角,这里和iPhone背后的内置相机没任何关系。
图3.8显示了在Interface Builder内的一对视图,正如你所见,首先出现在视图层级绿色的视图被绘制在红色视图的后面。
图3.8 在视图层级中绿色视图被绘制在红色视图的后面
我们希望在真实的应用中也能显示出绘图的顺序,同样地,如果我们提高绿色视图的zPosition(清单3.3),我们会发现顺序就反了(图3.9)。其实并不需要增加太多,视图都非常地薄,所以给zPosition提高一个像素就可以让绿色视图前置,当然0.1或者0.0001也能够做到,但是最好不要这样,因为浮点类型四舍五入的计算可能会造成一些不便的麻烦。
@interface ViewController () @property (nonatomic, weak) IBOutlet UIView *greenView; @property (nonatomic, weak) IBOutlet UIView *redView; @end @implementation ViewController - (void)viewDidLoad { [super viewDidLoad]; ? //move the green view zPosition nearer to the camera self.greenView.layer.zPosition = 1.0f; } @end
图3.9 绿色视图被绘制在红色视图的前面
Hit Testing
第一章“图层树”证实了最好使用图层相关视图,而不是创建独立的图层关系。其中一个原因就是要处理额外复杂的触摸事件。
CALayer并不关心任何响应链事件,所以不能直接处理触摸事件或者手势。但是它有一系列的方法帮你处理事件:-containsPoint:和-hitTest:。
-containsPoint:接受一个在本图层坐标系下的CGPoint,如果这个点在图层frame范围内就返回YES。如清单3.4所示第一章的项目的另一个合适的版本,也就是使用-containsPoint:方法来判断到底是白色还是蓝色的图层被触摸了 (图3.10)。这需要把触摸坐标转换成每个图层坐标系下的坐标,结果很不方便。
清单3.4 使用containsPoint判断被点击的图层
@interface ViewController () @property (nonatomic, weak) IBOutlet UIView *layerView; @property (nonatomic, weak) CALayer *blueLayer; @end @implementation ViewController - (void)viewDidLoad { [super viewDidLoad]; //create sublayer self.blueLayer = [CALayer layer]; self.blueLayer.frame = CGRectMake(50.0f, 50.0f, 100.0f, 100.0f); self.blueLayer.backgroundColor = [UIColor blueColor].CGColor; //add it to our view [self.layerView.layer addSublayer:self.blueLayer]; } - (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event { //get touch position relative to main view CGPoint point = [[touches anyObject] locationInView:self.view]; //convert point to the white layer‘s coordinates point = [self.layerView.layer convertPoint:point fromLayer:self.view.layer]; //get layer using containsPoint: if ([self.layerView.layer containsPoint:point]) { //convert point to blueLayer’s coordinates point = [self.blueLayer convertPoint:point fromLayer:self.layerView.layer]; if ([self.blueLayer containsPoint:point]) { [[[UIAlertView alloc] initWithTitle:@"Inside Blue Layer" message:nil delegate:nil cancelButtonTitle:@"OK" otherButtonTitles:nil] show]; } else { [[[UIAlertView alloc] initWithTitle:@"Inside White Layer" message:nil delegate:nil cancelButtonTitle:@"OK" otherButtonTitles:nil] show]; } } } @end
图3.10 点击图层被正确标识
-hitTest:方法同样接受一个CGPoint类型参数,而不是BOOL类型,它返回图层本身,或者包含这个坐标点的叶子节点图层。这意味着不再需要像使用-containsPoint:那样,人工地在每个子图层变换或者测试点击的坐标。如果这个点在最外面图层的范围之外,则返回nil。具体使用-hitTest:方法被点击图层的代码如清单3.5所示。
清单3.5 使用hitTest判断被点击的图层
- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event { //get touch position CGPoint point = [[touches anyObject] locationInView:self.view]; //get touched layer CALayer *layer = [self.layerView.layer hitTest:point]; //get layer using hitTest if (layer == self.blueLayer) { [[[UIAlertView alloc] initWithTitle:@"Inside Blue Layer" message:nil delegate:nil cancelButtonTitle:@"OK" otherButtonTitles:nil] show]; } else if (layer == self.layerView.layer) { [[[UIAlertView alloc] initWithTitle:@"Inside White Layer" message:nil delegate:nil cancelButtonTitle:@"OK" otherButtonTitles:nil] show]; } }
注意当调用图层的-hitTest:方法时,测算的顺序严格依赖于图层树当中的图层顺序(和UIView处理事件类似)。之前提到的zPosition属性可以明显改变屏幕上图层的顺序,但不能改变事件传递的顺序。
这意味着如果改变了图层的z轴顺序,你会发现将不能够检测到最前方的视图点击事件,这是因为被另一个图层遮盖住了,虽然它的zPosition值较小,但是在图层树中的顺序靠前。
自动布局
你可能用过UIViewAutoresizingMask类型的一些常量,应用于当父视图改变尺寸的时候,相应UIView的frame也跟着更新的场景(通常用于横竖屏切换)。
在iOS6中,苹果介绍了自动排版机制,它和自动调整不同,并且更加复杂。
在Mac OS平台,CALayer有一个叫做layoutManager的属性可以通过CALayoutManager协议和CAConstraintLayoutManager类来实现自动排版的机制。但由于某些原因,这在iOS上并不适用。
当使用视图的时候,可以充分利用UIView类接口暴露出来的UIViewAutoresizingMask和NSLayoutConstraintAPI,但如果想随意控制CALayer的布局,就需要手工操作。最简单的方法就是使用CALayerDelegate如下函数:
- (void)layoutSublayersOfLayer:(CALayer *)layer;
当图层的bounds发生改变,或者图层的-setNeedsLayout方法被调用的时候,这个函数将会被执行。这使得你可以手动地重新摆放或者重新调整子图层的大小,但是不能像UIView的autoresizingMask和constraints属性做到自适应屏幕旋转。
这也是为什么最好使用视图而不是单独的图层来构建应用程序的另一个重要原因之一。
总结
文章涉及了CALayer的集合结构,包括它的frame,position和bounds,介绍了三维空间内图层的概念,以及如何在独立的图层内响应事件,最后简单说明了在iOS平台,Core Animation对自动调整和自动布局支持的缺乏。