IOS核心动画高级三:图层几何学
第二章里,我们介绍了图层背后的图片,和一些控制图层坐标和旋转的属性,在本章里面我们将要看一看在图层内部是如何根据父图层和兄弟图层来控制位置和尺寸的。另外我们也会涉及如何管理图层的几何结构,以及它是如何被自动调整和自动布局影响。
布局
UIView有三个比较重要的布局属性:frame、bounds和center。CALayer对应的叫frame、bounds和position。为了能够区分清楚,图层用了”position“,视图用了”center“,但是他们都代表同样的值。
frame代表了图层的外部坐标(也就是在父图层上占据的空间),bounds是内部坐标({0,0}通常是图层的左上角)。center和position都代表了相对父图层anchorPoint所在的位置。anchorPoint我们后续再做介绍,现在把它想成是图层的中心点就好了,如图所示:
UIView和CALayer的坐标系视图的frame、bounds和center属性仅仅是存取方法。当操纵视图的frame,实际上是在改变位于视图下方CALayer的frame,不能够独立于图层之外改变视图的frame。
对于视图或者图层来说,frame并不是一个非常清晰的属性。它其实是一个虚拟属性,是根据bounds,position和transform计算而来,所以当其中任何一个值发生变化,frame都会改变。相反, 改变frame的值同样会影响到他们当中的值。
记住当对图层做变换的时候,比如旋转或者缩放,frame实际上代表了覆盖在图层旋转之后的整个轴对齐的矩形区域,也就是说frame的宽度可能和bounds的宽度不一致了。
如图所示:在旋转完成后一个视图或者图层的frame属性
锚点
之前提到过,视图的center属性和图层的position都指定了anchorPoint相对于父图层的位置。图层的auchorPoint通过position来控制它的frame的位置。你可以认为anchorPoint是用来移动图层的把柄。
默认来说,anchorPoint位于图层的中点,所以图层将会以这个点为中心进行放置。anchorPoint并没有被UIView接口暴露出来,这也是视图的position属性被叫做center的原因。但是图层的anchorPoint可以被移动,比如你可以把它置于图层frame的左上角,于是图层的内容将会向右下角的position方向移动,而不是剧中。
图示如下:
和第二章中用到的contentsRect和contentsCenter属性相类似,anchorPoint用单位坐标来描述,也就是图层的相对坐标**。图层的左上角是{0,0},图层的右下角是{1,1},因此默认的坐标为{0.5,0.5};anchorPoint可以通过指定x和y的值小于0和大于1,使它放置在图层范围之外。
注意在上图中,当改变anchorPoint值,position属性保持固定的值并没有发生变化。但是frame却移动了。
那在什么场合下需要改变anchorPoint的值呢?既然我们可以随意改变图层位置,那改变anchorPoint不会产生困惑吗?为了举例说明,我们来讲一个实用的例子,创建一个模拟闹钟的项目。
钟面和钟表有四张图片组成。
组成钟面和钟表的四张图片为了简单说明,我们还是用传统的方式来装载和加载图片,实用四个UIImageView实例。(当然你也可以使用普通的视图,设置他们的contents属性);
闹钟的组件通过IB来排列,这些图片视图嵌套在一个容器视图之内,并且自动布局和自动调整都被禁止了。这是因为自动调整会影响到视图的frame,当视图旋转时,frame是会发生改变的,这将会导致一些布局上的失灵。
我们用NSTimer来更新闹钟,使用视图的transform来进行钟表的旋转(如果你对这个属性不熟悉,我们会再后面的章节进行详细说明),
使用Interface Bulider创建的布局代码如下:
@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
效果如下:
钟面和对不起的指针你也许会认为可以在interface bulider当中调整指针图片的位置来解决,但其实并不能达到目的,因为如果不放到钟面中间的话,同样不能正确旋转。
也许在图片的末尾添加一个透明空间也是一个解决方案,当这样会让图片变大,也会消耗更多的内存,这样并不优雅。
更好的解决方案是使用anchorPoint属性,我们在-viewDidLoad方法中添加几行代码来给每个钟指针的anchorPoint做一些平移,
代码如下:
- (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位于父图层的左上角,但是在MaxOS系统上,通常是位于左下角,CoreAnimation可以通过geometryFilpped属性来适配这两种情况,它决定了一个图层的坐标是否相对于父图层垂直翻转,是一个bool类型,在ios上通过设置它为yes意味着它的子视图将会被垂直翻转,也就是将会沿着底部排版而不是通常的顶部(它的所有子图层也同理,除非把它们的geometryFlipped属性也设置成yes)。
Z坐标轴
和UIView严格的二维坐标系不同,CALayer存在于一个三维空间中,除了我们已经讨论过的position和anchorPoint之外,CALayer还有另外两个属性zPosition和anchorPointZ,两者都是在Z轴上描述图层位置的浮点类型。
注意在这里并没有更深的属性来描述由宽和高做成的bounds了,图层是一个完全扁平的对象,你可以把它想象成类似是一页二维的坚硬的纸片,用胶水粘成一个空洞,就像三维结构的折纸一样。
zPosition在大多数情况下其实并不常用。接下去的章节我们将会涉及到CATransform3D,你会知道如何在三维空间移动和旋转图层,除了做变换之外,zPosition最实用的功能就是改变图层的显示顺序。
通常,图层是根据他们子图层的subLayers出现的顺序来绘制的,这就是所谓的画家的算法——就像一个画家在墙上作画后被绘制上图层将会遮盖住之前的图层**,但是通过增加图层的zPosition,就可以把图层向相机方向前置,于是它就在所有其他图层的前面了(或者至少是小于他的zPosition值的图层的前面)。
这里所谓的“相机”实际上是相对于用户的视角,这里和iPhone背后的内置相机没任何关系。
如下图所示首先出现在视图层级绿色的视图被绘制的红色视图后面。
绿色的视图被绘制的红色视图后面我们希望在真实的应用中也能显示出绘图的顺序,同样的,如果我们提高的绿色视图的zPosition,我们会发现顺序就会反了。其实并不需要增加太多,视图都非常的薄,所以给zPosition提高一个像素就可以使绿色视图前置。当然0.1或者0.0001也能够做到,但是最好不要这样,因为浮点类型四舍五入的计算可能会造成一些不便的麻烦。
代码如下:
- (void)viewDidLoad {
[super viewDidLoad];
// Do any additional setup after loading the view.
self.view.backgroundColor = [UIColor whiteColor];
CALayer *greenLayer = [CALayer layer];
greenLayer.frame = CGRectMake(100, 100, 200, 200);
greenLayer.backgroundColor = [UIColor greenColor].CGColor;
greenLayer.zPosition = 1;
[self.view.layer addSublayer:greenLayer];
CALayer *redLayer = [CALayer layer];
redLayer.frame = CGRectMake(200, 200, 200, 200);
redLayer.backgroundColor = [UIColor redColor].CGColor;
[self.view.layer addSublayer:redLayer];
}
效果如下:
绿色覆盖红色.pngHit Testing
第一章的时候“图层树”证实了最好使用图层相关视图,而不是创建独立的图层关系,其中一个原因就是要处理额外复杂的触摸事件。
CALayer并不关心任何响应链事件,所以不能直接处理触摸事件或者手势,但是他有一系列的方法帮你处理事件:-hitText;和-containsPoint;
-containsPoint:接收一个在本图层坐标系下的CGPoint,如果这个点在图层的frame范围之内就返回yes;在第一章的例子的另一个版本,使用-containsPoint方法来判断到底是白色图层还是蓝色图层被触摸了。这需要把触摸坐标转换成每个图层坐标系下的坐标,结果很不方便;
代码如下:
- (void)viewDidLoad {
[super viewDidLoad];
// Do any additional setup after loading the view.
self.view.backgroundColor = [UIColor grayColor];
_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;
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, 1.3, 1.3);
*/
//添加一个图层
_layer = [CALayer layer];
_layer.frame = CGRectMake(50, 50, 100, 100);
_layer.backgroundColor = [UIColor blueColor].CGColor;
[_layerView.layer addSublayer:_layer];
}
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event
{
CGPoint point = [[touches anyObject] locationInView:self.view];
point = [self.layerView.layer convertPoint:point fromLayer:self.view.layer];
if ([self.layerView.layer containsPoint:point]) {
NSLog(@"白色区域");
CGPoint point2 = [self.layer convertPoint:point fromLayer:self.layerView.layer];
if ([self.layer containsPoint:point2]) {
NSLog(@"蓝色区域");
}
}else{
NSLog(@"空白区域");
}
}
-hitText:方法同样接收一个CGPoint类型参数,而不是bool类型,他返回图层本身,或者包含这个坐标点的叶子节点图层。这意味着不再需要像使用-containsPoint那样,人工的在每个子图层变换或者测试点击的坐标。如果这个点在最外面图层的外围之外,则返回nil,
代码如下:
//hitTest
CGPoint point = [[touches anyObject] locationInView:self.view];
CALayer *layerHit = [self.view.layer hitTest:point];
if (layerHit == self.view.layer) {
NSLog(@"空白区域");
}else if (layerHit == self.layer){
NSLog(@"白色区域 + 蓝色区域");
}else if (layerHit == self.layerView.layer){
NSLog(@"白色区域");
}
注意当调用图层的-hitTest方法时,测算的顺序严格依赖于图层树中的图层顺序(和UIView处理事件类似),之前提到的zPosition属性可以明显改变屏幕上图层的顺序,但不能改变事件传递的顺序。
这意味着如果改变了图层的z轴顺序,你会发现将不能检测到最前方的的视图点击事件,这是因为被另一个图层遮盖住了。虽然它 的zPosition较小,但是在图层树中的顺序靠前,我们将在后面章节详细介绍这个问题。
自动布局
你可能用过UIViewAutoresizingMask类型的一些常量,应用于当父视图改变尺寸的时候,相应UIView的frame也跟着更新的场景(通常用于横竖屏的切换)。
在IOS6中,苹果介绍了自动排版机制,他和自动调整不同,并且更加复杂。
在MacOS平台上,CALayer有一个叫做layoutManager的属性可以通过CALayoutManager协议和CAConstraintLayoutManager类来实现自动排版机制,但由于某些原因,这在IOS上并不适用。
当使用视图的时候,可以充分利用UIView类接口暴露出的UIViewAutoresizingMask和CAConstraintLayoutManager API,但如果想随意控制CALayer的布局,就需要手工操作,最简单的方法就是用CALayerDelegate 如下函数:
-(void)layoutSublayersOfLayer:(CALayer) layer;
当图层的bounds发生改变或者 图层的-setNeedsLayout被调用时,这个函数就是被执行,这使得你可以手动的重新调整子图层的大小,但是不能像UIView的autoresizingMask和contraints属性做到自适应屏幕旋转。
这也是为什么最好使用视图而不是单独的图层来构建应用程序的另一个重要原因之一。
总结
本章涉及了CALayer的集合结构,包括他的frame、bounds和position,介绍了三维空间内图层的概念。以及如何在独立的图层内响应事件,最后简单的说明了在ios平台,Core Animation对自动调整和自动布局支持的缺乏。