iOS -图层树视图与层的关系

Core Animation其实是一个令人误解的命名。你可能认为它只是用来做动画的,但实际上它是从一个叫做Layer Kit这么一个不怎么和动画有关的名字演变而来,所以做动画这只是Core Animation特性的冰山一角。Core Animation是一个复合引擎,它的职责就是尽可能快地组合屏幕上不同的可视内容,这个内容是被分解成独立的图层,存储在一个叫做图层树的体系之中。于是这个树形成了UIKit以及在iOS应用程序当中你所能在屏幕上看见的一切的基础。

1、图层与视图。

如果你曾经在iOS或者Mac OS平台上写过应用程序,你可能会对视图的概念比较熟悉。一个视图就是在屏幕上显示的一个矩形块(比如图片,文字或者视频),它能够拦截类似于鼠标点击或者触摸手势等用户输入。视图在层级关系中可以互相嵌套,一个视图可以管理它的所有子视图的位置。

在iOS当中,所有的视图都从一个叫做UIVIew的基类派生而来,UIView可以处理触摸事件,可以支持基于Core Graphics绘图,可以做仿射变换(例如旋转或者缩放),或者简单的类似于滑动或者渐变的动画

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的原因。他们功能上很相似,但是在实现上有着显著的区别。

1.2 图层的能力

如果说CALayer是UIView内部实现细节,那我们为什么要全面地了解它呢?苹果当然为我们提供了优美简洁的UIView接口,那么我们是否就没必要直接去处理Core Animation的细节了呢?

某种意义上说的确是这样,对一些简单的需求来说,我们确实没必要处理CALayer,因为苹果已经通过UIView的高级API间接地使得动画变得很简单。

但是这种简单会不可避免地带来一些灵活上的缺陷。如果你略微想在底层做一些改变,或者使用一些苹果没有在UIView上实现的接口功能,这时除了介入Core Animation底层之外别无选择。

我们已经证实了图层不能像视图那样处理触摸事件,那么他能做哪些视图不能做的呢?这里有一些UIView没有暴露出来的CALayer的功能:阴影,圆角,带颜色的边框;3D变换;非矩形范围,透明遮罩、多级非线性动画。

一个视图只有一个相关联的图层(自动创建),同时它也可以支持添加无数多个子图层,你可以显示创建一个单独的图层,并且把它直接添加到视图关联图层的子图层。尽管可以这样添加图层,但往往我们只是见简单地处理视图,他们关联的图层并不需要额外地手动添加子图层。

使用图层关联的视图而不是CALayer的好处在于,你能在使用所有CALayer底层特性的同时,也可以使用UIView的高级API(比如自动排版,布局和事件处理)

然而,当满足以下条件的时候,你可能更需要使用CALayer而不是UIView:

1)、开发同时可以在Mac OS上运行的跨平台应用

2). 使用多种CALayer的子类,并且不想创建额外的UIView去包封装它们所有;

但是这些例子都很少见,总的来说,处理视图会比单独处理图层更加方便

使用图层

在屏幕中创建一个UIView对象正方形的,我们要在这个view上添加一个蓝色的色块。我们可以添加一个子view用代码或者IB都OK。但是这里我们准备使用CAlayer。如果要想用layer相关的接口和属性需要添加QuartzCore框架。

//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];

每一个视图只有一个相关联的图层,可以在这个自动创建的图层上添加无数多的字图层。

寄宿图

寄宿图就是指在CAlayer中包含的图。

对寄宿图的处理主要在layer的contents属性上,可以参考文章:iOS-CAlayer之contents 属性。给contents赋值CGImage并不是唯一的设置寄宿图的方法。我们可以通过CoreGraphics直接绘制寄宿图。可以通过重写drawRect方法来绘制。

-drawRect:方法没有默认的实现,因为对UIView来说,寄宿图并不是必须的,它不在意是单调的颜色还是一个图片的实例。如果UIView检测到-drawRect:方法被调用了,它就会为视图分配一个寄宿图,这个寄宿图的像素尺寸就是视图大小乘以contentsScale。如果你不需要激素图,那就不要这个方法了,这会造成CPU资源和内存的浪费。这就是如果没有自定义的绘制任务,不要重写-drawRect:方法。

     当视图在屏幕出现的时候-drawRect:方法就会自动调用。-drawRect:方法里的代码利用coregraphics去绘制一个寄宿图,然后内容就会被缓存起来直到它需要被更新(通常是因为开发者调用-setNeedsDisplay方法,尽管影响到表现效果的属性值被更改时,一些视图类型会被自动重绘,如bounds属性)。

CAlayer有一个可选的delegate属性,实现了CAlayerDelegate协议,当CAlayer需要一个内容特定的信息时,就会从协议中请求。CAlayerDelegate是一个非正式协议,没有CAlayerDelegate可以在类中引用。

当被要求重绘时,CAlayer会请求它的代理给它一个寄宿图来显示。它通过下面这个方法做到的:

-(void)displayLayer:(CAlayer*)layer;

我们就可以在方法中直接设置contents属性,如果代理不实现此方法,CAlayer的代理就会尝试调用下面这个方法:

-(void)drawLayer:(CAlayer*)layer in Context:(CGContextRef)ctx;

下面利用CAlayerDelegate做一些绘图工作。

//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);

}

Tips:1、我们在blueLayer上显式的调用-display。不同于UIView,当CAlayer显示在屏幕上时,CALayer不会自动重绘它的内容。它把重绘的决定权交给了developer。

           2、尽管我们没有用maskToBounds属性,绘制的那个圆仍然沿边界被裁剪了,这是因为当你使用CAlayerDelegate绘制激素图的时候,比没有对超出layer边界的内容提供绘制支持。

现在我们理解并知道怎么使用CAlayerDelegate,但是除非你创建一个单独的图层,你几乎没有机会用奥CAlayerDelegate协议。因为当UIView创建它自己的宿主layer的时候,它自动的就把layer的delegate设置为它自己,并提供了-displayer:的实现。

当使用寄宿图层的时候,你也不必实现CALayerDelegate的方法。通常都是重写-drawRect:方法,UIView就会帮你完成剩下的工作,包括重绘时候调用-display方法。

     这一小节介绍了寄宿图和一些相关属性。学到了如何显示和放置图片,使用拼合技术来显示,以及用CAlayerDelegate 和coreGraphics绘制图层的。

图层几何学

在上一节中,我们介绍了图层背后的图片,和一些控制图层的坐标和旋转的属性,现在我们要学习如何根据父图层和兄弟图层来控制位置和尺寸,如何管理图层的几何结构,以及它们是如何被自动调整和自动布局影响的。

1、UIView有三个比较重要的布局属性:frame,bounds,center;CAlayer对应的叫做frame,bounds和position。为了区分清楚,layer用position,Uiview用center它们都是代表同样的值。

frame代表了图层的外部坐标也就是在父图层上占据的空间,bounds时内部坐标({0,0}通常是图层的左上角),center和position都代表了相对于父图层anchorPoint:锚点所在的位置。现在把它想成图层的重点就好。

UIView和CAlayer的坐标系

        视图的frame、bounds、center属性仅仅是存取方法,当操作视图frame,实际上是在改变位于视图下方CAlayer的frame,不能独立于图层之外改变视图的frame。

对于视图或者图层来说,frame并不是一个非常清晰的属性,他是一个虚拟的属性,是根据bounds,positon和transform计算而来,所以当其中任何一饿值发生变化,frame都会变化。相反,改变frame的值一样会影响bounds、tranform的值。

当图层做变换的时候,比如旋转或者缩放,frame实际上代表了覆盖在图层旋转之后的整个轴对齐的矩形区域,也就是frame的宽高和bounds的宽高不再一致了。

旋转一个视图或者图层之后的frame

上文提到过视图的center和图层的position都是指定了一个anchorPoint相对于父图层的位置。图层的anchorPoint通过position来控制它的frame的位置,可以认为anchorPoint是用来移动图层的。

    默认来说,anchorPoint位于图层的中点,所以图层以这个点为中心放置。anchorPoint属性并没有被UIView接口暴露出来,这也是视图positon属性被叫做center的原因。但是图层的anchorPoint,可以被移动,比如你把anchorPoint设置位于图层的frame的左上角,于是图层的内容将会想右下角的positon方法移动而不是居中了。

改变了anchorPoint效果

和前面contentsRect以及contentsCenter类似,anchorPoint是用单位坐标来描述的,也就是图岑的相对坐标,图层左上角是(0,0)右下角是(1,1),因此默认是(0.5,0.5)。anchorPoint可以通过制定x、y小于0或者大于1,使它放置在图层范围外。  

    在图中我们可以看到当anchorPoint变化时候,postion属性保持固定值并没有变,但是frame却移动了。什么时候需要改变anchorPoint呢?我们举例说明。创建一个模拟闹钟的项目。

钟面和三个指针

闹钟的组件通过IB来排列,这些图片嵌套在一个容器视图中,并且自动调整和自动布局都被禁用了。这是因为自动调整会影响到视图的frame,当视图旋转时候,frame是会发生变化的,这将会导致一些布局上的失灵。

钟面和指针的布局

我们用NSTimer来更新闹钟,使用视图的transform属性来旋转钟表。

@property (nonatomic, weak) IBOutlet UIImageView *hourHand;

@property (nonatomic, weak) IBOutlet UIImageView *minuteHand;

@property (nonatomic, weak) IBOutlet UIImageView *secondHand;

@property (nonatomic, weak) NSTimer *timer;

- (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);

}

运行项目看起来有点儿奇怪,因为钟表的指针在围绕中心旋转,这并不是是想要的。

钟面和不对齐的钟指针

怎么解决这个问题呢?可以在图片的末尾添加一个透明空间,但是这样会让图片变大,也会消耗更多内存。更好的方案是使用anchorPoint属性,我们在viewDidload中添加几行代码让钟表每个指针的anchorPoint做一些平移

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);

指针向上anchorPoint向下移动。最后效果如下:

调整后的钟表图片

个人感觉anchorPoint就是图层旋转时候的旋转轴。

坐标系

和视图一样,图层在图层数当中也是相对于父图层按层级关系放置,一个图层的position依赖于它父图层的bounds,如果父图层发生了移动,它的所有子图层也会跟着移动。但是有时候你需要知道一个图层的绝对位置,或者是相对于另一个图层的位置,而不是它当前父图层的位置。CAlayer提供了一些转换工具类方法,这些以conver开头的方法可以把定义在一个图层坐标系下的点或者矩形转换成另一个图层坐标系下的点或者矩形。

翻转的几何结构

通常说来,在iOS上Original位于父图层的左上角,但在OSX上,通常位于坐下角。CoreAnimation可以通过geometryFlipped属性来适配这两种情况,它决定了一个图层的坐标是否相对于父图层垂直翻转,是一个Bool类型。在iOS上通过设置它为YES意味着它的子图层将会被垂直翻转,也就是将会沿着地步排版而不是通畅的顶部。

Z坐标轴

和UIView严格的二维坐标系不同,CAlayer存在于一个三维空间中。除了我们讨论过的position和anchorPoint属性外,CAlayer还有另外两个属性,zPosition和anchorPointZ,二者都是在z轴上描述图层位置的浮点类型。

zPosition属性在大多数情况下并不常用。在涉及CATransform3D,在三维空间移动和旋转图层会用到,除此最实用的功能就是改变图层的显示顺序了。通常,图层是根据它们子图层的sublayers出现的顺序来绘制的,这就是所谓的画家算法-就像一个画家在墙上作画,后绘制的图层灰遮盖住之前的图层,但是通过增加图层的zPositon,就可以把图层向相机方向前置,于是它就在所有其他图层的前面了。这里的相机实际上是相对于用户的视角,这里和iPhone背面的内置相机没有任何关系。

//move the green view zPosition nearer to the camera

self.greenView.layer.zPosition = 1.0f;

就可以改变图层的前后顺序了。

Hit Testing

在开始图层树证实了最好使用图层相关的视图,而不是创建独立的图层关系。其中一个原因就是要额外处理复杂的触摸事件。

CAlayer并不关心任何响应链事件,所以不能直接处理触摸事件或者手势。但是它有一系列的方法帮你处理了事件:-containsPoint:和-hitTest:

- (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]) {}

}

-hitTest:方法同样接受一个CGPoint类型参数,它返回的不是Bool类型,它返回的是图层本身,或者包含这个坐标点的叶子节点图层。如果这个点在最外层的范围之外,则返回nil,使用如下

- (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];

}

}

Notes:当调用图层的-hitTest:方法时,测试的顺序严格按照图层树当中图层的顺序(和UIView处理事件类似)。之前说的zPosition属性可以明显的改变屏幕上图层的顺序,但不能改变事件传递的顺序。这意味着如果改变了图层的zPosition,你会发现奖不能检测到最前方的视图点击事件,这是因为被另一个图层遮盖住了,虽然前面的图层zPosition值较小,但是在图层顺序中靠前。

自动布局

你可能用过UIViewAutoresizingMask类型的一些常量,应用于当父视图改变尺寸的时候,相对应UIView的frame也跟着更新的场景。当使用视图的时候,可以充分利用UIView类接口暴露出来的UiViewAutoresizeingMask和NSlayoutConstraint API,但是如果随意控制CALayer的布局,就需要手工操作。最简单的方法就是使用CALayerDelegate:

- (void)layoutSublayersOfLayer:(CALayer *)layer;

当图层bounds发生改变,或者图层的-setNeedslayout方法被调用的时候,这个函数将会执行。这使得你可以手动地重新摆放或者重新调整子图层的大小,但是不能像UIView的autoresizingMask和constraints属性做到自适应屏幕旋转。

这也是最好使用视图而不是单独的图层来构建应用的一个重要原因。

视觉效果

我们在前一节图层几何学中讨论了图层的frame,之前还讨论了图层的寄宿图。但是图层不仅仅可以时图片或者颜色的容器,还有一系列内建的特性使得创造美丽优雅的令人深刻的界面元素称为可能。这一节我们学习能够通过CAlayer属性是心啊的视觉效果。

圆角

圆角矩形在iOS中比较流行。除了直接使用有圆角的原始图外,CAlayer的conrnerRadius属性控制着图层角的曲率。默认情况下这个曲率值只影响背景颜色而不是背景图片或者子图层。不过,如果把maskTobounds设置为YES,图层理的所有东西都会被截取。

self.layerView2.layer.masksToBounds = YES;

      CALayer另外两个非常有用属性就是borderWidth和borderColor。二者共同定义了图层边的绘制样式。这条线(也被称作stroke)沿着图层的bounds绘制,同时也包含图层的角。边框是根据图层边界变化的,而不是图层里面的内容。

阴影

shadowOpacity属性的值大于0,阴影就可以显示在任意图层之下。若要改动阴影的表现,你可以使用CAlayer的另外三个属性:shadowColor、shadowOffset和shadowRadius。shadowOffset属性控制着阴影的方向和距离。它是一个CGSize的值,默认是{0,-3},阴影相对Y轴有3个点的向上位移,这个是因为CAlayer的坐标和视图的坐标是不同的,CAlayer的坐标是在MacOS上使用的。

shadowRadius属性控制着阴影的模糊度,当它值为0,阴影就和视图一样有一个非常确定的边界线,当值越大的时候,边界线就越来越模糊和自然。和图层的边框不同,图层的阴影继承自内容的外形,而不是根据边界和角半径来确定。为了计算出阴影的形状,CoreAnimation会将寄宿图考虑在内,然后通过这些完美搭配图层来创建一个阴影。阴影通常就是在layer的边界之外,如果开启了masksToBounds属性,所有从图层中突出来的内容都会被剪掉。如果像沿着内容裁切而且还有阴影,就需要两个图层:一个花阴影的空的外图层,和一个用masksToBounds裁剪内容的内图层。

实时计算阴影是一个非常消耗资源的事情,尤其是有多个子图层的时候。如果事先知道阴影的形状,可以通过shadowPath来提高性能。shadowPath是一个CGPathRef的类型。

//create a square shadow

CGMutablePathRef squarePath = CGPathCreateMutable();

CGPathAddRect(squarePath, NULL, self.layerView1.bounds);

self.layerView1.layer.shadowPath = squarePath; CGPathRelease(squarePath);

//create a circular shadow

CGMutablePathRef circlePath = CGPathCreateMutable();

CGPathAddEllipseInRect(circlePath, NULL, self.layerView2.bounds);

self.layerView2.layer.shadowPath = circlePath; CGPathRelease(circlePath);

图层蒙版

        通过maskToBounds属性,我们可以沿着边界裁剪图形;通过cornerRadius属性,我们可以设定一个圆角。到那时有时候你希望展示的内容不实载一个矩形或者圆角的矩形。比如,展示一个有星形框架的图片,或者让一些古卷文字慢慢渐变成背景色,而不是一个突兀的边界。

      使用一个32位的有alpha通道的png图片通常是创建一个非矩形视图最方便的方法,可以通过指定一个透明蒙版来实现。但是这个方法不能让我们通过代码来实现蒙版,也不能让子图层或者子视图裁剪成同样的形状。

CAlayer有一个属性mask可以解决这个问题,这个属性本身就是一个CAlayer类型,有和其他图层一样绘制和布局的属性。它类似一个子图层,相对于父图层布局,但是它却不是一个普通的子图层。不同于那些绘制在父图层中的子图层,mask图层定义了父图层的部分可见区域。

mask图层的color属性是无关要紧的,真正重要的是图层的轮廓。mask属性就像一个饼干切割机,mask图层实心的部分会被保留,其它被舍弃。

如果mask图层比父图层小,只有在mask图层里面的内容才显示

图片和蒙版图层作用一起的效果

我们将演示下这个过程。

@property (nonatomic, weak) IBOutlet UIImageView *imageView;//图层

- (void)viewDidLoad

{

[super viewDidLoad];

//create mask layer

CALayer *maskLayer = [CALayer layer];

maskLayer.frame = self.layerView.bounds;

//self.layerView是蒙版模型的view

UIImage *maskImage = [UIImage imageNamed:@"Cone.png"];

maskLayer.contents = (__bridge id)maskImage.CGImage;

//apply mask to image layer

self.imageView.layer.mask = maskLayer;

}

CAlayer蒙版不限于静态图。任何有图层构成的都可以作为mask属性,这意味着蒙版可以通过代码甚至动画实时生成。

拉伸过滤

当我们视图显示一个图片的时候,都应该正确的显示这个图片。满足如下条件:

1)能够显示最好的画质,像素既没有被压塑也没有被拉伸。

2)能更好的使用内存。3)最好的性能表现,CPU不需要为此额外计算。

不过有时候,显示一个非真实大小的图片也是我们需要的效果。比如一个头像或者图片的缩略图,再比如一个可以被拖拽和伸缩的大图。当图片需要显示不同大小的时候,拉伸过滤的算法就起作用了,它作用于原图的像素上并根据需要生成新的像素显示在屏幕上。重绘图片大小取决于需要拉伸的内容,放大或者缩小的需求。CAlayer为此提供了三种拉伸过滤常量:kCAFilterLinear,kCAFilterNearest,kCAFilterTrilinear。

ninification缩小图片和magnification放大图片,默认的过滤器都是kCAFillterLinear,这个过滤器采用双线性滤波算法,他在大多数情况下都表现良好。双线性滤波算法通过多个像素取样最终生成新的值,得到一个平滑的表现不错的拉伸,但是当放大倍数比较大的时候图片就模糊了。

kCAFilterTrilinear和kCAFilterLinear非常相似,大部分情况下二者都看不出区别,但是比较kCAFilterLinear,该三线性滤波算法存储了多个大小情况下的图片,并三位取样,同时结合大图和小图的存储进而得到最后结果。

总的来说,对于比较小的图或者是差异特别明显,极少斜线的大图,kCAFilterNearest算法会保留这种差异明显的特质以呈现更好的结果。但是对于大多数的图尤其是很多斜线或者曲线轮廓的图片来说,kCAFilterNearest算法会导致更差的结果,应该用线性过滤算法

我们来验证一下,改动前面闹钟的项目,用LCD风格的数字显示。我们用简单的像素字体创造数字显示方式。

存储在本地的数字图片

用简单的拼合技术来显示LCD数字风格的像素字体

用IB放置六个视图,小时,分钟,秒钟各两个;

@interface ViewController ()

@property (nonatomic, strong) IBOutletCollection(UIView) NSArray *digitViews;

@property (nonatomic, weak) NSTimer *timer;

@end

- (void)viewDidLoad

{

[super viewDidLoad]; //get spritesheet image

UIImage *digits = [UIImage imageNamed:@"Digits.png"];

//set up digit views

for (UIView *view in self.digitViews) {

//set contents

view.layer.contents = (__bridge id)digits.CGImage;

view.layer.contentsRect = CGRectMake(0, 0, 0.1, 1.0);

view.layer.contentsGravity = kCAGravityResizeAspect;

}

//start timer

self.timer = [NSTimer scheduledTimerWithTimeInterval:1.0 target:self selector:@selector(tick) userInfo:nil repeats:YES];

//set initial clock time

[self tick];

}

- (void)setDigit:(NSInteger)digit forView:(UIView *)view

{//拼合图层

//adjust contentsRect to select correct digit

view.layer.contentsRect = CGRectMake(digit * 0.1, 0, 0.1, 1.0);

}

- (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]];

//set hours

[self setDigit:components.hour / 10 forView:self.digitViews[0]];

[self setDigit:components.hour % 10 forView:self.digitViews[1]];

//set minutes

[self setDigit:components.minute / 10 forView:self.digitViews[2]];

[self setDigit:components.minute % 10 forView:self.digitViews[3]];

//set seconds

[self setDigit:components.second / 10 forView:self.digitViews[4]];

[self setDigit:components.second % 10 forView:self.digitViews[5]];

}

运行的效果如下:

有点模糊,是由默认的kCAFilterLinear引起的

为了能够显示的更加清晰,通过上面我们知道小图和没有斜线的图,kCAFilterNearest显示效果更好。所以在for循环中加入:

view.layer.magnificationFilter = kCAFillterNearest;像素放大过滤器。


设置过滤之后的清晰显示

透明度

        UIView有个alpha的属性来去定视图的透明度。CAlayer有一个等同的属性叫做opacity,这两个属性都是影响子层级的,也就是说,如果你给一个图层设置了opacity属性,那么它的子图层都会受到影响。

iOS常见的做法是把一个控件的alpha设置为0.5,使其看上去呈现不可用状态。对于独立的视图来说还不错,但是对一个有子视图的控件就又点儿奇怪了。这是由透明度的混合叠加造成的,当你显示一个50%透明度的图层时,图层的每一个像素都会一半显示自己的颜色另一半显示下面的颜色。

        理想状况下,当你设置了一个图层的透明度,我们希望它包含的整个图层树上一个整体一样的透明效果。我们可以通过CAlayer的一个叫做shouldRasterize属性来时现组透明的效果,如果他被设置为YES,在应用透明度之前,图层及子图层都会被整合成为一个整体的图片,这样就没有透明度混合的问题了。

       为了启用shouldRasterize属性,我们设置了图层的rasterizationScale属性。默认情况下,所有图层都拉伸1.0,所以使用shouldRasterize属性,确保设置了rasterizetionScale属性去匹配屏幕,防止出现Retina屏幕像素化的问题。

//创建一个不透明button,用来对比

UIButton *button1 = [self customButton];

button1.center = CGPointMake(50, 150);

[self.containerView addSubview:button1];

//创建一个变化的button

UIButton *button2 = [self customButton];

button2.center = CGPointMake(250, 150);

button2.alpha = 0.5;

[self.containerView addSubview:button2];

//enable rasterization for the translucent button

button2.layer.shouldRasterize = YES;//组透明

button2.layer.rasterizationScale = [UIScreen mainScreen].scale;变化的拉伸比例。

这样button2像一个整体一样被设置了透明度。

变换

1、仿射变换

在图层几何学中,我们使用了UIView的transform属性旋转了钟的指针,但是没有解释背后的运作原理。实际上UIView的transform属性是一个CGAffinetransfor的类型,用来在二维空间做旋转,缩放和平移。CGAffineTransform是一个可以和二维空间向量例如CGPoint做乘法的3x2矩阵。

用矩阵表示CGPoint和CGAffineTransform的关系

图中显示灰色的元素是为了满足矩阵的乘法规则添加的信息,不改变运算结果。

当图层应用变换矩阵,图层内的一个点都被相应的变换。CGAffineTransform中的仿射的意思是无论变换矩阵用什么值,图层中平行的两条线变换以后仍然保持平行。

CGAffineTransformMakeRotation(CGFloat angle)//旋转

CGAffineTransformMakeScale(CGFloat sx, CGFloat sy)//缩放

CGAffineTransformMakeTranslation(CGFloat tx, CGFloat ty)//平移

UIView可以通过设置transform属性做变换,但实际上它只是封装了内部图层的变换。CAlayer也有一个transform属性,但是它的类型是CATransform3D,而不是平面CGAffineTransform,图层对应的属性是affineTransform。例如:

CGAffineTransform transform = CGAffineTransformMakeRotation(M_PI_4);

self.layerView.layer.affineTransform = transform;

混合变换

CoreGraphics 提供一系列的函数可以在一个变换的基础上做更深层次的变换,例如可以做一个既要缩放又要旋转的变换。

当操纵一个变换时候,初始化一个单位矩阵是很重要的,CGAffineTransformIdentity 提供一个方便的常量。

最后,如果要混合两个已经存在的变换矩阵,就可以使用如下方法,在两个变换的基础上创建一个新的变换:

CGAffineTransformconcat(CGAffineTransform t1,t2);

下面我们完成一个组合变换,先缩小50%,再旋转30度,最后像右平移200个像素。

//create a new transform初始化一个单位矩阵

CGAffineTransform transform = CGAffineTransformIdentity;

//scale by 50%

transform = CGAffineTransformScale(transform, 0.5, 0.5);

//rotate by 30 degrees

transform = CGAffineTransformRotate(transform, M_PI / 180.0 * 30.0);

//translate by 200 points

transform = CGAffineTransformTranslate(transform, 200, 0);

self.layerView.layer.affineTransform = transform;//在图层上应用变换。

在实验中我们发现,图片向右边发生了平移但是没有200像素,另外还有点儿向下平移。原因在于我们按照顺序做了变换,上一个变换的结果会影响之后的变换。也就是说顺序的改变造成的结果是不一样的,若想分开效果就要用 

CGAffineTransformConcat去结合生成新的仿射。

3D变换

CG前缀告诉我们CGAffineTransform类型属于CoreGraphics,是一个2D绘图API,前面我们提到zPositon属性,可以让图层靠近或者用户视角,transform属性(CATransform3D)可以做到这点,让图层在3D空间内移动或者旋转。

   和CGAffineTransform类似,CATransform3D也是一个矩阵,不过是一个4x4的矩阵。

和CGAfineTransform矩阵类似,CoreAnimation提供了一系列的方法来创建和组合CATransform3D类型的矩阵,和CoreGraphics类似,但是3D的平移和旋转多了一个z参数,旋转的函数除了angle之外,多了x,y,z三个参数,分别决定了每个坐标轴方向的旋转:

CATransform3DMakeRotation(CGFloat angle, CGFloat x, CGFloat y, CGFloat z)

CATransform3DMakeScale(CGFloat sx, CGFloat sy, CGFloat sz)

CATransform3DMakeTranslation(Gloat tx, CGFloat ty, CGFloat tz)

x、y、z、轴,以及围绕它们的方向

有图可见,绕Z轴旋转等同于之前二维空间的仿射旋转,但是围绕X轴和Y轴的旋转就突破了屏幕的二维空间,并且在用户视角看来发生了倾斜。

我们做一个例子。CATransform3DMakeRotation对视图内的图层绕Y轴做了45度的旋转,我们可以吧视图向右倾斜,这样看得更清晰。

CATransform3D transform = CATransform3DMakeRotation(M_PI_4, 0, 1, 0);

self.layerView.layer.transform = transform;绕Y轴向右

看起来图层并木有被旋转,仅仅是在水平方向上的一个压缩,其实没错,视图看起来更窄是因为我们在一个斜向的视角看它,不是透视。

       在真实世界中,当物体远离我们的时候,由于视角的原因看起来会变小,理论上说远离我们的视图的边要比靠近视角的边跟短,但实际上并没有发生,而我们当前的视角是等距离的,也就是在3D变换中任然保持平行,和之前提到的仿射变换类似

      为了做一些修正,我们需要引入投影变换(又称作z变换)来对除了旋转之外的变换矩阵做一些修改,Core Animation并没有给我们提供设置透视变换的函数,因此我们需要手动修改矩阵值,幸运的是,很简单:

CATransform3D的透视效果通过一个矩阵中一个很简单的元素来控制:m34,m34用于按比例缩放X和Y值来计算到底离视角多远。

m34元素,用来做透视

m34的默认值是0,我们可以通过设置m34为-1.0/d来应用透视效果,d代表了,想象中视角相机和屏幕之间的距离,以像素为单位,这个距离不需要计算,估算一个就好,因为视角相机并不存在,可以根据效果自由决定。通常在500-1000之间,减少距离的值会增强透视效果。对视图应用透视的代码如下:

//create a new transform

CATransform3D transform = CATransform3DIdentity;

//apply perspective

transform.m34 = - 1.0 / 500.0;

//rotate by 45 degrees along the Y axis

transform = CATransform3DRotate(transform, M_PI_4, 0, 1, 0);

//apply to layer

self.layerView.layer.transform = transform;

增加了透视感之后,结果更像是在空间中3D翻转了,更符合用户视角。

灭点

在透视角度绘图的时候,远离相机视角的物体会变小,当远到一个极限距离的时候,它们可能就缩成了一个点,于是所有物体最后都汇聚消失在同一个点。在现实中,这个点通常是视图的中心,于是为了在应用中创建拟真效果的透视,这个点应该聚在屏幕中点,或者至少包含所有3D对象的视图中点。

灭点

CoreAnimation定义了这个点位于变换图层anchorPoint,通常初始化时候在图层的中心,但并不是永远都在中心,如果手动改变也是会变的,比如闹钟的例子。也就是说,当图层发生变换时候,这个点位置不变的,变换的轴线就是这个点。

当改变一个图层postion,也改变了它的灭点,做3D变换的时候要记住这点。当视图通过m34来让它更加有3D效果,应该首先把它放倒屏幕中央,然后通过平移来把它移动到指定位置,而不是直接改变它的position,这样3D图层都共享一个灭点。

如果有多个视图或者图层,每个都做3D变换,那就需要分别设置相同的m34值,并且确保在变换之前都在屏幕中央享受同一个position。CAlayer中又一个更好的方法。

CAlayer有个sublayerTransform属性。它也是CATransform3D类型,它能影响到所有的子图层。这意味着,我们可以一次性的对包含这些图层的容器做变换,所有的子图层都自动继承了这个变换方法。

通过一个地方设置透视变换的一个显著优势就是,灭点被设置在容器图层中点,不要再对子图层分别设置了。下面是一个例子

一个视图容器内并排放置两个视图

@interface ViewController ()

@property (nonatomic, weak) IBOutlet UIView *containerView;

@property (nonatomic, weak) IBOutlet UIView *layerView1;

@property (nonatomic, weak) IBOutlet UIView *layerView2;

@end

@implementation ViewController

- (void)viewDidLoad

{

[super viewDidLoad];

//apply perspective transform to container

CATransform3D perspective = CATransform3DIdentity;//初始化一个,position在中点

perspective.m34 = - 1.0 / 500.0;

//应用了sublayerTranform属性,保证子layer变换都在同一个灭点。

self.containerView.layer.sublayerTransform = perspective;

//rotate layerView1 by 45 degrees along the Y axis

CATransform3D transform1 = CATransform3DMakeRotation(M_PI_4, 0, 1, 0);

self.layerView1.layer.transform = transform1;

//rotate layerView2 by 45 degrees along the Y axis

CATransform3D transform2 = CATransform3DMakeRotation(-M_PI_4, 0, 1, 0);

self.layerView2.layer.transform = transform2;

}

相同的透视效果分别对视图做变换

背面

上边例子中我们都是在正面旋转一定角度看到的,如果旋转180度呢?图层完全旋转一个半圆,我们就从背面去看它了。

视图的背面,一个镜像对称的图片

但这并不是一个很好的特性,因为如果图层包含文本或者其他控件,用户看到这些内容的镜像图片会很奇怪,也会造成资源的浪费:想象一个不透明的固体立方体,既然永远都看不到这些图层的背面,为什么要浪费GPU来绘制它们?

CAlayer有个doubleSided的属性来控制图层的背面是否要被绘制。这是一个BOOL类型,默认为YES,设置为NO,那么当图层正面从相机视角消失的时候,它背面不会被重绘。

扁平化图层

如果对包含已经做过变换的图层的图层做反方向的变换会出现什么呢?

反方向变换的嵌套图层

@interface ViewController ()

@property (nonatomic, weak) IBOutlet UIView *outerView;

@property (nonatomic, weak) IBOutlet UIView *innerView;

@end

@implementation ViewController

- (void)viewDidLoad

{

[super viewDidLoad];

//rotate the outer layer 45 degrees

CATransform3D outer = CATransform3DMakeRotation(M_PI_4, 0, 0, 1);

self.outerView.layer.transform = outer;

//rotate the inner layer -45 degrees

CATransform3D inner = CATransform3DMakeRotation(-M_PI_4, 0, 0, 1);

self.innerView.layer.transform = inner;

}

@end

在2D平面转换结果和我们想的一样。

如果在3D情况下再试一个。修改代码,让内外两个视图绕Y轴,而不是z轴,再加上透视效果。注意不能用sublayerTransform,因为内部图层并不直接是容器图层的子图层,所以要分别对图层设置透视变换。

//让outer layer绕着Y轴45度

CATransform3D outer = CATransform3DIdentity;

outer.m34 = -1.0 / 500.0;

outer = CATransform3DRotate(outer, M_PI_4, 0, 1, 0);

self.outerView.layer.transform = outer;

//rotate the inner layer -45 degrees:内图层绕着Y轴-45度

CATransform3D inner = CATransform3DIdentity;

inner.m34 = -1.0 / 500.0;

inner = CATransform3DRotate(inner, -M_PI_4, 0, 1, 0);

self.innerView.layer.transform = inner;

我们预期效果如下:

绕Y轴做相反旋转的预期结果

但我们看到的却不是这样的,我们看到的实际画面是下面这样,内部图层仍然向左旋转,并发生了扭曲:

绕Y轴做相反旋转的真实结果

这是由于CA图层尽管都存在于3D空间中,但是不同的图层不都存在于同一个3D空间。每一个图层的3D场景其实都是扁平化的。当你从正面观察一个图层,看到的实际上由子图层创建的想象出来的3D场景,但当我们倾斜这个图层,你会发现这个3D场景仅仅是被绘制在图层的表面上。

       类似的,当你在玩一个3D游戏,实际上仅仅是把屏幕做了一次倾斜,或许在游戏中可以看见有一面墙在你面前,但是倾斜屏幕并不能够看见墙里面的东西。所有场景里面绘制的东西并不会随着你观察它的角度改变而发生变化;图层也是同样的道理。

       这使得用Core Animation创建非常复杂的3D场景变得十分困难。你不能够使用图层树去创建一个3D结构的层级关系--在相同场景下的任何3D表面必须和同样的图层保持一致,这是因为每个的父视图都把它的子视图扁平化了。

至少当你用正常的CALayer的时候是这样,CALayer有一个叫做CATransformLayer的子类来解决这个问题。

另开一篇介绍这个“iOS-CATransformlayer”。


推荐阅读更多精彩内容