寄宿图
承接上文,我们在【图层树】的文章中介绍了CALayer 并且创建了一个简单蓝色背景的图层进行展示,如果图层只能展示单调的颜色未免太无聊了,事实上CALayer类能够包含一张你喜欢的图片,本章我们一块来探索CALayer的寄宿图(即图层中包含的图)。
contents属性
CALayer有一个属性叫做contents,这个属性的类型被定义为id,意味着它可以是任意类型的对象。在这种情况下,你可以给contents属性赋任何值,你的APP仍然可以编译通过。但是,在实践中,如果你给contents赋的值不是CGImage类型,你所得到的图层都是空白的。
contents的这种奇怪表现是由Mac OS的历史原因造成的。它之所以被定义为id类型,是因为在Mac OS系统上,这个属性对CGImage 和 NSImage类型的值都起作用。如果你试图在IOS系统上给它赋UIImage类型的值,那只能得到一块空白的图层。一些初识Core Animation 的开发者可能会对这个感到困惑。
头疼的不仅仅是上面我们提到的这个问题,事实上,你真正要赋值的类型应该是CGImageRef,CGImageRef是一个指向CGImage结构的指针。UIImage有一个CGimage属性,它会返回一个‘CGImageRef’,如果你想把它直接赋值给CALayer的contents属性,那你将会得到一个编译错误。因为CGImageRef并不是一个真正的Cocoa对象,而是一个Core Foundation类型。
尽管Cocoa对象和Core Foundation类型在运行时貌似很像,被称作(toll-free bridging)。他们并不是类型兼容的,不过你可以使用bridged关键字进行转换。如果要给图层的寄宿图赋值,代码如下:
cLayer.contents = (__bridge id)image.CGImage;
如果你没有使用ARC (自动引用计数),你就不需要使用__bridge 这个部分,但是,你干嘛不用ARC呢?
那好,接下来我们以之前的代码为基础继续进行讲解:我们要让图层不仅能够设置背景色还能显示图片。我们已经创建了图层CALayer ,我们设置宿主图层的contents属性为图片;
代码如下
UIView *layerView = [[UIView alloc] initWithFrame: CGRectMake(100, 100, 200, 200)];
layerView.backgroundColor = [UIColor whiteColor];
[self.view addSubview:layerView];
UIImage *image = [UIImage imageNamed:@"tesla.jpg"];
layerView.layer.contents = (__bridge id)image.CGImage;
看下图层展示图片的效果:
我们用如此简单的代码做了一件很有趣事:我们利用CALayer在一个普通的UIView上展示了一张图片。这不是一个UIImageView,它不是我们通常用来展示图片的方法。通过直接操作图层,我们使用了一个新的函数,使得UIView更加有趣了。
contentGravity
你可能也注意到了图片有些拥挤。小车变胖了!因为我们加载的图片并不能刚好就是一个方形的,为了适应这个视图,它有一点点被拉伸了。在使用UIImageView 的时候遇到过同样的问题,解决办法是设置contentMode的值为更合适的值,比如:
view.contentMode = UIViewContentModeScaleAspectFit;
这种方法已经和我们遇到的问题的解决方法很接近了(可以先不看后面的内容,自己试下)。不过UIView大多数和视觉相关的属性(比如:contentsmode),对这些属性的操作其实是对对应图层的操作。
CALayer中与contentMode对应的属性的属性是contentsGravity,但是它是一个NSString类型,而不是像对应的UIKit 部分,那里面的值是枚举类型。contentsGravity 可选的常量值有以下一些:
- kCAGravityCenter
- kCAGravityTop
- kCAGravityLeft
- kCAGravityBottom
- kCAGravityRight
- kCAGravityTopLeft
- kCAGravityTopRight
- kCAGravityBottomLeft
- kCAGravityBottomRight
- kCAGravityResize
- kCAGravityResizeAspect
- kCAGravityResizeAspectFill
和contentMode一样,contentsGravity的目的是为了决定内容在图层的边界中怎么对齐,我们将使用kCAGravityResizeAspect,它的效果等同于UIViewContentModeScaleAspectFit,同时它还能在图层中等比例拉伸以适应图层的边界。
代码如下:
layerView.layer.contentsGravity = kCAGravityResizeAspect;
效果如下:
contentsScale
contentsScale属性定义了寄宿图的像素尺寸和视图大小的比例。默认情况下它是一个值为1.0的浮点数。
contentsScale的目的并不是那么明显,它并不是总会对屏幕上寄宿图有影响。如果你尝试在我们的例子上设置不同的值,你会发现根本没有任何影响。因为contents设置了contentsGravity属性值,所以它已经被拉伸以适应图层边界。
如果你只是想单纯的放大图层的content图片,你可以使用图层的transform 和 affineTransform属性来达到这个目的,将content图片放大也不是contentScale 的目的所在。
contentsScale属性其实属于支持高分辨率(Hi-DPI或者Retina)屏幕机制的一部分。它用来判断在绘制图层的时候应该为寄宿图创建的空间大小,和需要显示的图片的拉伸度(假设contents没有设置contentsGravity属性)。UIView有一个类似功能但是非常少用的属性contentScaleFactor。
如果contentsScale设置为1.0,将会以每个点1个像素绘制图片,如果设置为2.0,将会以每个点2个像素绘制图片,这就是我们熟知的Retina屏幕。(如果对点和像素的概念不太清楚,我们在本章末尾会给出解释,也可自行上网查看)。
这并不会对我们在使用kCAGravityResizeAspect时产生任何影响,因为它就是拉伸图片以适应图层而已。根本不会考虑到分辨率的问题。但是如果我们把contentsGravity设置成kCAGravityCenter(这个值并不会拉伸图片),那将会有明显的变化:
代码如下:
layerView.layer.contentsGravity = kCAGravityCenter;
效果如下
如图所示,我们图片不仅变大而且仔细看会有一种像素的颗粒感。那是因为CGImage和UIImage不同,CGImage没有拉伸的概念。当我们使用UIImage类去读取我们的特斯拉图片时, 它读取到的是高质量的Retina版本的图片。但是当我们使用CGImage来设置我们的图层内容时,拉伸这个因素在转换的时候就丢失了。此时我们就可以使用contentsScale来修复这个问题。
当用代码的方式来处理寄宿图的时候,一定要记住手动设置图层的contentsScale属性,否则,你的图片在Retina屏幕上就显示不正确了。
代码如下:
layerView.layer.contentsGravity = kCAGravityCenter;
layerView.layer.contentsScale = [UIScreen mainScreen].scale;
效果如下
maskToBounds
截止到此我们特斯拉已经显示正确的大小,不过你可能也已经发现另外一件事情,他超出了视图的边界,默认情况下,UIView 仍然会绘制超出边界的内容或者子视图,在CALayer上也是这样的。
UIView中有一个clipsToBounds属性来决定是否显示超出边界的内容。CALayer对应的属性是masksToBounds,把他设置成YES,就是只显示在视图边界内的内容。
代码如下:
layerView.layer.masksToBounds = YES;
效果如下:
contentsRect
CALayer的contentsRect属性允许我们在图层边框里显示寄宿图的一个子域,这涉及到图片是如何显示和拉伸,所以要比contentsGravity灵活多了。
和bounds,frame不同,contentsRect不是按点来计算的,它使用了单位坐标,单位坐标指定在0到1之间,是个相对值(像素和点都是绝对值)。所以单位坐标是相对与寄宿图的尺寸的。
在此我们总结一些ios系统是用到了一些坐标系统:
点——在Ios和MacOS系统中最常见的坐标体系。点就像是虚拟的像素,也被称作逻辑像素。在标准设备上,一个点就是一个像素,但是在Retina设备上,一个点等于2 * 2个像素。ios用点作为屏幕的坐标测算体系就是为了让应用在Retina设备和标准设备上有一致的视觉效果。
像素——物理像素坐标并不会用来做屏幕布局,但是仍然和图片有相对关系。UIImage是一个屏幕分辨率解决方案,所以指定点来度量大小。但是一些底层的图片表示比如CGImage就会使用像素,所以你要清楚在Retina设备和普通设备上,他们表现出来了不同大小。
单位——对于与图片大小或者图层边界相关的显示,单位坐标是一个方便的度量方式,当大小改变时,也不需要再次调整。单位坐标在OpenGL这种纹理坐标系统中用的比较多。Core Animation中也用到了这种坐标。
默认的contentsRect是{0,0,1,1},这意味着整个寄宿图默认都是可见的。如果我们指定一个小点的矩形,图片就会被裁剪。
例如:
接上我们的例子
代码如下
layerView.layer.contentsGravity = kCAGravityResizeAspect;
// layerView.layer.contentsScale = image.scale;
layerView.layer.masksToBounds = YES;
layerView.layer.contentsScale = [UIScreen mainScreen].scale;
layerView.layer.contentsRect = CGRectMake(0, 0, 0.5, 0.5);
效果如下:
以上描述的这种情况,我们的截图空间是[0,0]坐标到[1,1]之间,如果我们的起始坐标为负数,结束坐标超过一会会如何呢?
代码如下:
layerView.layer.contentsRect = CGRectMake(-0.3, 0.3, 1, 1);
效果
代码如下:
layerView.layer.contentsRect = CGRectMake(0, 0, 1.3, 1.3);
效果如下:
事实上给contentsRect设置一个为负数的原点或者设置一个大于{1,1}的尺寸也是可以的。在这种情况下,最外面的一层像素会被拉伸以填充剩下的区域。
contentsRect在app中最有趣的地方在于一个叫image sprites(图片拼合)的用法。如果你有游戏编程的经验,那你一定对图片拼合的概念很熟悉,图片能够在屏幕上独立的变更位置。抛开游戏编程不谈,这个技术常用来指代载入拼合的图片,跟移动图片一点关系也没有。
典型的,图片拼合后可以打包整合到一张大图上一次性载入。相比多次载入不同的图片,这样能够带来很多方面的好处:内存使用,载入时间,渲染性能等等。
2D游戏引擎Cocoa2D使用了拼合技术,它使用OpenGL来显示图片。不过我们可以在一个普通的UIKit应用中使用拼合,那就是contentsRect。
首先我们需要一个拼合后的图表——一个包含了小一些拼合图的大图片。举例:
接下来,我们要在app中载入并显示这些拼合图(在此我们以之前的特斯拉图片为例,以3 * 2 的网格分割为6张拼合图)。规则很简单:像平常一样载入我们的大图,然后把它赋值给6个独立的图层的contents。然后设置图层的contentsRect来去掉我们不想显示的部分。
代码如下:
- (void)viewDidLoad {
[super viewDidLoad];
// Do any additional setup after loading the view.
self.view.backgroundColor = [UIColor whiteColor];
//载入图片
UIImage *tesleImg = [UIImage imageNamed:@"tesla.jpg"];///600 × 450
//存放6个图片的图层
UIView *view1 = [[UIView alloc] initWithFrame:CGRectMake(0, 0, 200, 200)];
[self imageSpriteOnLayer:view1.layer withRect:CGRectMake(0, 0, 0.5, 0.5) withImage:tesleImg];
UIView *view2 = [[UIView alloc] initWithFrame:CGRectMake(120, 150, 200, 200)];
[self imageSpriteOnLayer:view2.layer withRect:CGRectMake(0.5, 0, 0.5, 0.5) withImage:tesleImg];
UIView *view3 = [[UIView alloc] initWithFrame:CGRectMake(0, 300, 200, 200)];
[self imageSpriteOnLayer:view3.layer withRect:CGRectMake(0, 0.5, 0.5, 0.5) withImage:tesleImg];
UIView *view4 = [[UIView alloc] initWithFrame:CGRectMake(120, 500, 200, 200)];
[self imageSpriteOnLayer:view4.layer withRect:CGRectMake(0.5, 0.5, 0.5 , 0.5) withImage:tesleImg];
[self.view addSubview:view1];
[self.view addSubview:view2];
[self.view addSubview:view3];
[self.view addSubview:view4];
}
- (void)imageSpriteOnLayer:(CALayer *)layer withRect:(CGRect) rect withImage:(UIImage *)image
{
layer.contents = (__bridge id) image.CGImage;
layer.contentsRect = rect;
layer.contentsGravity = kCAGravityResizeAspect;
}
效果如下:
拼合不仅给app提供了一个整洁的载入方式,还有效的提高了载入性能(单张大图要比多张小图载入地更快)。但是如果有手动安排的话,他们还是有一些不方便的,如果你需要在已经创建好的拼合图上做一些尺寸上的修改或者其他变动,无疑是比较麻烦的。
Mac上有一些商业软件可以为你自动拼合图片,这些工具自动生成一个包含拼合后的坐标的xml或者plist文件,拼合图片的使用大大简化。这个文件可以和图片一同载入,并给每个拼合的图层设置contentsRect,这样开发人员就不用手动写代码来摆放位置了。
这些文件通常在OpenGL游戏中使用,不过,你要是有兴趣在一些常见的app中使用拼合技术,那么有一个叫做LayerSprites的开源库。他能够读取Cocos2D格式中的拼合图并在普通的Core Animation层中显示出来。
contentsCenter
本章我们介绍的最后一个和内容相关的属性是contentsCenter。看名字你感觉可能会跟图片的位置有关,不过这名字着实误导了你。contentsCenter其实是一个CGRect类型,它定义了一个固定的边框和一个在图层上可拉伸的区域。改变contentsRect的值并不会影响寄宿图的显示。除非这个图层的大小改变了。你才看得到效果。
默认情况下,contentsCenter的值为{0,0,1,1}(这里要特别注意contentsCenter用的是单位坐标),这意味着如果大小(由contentsGravity决定)改变了,那么寄宿图就会均匀的拉伸开**。但是如果我们增加原点的值并减小尺寸。我们就会在图片的周围创建出一个边框。如下图所示:我们将contentsCenter的值设置成{0.25,0.25,0.5,0.5}
这意味着我们可以随便调整尺寸,边框仍是会是连续的。他工作起来的效果和UIImage中的-resizeableImageWithCapInsets:方法效果非常类似,只是它可以用到任何寄宿图上,甚至包括用Core Graphics运行时绘制的图形上。
代码如下:
self.view.backgroundColor = [UIColor whiteColor];
UIImage *image = [UIImage imageNamed:@"geren_.png"];
CGRect contentsCenterRect = CGRectMake(0.25, 0.25, 0.5, 0.5); //会拉伸的区域
//横向拉伸
CALayer *layerH = [CALayer layer];
layerH.contents = (__bridge id) image.CGImage;
layerH.frame = CGRectMake(0, 60, 320, 72);
layerH.backgroundColor = [UIColor grayColor].CGColor;
layerH.contentsCenter = contentsCenterRect;
// layerH.contentsGravity = kCAGravityResizeAspect;
[self.view.layer addSublayer:layerH];
//纵向拉伸
CALayer *layerV = [CALayer layer];
layerV.contents = (__bridge id) image.CGImage;
layerV.frame = CGRectMake(100, 150, 72, 320);
layerV.backgroundColor = [UIColor redColor].CGColor;
layerV.contentsCenter = contentsCenterRect;
// layerV.contentsGravity = kCAGravityResizeAspect;
[self.view.layer addSublayer:layerV];
效果如下:
contentsCenter另外一个很酷的特性就是可以直接在interface builder里面进行配置,根本不用写代码。
如图:
custom Drawing
给contents赋CGImage值不是唯一的设置寄宿图的方法。我们也可以直接使用Core Graphics直接绘制寄宿图。能够通过集成UIView来重新实现-drawRect:方法来自定义绘制。
-drawRect:方法没有默认的实现,因为对UIView来说,寄宿图并不是必须的,它不在意那到底是一个单调的颜色还是一张图片的实例。如果UIView检测到-drawRect:方法被调用了,它就会为视图分配一个寄宿图。这个寄宿图的像素尺寸等于视图大小乘以contentsScale的值。
如果你不需要寄宿图,那就不要创建这个方法了,这会造成CPU和内存资源的浪费,这也就是为什么苹果建议:如果没有自定义绘制的任务就不要在子类中写一个空的-drawRect方法。
当视图在屏幕上出现的时候-drawRect:方法会被自动调用,-drawRect:里面的代码会利用Core Graphics 去绘制一个寄宿图。然后内容就会被缓存起来直到他需要被更新(通常是因为开发者调用了-setNeedsToDisplay方法,尽管影响到表现效果的属性值被更改时,一些视图类型会被自动重绘,如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参数传入。
让我们继续在项目学习,让他实现CALayerDelegate并做一些绘图操作:
代码如下:
- (void)viewDidLoad {
[super viewDidLoad];
self.view.backgroundColor = [UIColor whiteColor];
//创建一个layer
CALayer *layer = [CALayer layer];
layer.frame = CGRectMake(100, 100, 200, 200);
layer.backgroundColor = [UIColor blueColor].CGColor;
//ensure that layer backing image uses correct scale
layer.contentsScale = [UIScreen mainScreen].scale;
[self.view.layer addSublayer:layer];
layer.delegate = self;
//重新绘制
[layer display];
}
- (void)drawLayer:(CALayer *)layer inContext:(CGContextRef)ctx
{
CGContextSetLineWidth(ctx, 10.f);
CGContextSetStrokeColorWithColor(ctx,[UIColor redColor].CGColor);
CGContextStrokeEllipseInRect(ctx, layer.bounds);
}
效果如下:
这里我们要注意一些有趣的事情:
1:我们在layer上显示调用了-display。不同于UIView,当图层显示在屏幕上时,CALayer不会自动重绘它的内容。它把重新绘制的决定权交给了开发者。
2:尽管我们没有用maskToBounds属性。绘制的这个圆仍然沿着边界被裁剪了。这是因为当你使用CALayerDelegate绘制寄宿图的时候,并没有对超出边界外的内容提供绘制支持。
现在你理解了CALayerDelegate,并知道怎么使用它。但是除非你创建了一个单独的图层。你几乎没有机会用到CALayerDelegate协议,因为当UIView创建它的宿主图层时,它就会自动把图层的delegate设置为它自己。并提供一个-displaylayer:来实现,那所有的问题都没了、
当使用寄宿了视图的图层的时候,你也不必再实现 -displayLayer:和 -drawLayer:inContext:方法来绘制你的寄宿图。通常的做法就是实现UIView中的-drawRect方法,UIView会帮你做完剩下的工作,包括在需要重绘时调用-display方法。
总结
本章介绍了寄宿图和一些相关的属性,如何显示和放置图片。使用拼合技术来显示。使用CALayerDelegate和CoreGraphics来绘制图层内容。