图层几何学

不熟悉几何学的人就不要来这里了 —柏拉图学院入口的签名

在第二章里面,我们介绍了图层背后的图片,和一些控制图层坐标和旋转的属性。在这一章中,我们将要看一看图层内部是如何根据父图层和兄弟图层来控制位置和尺寸的。另外我们也会涉及如何管理图层的几何结构,以及它是如何被自动调整和自动布局影响的。

布局

UIView有三个比较重要的布局属性:frameboundscenterCALayer对应地叫做frameboundsposition。为了能清楚区分,图层用了“position”,视图用了“center”,但是他们都代表同样的值。

frame代表了图层的外部坐标(也就是在父图层上占据的空间),bounds是内部坐标({0, 0}通常是图层的左上角),centerposition都代表了相对于父图层anchorPoint所在的位置。anchorPoint的属性将会在后续介绍到,现在把它想成图层的中心点就好了。图3.1显示了这些属性是如何相互依赖的。

图3.1

图3.1 UIViewCALayer的坐标系

视图的frameboundscenter属性仅仅是存取方法,当操纵视图的frame,实际上是在改变位于视图下方CALayerframe,不能够独立于图层之外改变视图的frame

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

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

图3.2

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

锚点

之前提到过,视图的center属性和图层的position属性都指定了anchorPoint相对于父图层的位置。图层的anchorPoint通过position来控制它的frame的位置,你可以认为anchorPoint是用来移动图层的把柄

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

图3.3

图3.3 改变anchorPoint的效果

和第二章提到的contentsRectcontentsCenter属性类似,anchorPoint单位坐标来描述,也就是图层的相对坐标,图层左上角是{0, 0},右下角是{1, 1},因此默认坐标是{0.5, 0.5}。anchorPoint可以通过指定x和y值小于0或者大于1,使它放置在图层范围之外。

注意在图3.3中,当改变了anchorPointposition属性保持固定的值并没有发生改变,但是frame却移动了。

那在什么场合需要改变anchorPoint呢?既然我们可以随意改变图层位置,那改变anchorPoint不会造成困惑么?为了举例说明,我们来举一个实用的例子,创建一个模拟闹钟的项目。

钟面和钟表由四张图片组成(图3.4),为了简单说明,我们还是用传统的方式来装载和加载图片,使用四个UIImageView实例(当然你也可以用正常的视图,设置他们图层的contents图片)。

图3.4

图3.4 组成钟面和钟表的四张图片

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

我们用NSTimer来更新闹钟,使用视图的transform属性来旋转钟表(如果你对这个属性不太熟悉,不要着急,我们将会在第5章“变换”当中详细说明),具体代码见清单3.1

图3.5

图3.5 在Interface Builder中布局闹钟视图

清单3.1 Clock

  1. @interface ViewController ()
  2. @property (nonatomic, weak) IBOutlet UIImageView *hourHand;
  3. @property (nonatomic, weak) IBOutlet UIImageView *minuteHand;
  4. @property (nonatomic, weak) IBOutlet UIImageView *secondHand;
  5. @property (nonatomic, weak) NSTimer *timer;
  6. @end
  7. @implementation ViewController
  8. - (void)viewDidLoad
  9. {
  10. [super viewDidLoad];
  11. //start timer
  12. self.timer = [NSTimer scheduledTimerWithTimeInterval:1.0 target:self selector:@selector(tick) userInfo:nil repeats:YES];
  13. //set initial hand positions
  14. [self tick];
  15. }
  16. - (void)tick
  17. {
  18. //convert time to hours, minutes and seconds
  19. NSCalendar *calendar = [[NSCalendar alloc] initWithCalendarIdentifier:NSGregorianCalendar];
  20. NSUInteger units = NSHourCalendarUnit | NSMinuteCalendarUnit | NSSecondCalendarUnit;
  21. NSDateComponents *components = [calendar components:units fromDate:[NSDate date]];
  22. CGFloat hoursAngle = (components.hour / 12.0) * M_PI * 2.0;
  23. //calculate hour hand angle //calculate minute hand angle
  24. CGFloat minsAngle = (components.minute / 60.0) * M_PI * 2.0;
  25. //calculate second hand angle
  26. CGFloat secsAngle = (components.second / 60.0) * M_PI * 2.0;
  27. //rotate hands
  28. self.hourHand.transform = CGAffineTransformMakeRotation(hoursAngle);
  29. self.minuteHand.transform = CGAffineTransformMakeRotation(minsAngle);
  30. self.secondHand.transform = CGAffineTransformMakeRotation(secsAngle);
  31. }
  32. @end

运行项目,看起来有点奇怪(图3.6),因为钟表的图片在围绕着中心旋转,这并不是我们期待的一个支点。

图3.6

图3.6 钟面,和不对齐的钟指针

你也许会认为可以在Interface Builder当中调整指针图片的位置来解决,但其实并不能达到目的,因为如果不放在钟面中间的话,同样不能正确的旋转。

也许在图片末尾添加一个透明空间也是个解决方案,但这样会让图片变大,也会消耗更多的内存,这样并不优雅。

更好的方案是使用anchorPoint属性,我们来在-viewDidLoad方法中添加几行代码来给每个钟指针的anchorPoint做一些平移(清单3.2),图3.7显示了正确的结果。

清单3.2

  1. - (void)viewDidLoad
  2. {
  3. [super viewDidLoad];
  4. // adjust anchor points
  5. self.secondHand.layer.anchorPoint = CGPointMake(0.5f, 0.9f);
  6. self.minuteHand.layer.anchorPoint = CGPointMake(0.5f, 0.9f);
  7. self.hourHand.layer.anchorPoint = CGPointMake(0.5f, 0.9f);
  8. // start timer
  9. }

图3.7

图3.7 钟面,和正确对齐的钟指针

坐标系

和视图一样,图层在图层树当中也是相对于父图层按层级关系放置,一个图层的position依赖于它父图层的bounds,如果父图层发生了移动,它的所有子图层也会跟着移动。

这样对于放置图层会更加方便,因为你可以通过移动根图层来将它的子图层作为一个整体来移动,但是有时候你需要知道一个图层的绝对位置,或者是相对于另一个图层的位置,而不是它当前父图层的位置。

CALayer给不同坐标系之间的图层转换提供了一些工具类方法:

  1. - (CGPoint)convertPoint:(CGPoint)point fromLayer:(CALayer *)layer;
  2. - (CGPoint)convertPoint:(CGPoint)point toLayer:(CALayer *)layer;
  3. - (CGRect)convertRect:(CGRect)rect fromLayer:(CALayer *)layer;
  4. - (CGRect)convertRect:(CGRect)rect toLayer:(CALayer *)layer;

这些方法可以把定义在一个图层坐标系下的点或者矩形转换成另一个图层坐标系下的点或者矩形

翻转的几何结构

常规说来,在iOS上,一个图层的position位于父图层的左上角,但是在Mac OS上,通常是位于左下角。Core Animation可以通过geometryFlipped属性来适配这两种情况,它决定了一个图层的坐标是否相对于父图层垂直翻转,是一个BOOL类型。在iOS上通过设置它为YES意味着它的子图层将会被垂直翻转,也就是将会沿着底部排版而不是通常的顶部(它的所有子图层也同理,除非把它们的geometryFlipped属性也设为YES)。

Z坐标轴

UIView严格的二维坐标系不同,CALayer存在于一个三维空间当中。除了我们已经讨论过的positionanchorPoint属性之外,CALayer还有另外两个属性,zPositionanchorPointZ,二者都是在Z轴上描述图层位置的浮点类型。

注意这里并没有更的属性来描述由宽和高做成的bounds了,图层是一个完全扁平的对象,你可以把它们想象成类似于一页二维的坚硬的纸片,用胶水粘成一个空洞,就像三维结构的折纸一样。

zPosition属性在大多数情况下其实并不常用。在第五章,我们将会涉及CATransform3D,你会知道如何在三维空间移动和旋转图层,除了做变换之外,zPosition最实用的功能就是改变图层的显示顺序了。

通常,图层是根据它们子图层的sublayers出现的顺序来类绘制的,这就是所谓的画家的算法—就像一个画家在墙上作画—后被绘制上的图层将会遮盖住之前的图层,但是通过增加图层的zPosition,就可以把图层向相机方向前置,于是它就在所有其他图层的前面了(或者至少是小于它的zPosition值的图层的前面)。

这里所谓的“相机”实际上是相对于用户是视角,这里和iPhone背后的内置相机没任何关系。

图3.8显示了在Interface Builder内的一对视图,正如你所见,首先出现在视图层级绿色的视图被绘制在红色视图的后面。

图3.8

图3.8 在视图层级中绿色视图被绘制在红色视图的后面

我们希望在真实的应用中也能显示出绘图的顺序,同样地,如果我们提高绿色视图的zPosition(清单3.3),我们会发现顺序就反了(图3.9)。其实并不需要增加太多,视图都非常地薄,所以给zPosition提高一个像素就可以让绿色视图前置,当然0.1或者0.0001也能够做到,但是最好不要这样,因为浮点类型四舍五入的计算可能会造成一些不便的麻烦。

清单3.3

  1. @interface ViewController ()
  2. @property (nonatomic, weak) IBOutlet UIView *greenView;
  3. @property (nonatomic, weak) IBOutlet UIView *redView;
  4. @end
  5. @implementation ViewController
  6. - (void)viewDidLoad
  7. {
  8. [super viewDidLoad];
  9. //move the green view zPosition nearer to the camera
  10. self.greenView.layer.zPosition = 1.0f;
  11. }
  12. @end

图3.9

图3.9 绿色视图被绘制在红色视图的前面

Hit Testing

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

CALayer对响应链一无所知,所以它不能直接处理触摸事件或者手势。但是它有一系列的方法帮你处理事件:-containsPoint:-hitTest:

-containsPoint:接受一个在本图层坐标系下的CGPoint,如果这个点在图层frame范围内就返回YES。如清单3.4所示第一章的项目的另一个合适的版本,也就是使用-containsPoint:方法来判断到底是白色还是蓝色的图层被触摸了
(图3.10)。这需要把触摸坐标转换成每个图层坐标系下的坐标,结果很不方便。

清单3.4 使用containsPoint判断被点击的图层

  1. @interface ViewController ()
  2. @property (nonatomic, weak) IBOutlet UIView *layerView;
  3. @property (nonatomic, weak) CALayer *blueLayer;
  4. @end
  5. @implementation ViewController
  6. - (void)viewDidLoad
  7. {
  8. [super viewDidLoad];
  9. //create sublayer
  10. self.blueLayer = [CALayer layer];
  11. self.blueLayer.frame = CGRectMake(50.0f, 50.0f, 100.0f, 100.0f);
  12. self.blueLayer.backgroundColor = [UIColor blueColor].CGColor;
  13. //add it to our view
  14. [self.layerView.layer addSublayer:self.blueLayer];
  15. }
  16. - (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event
  17. {
  18. //get touch position relative to main view
  19. CGPoint point = [[touches anyObject] locationInView:self.view];
  20. //convert point to the white layer's coordinates
  21. point = [self.layerView.layer convertPoint:point fromLayer:self.view.layer];
  22. //get layer using containsPoint:
  23. if ([self.layerView.layer containsPoint:point]) {
  24. //convert point to blueLayer’s coordinates
  25. point = [self.blueLayer convertPoint:point fromLayer:self.layerView.layer];
  26. if ([self.blueLayer containsPoint:point]) {
  27. [[[UIAlertView alloc] initWithTitle:@"Inside Blue Layer"
  28. message:nil
  29. delegate:nil
  30. cancelButtonTitle:@"OK"
  31. otherButtonTitles:nil] show];
  32. } else {
  33. [[[UIAlertView alloc] initWithTitle:@"Inside White Layer"
  34. message:nil
  35. delegate:nil
  36. cancelButtonTitle:@"OK"
  37. otherButtonTitles:nil] show];
  38. }
  39. }
  40. }
  41. @end

图3.10

图3.10 点击图层被正确标识

-hitTest:方法同样接受一个CGPoint类型参数,而不是BOOL类型,它返回图层本身,或者包含这个坐标点的叶子节点图层。这意味着不再需要像使用-containsPoint:那样,人工地在每个子图层变换或者测试点击的坐标。如果这个点在最外面图层的范围之外,则返回nil。具体使用-hitTest:方法被点击图层的代码如清单3.5所示。

清单3.5 使用hitTest判断被点击的图层

  1. - (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event
  2. {
  3. //get touch position
  4. CGPoint point = [[touches anyObject] locationInView:self.view];
  5. //get touched layer
  6. CALayer *layer = [self.layerView.layer hitTest:point];
  7. //get layer using hitTest
  8. if (layer == self.blueLayer) {
  9. [[[UIAlertView alloc] initWithTitle:@"Inside Blue Layer"
  10. message:nil
  11. delegate:nil
  12. cancelButtonTitle:@"OK"
  13. otherButtonTitles:nil] show];
  14. } else if (layer == self.layerView.layer) {
  15. [[[UIAlertView alloc] initWithTitle:@"Inside White Layer"
  16. message:nil
  17. delegate:nil
  18. cancelButtonTitle:@"OK"
  19. otherButtonTitles:nil] show];
  20. }
  21. }

注意当调用图层的-hitTest:方法时,测算的顺序严格依赖于图层树当中的图层顺序(和UIView处理事件类似)。之前提到的zPosition属性可以明显改变屏幕上图层的顺序,但不能改变触摸事件被处理的顺序。

这意味着如果改变了图层的z轴顺序,你会发现将不能够检测到最前方的视图点击事件,这是因为被另一个图层遮盖住了,虽然它的zPosition值较小,但是在图层树中的顺序靠前。我们将在第五章详细讨论这个问题。

自动布局

你可能用过UIViewAutoresizingMask类型的一些常量,应用于当父视图改变尺寸的时候,相应UIViewframe也跟着更新的场景(通常用于横竖屏切换)。

在iOS6中,苹果介绍了自动布局机制,它和自动调整不同,并且更加复杂,它通过指定组合形成线性方程组和不等式的约束,定义视图的位置和大小。

在Mac OS平台,CALayer有一个叫做layoutManager的属性可以通过CALayoutManager协议和CAConstraintLayoutManager类来实现自动排版的机制。但由于某些原因,这在iOS上并不适用。

当使用视图的时候,可以充分利用UIView类接口暴露出来的UIViewAutoresizingMaskNSLayoutConstraintAPI,但如果想随意控制CALayer的布局,就需要手工操作。最简单的方法就是使用CALayerDelegate如下函数:

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

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

这也是为什么最好使用视图而不是单独的图层来构建应用程序的另一个重要原因之一。

总结

本章涉及了CALayer的几何结构,包括它的framepositionbounds,介绍了三维空间内图层的概念,以及如何在独立的图层内响应事件,最后简单说明了在iOS平台中,Core Animation对自动调整和自动布局支持的缺乏。

在第四章“视觉效果”当中,我们接着介绍一些图层外表的特性。