目录
- 图层几何学
- 布局
- 锚点
- 坐标系
- Hit Testing
- 自动布局
一 图层几何学
在这一章中,我们将要看一看图层内部是如何根据父图层和兄弟图层来控制位置和尺寸的。另外我们也会涉及如何管理图层的几何结构,以及它是如何被自动调整和自动布局影响的。
1.1 布局
UIView有三个比较重要的布局属性:frame
,bounds
和center
,CALayer对应地叫做frame
,bounds
和position
。为了能清楚区分,图层用了position
,视图用了center
,但是他们都代表同样的值。
frame
代表了图层的外部坐标(也就是在父图层上占据的空间),bounds
是内部坐标({0, 0}通常是图层的左上角),center
和position
都代表了相对于父图层anchorPoint
所在的位置。anchorPoint的属性将会在后续介绍到,现在把它想成图层的中心点就好了。下图显示了这些属性是如何相互依赖的。
视图的frame
,bounds
和center
属性仅仅是存取方法
,当操纵视图的frame,实际上是在改变位于视图下方CALayer
的frame
,不能够独立于图层之外改变视图的frame。
对于视图或者图层来说,frame
并不是一个非常清晰的属性,它其实是一个虚拟属性
,是根据bounds,position和transform计算而来,所以当其中任何一个值发生改变,frame都会变化。相反,改变frame的值同样会影响到他们当中的值。
记住当对图层做变换的时候,比如旋转
或者缩放
,frame实际上代表了覆盖在图层旋转之后的整个轴对齐的矩形区域,也就是说frame的宽高可能和bounds的宽高不再一致了
1.2 锚点
视图的center
属性和图层的position
属性都指定了anchorPoint
相对于父图层的位置。图层的anchorPoint
通过position
来控制它的frame
的位置,你可以认为anchorPoint是用来移动图层的把柄
。
默认来说,anchorPoint
位于图层的中点,所以图层的将会以这个点为中心放置。anchorPoint
属性并没有被UIView接口暴露出来,这也是视图的position
属性被叫做center
的原因。但是图层的anchorPoint
可以被移动,比如你可以把它置于图层frame的左上角,于是图层的内容将会向右下角的position方向移动(图3.3),而不是居中了。
anchorPoint
用单位坐标来描述,也就是图层的相对坐标
,图层左上角是{0, 0},右下角是{1, 1},因此默认坐标是{0.5, 0.5}
。anchorPoint可以通过指定x和y值小于0或者大于1,使它放置在图层范围之外。
上图中当改变了anchorPoint
,position
属性保持固定的值并没有发生改变,但是frame
却移动了。
那在什么场合需要改变anchorPoint
呢?既然我们可以随意改变图层位置,那改变anchorPoint
不会造成困惑么?为了举例说明,我们来举一个实用的例子,创建一个模拟闹钟的项目。
- 代码实现
@interface ViewController ()
/** hour */
@property(nonatomic, strong)UIImageView *hourImgView;
/** minute */
@property(nonatomic, strong)UIImageView *minuteImgView;
/** second */
@property(nonatomic, strong)UIImageView *secondImgView;
/** timer */
@property(nonatomic, strong)NSTimer *timer;
@end
@implementation ViewController
- (void)viewDidLoad {
[super viewDidLoad];
// Do any additional setup after loading the view.
[self drawUI];
[self setupTimer];
}
- (void)drawUI {
UIImageView *clockImgView = [[UIImageView alloc] initWithFrame:CGRectMake(0, 0, 300, 300)];
clockImgView.image = [UIImage imageNamed:@"clock"];
clockImgView.contentMode = UIViewContentModeScaleAspectFit;
clockImgView.center = self.view.center;
[self.view addSubview:clockImgView];
UIImageView *secondImgView = [[UIImageView alloc] initWithFrame:CGRectMake(0, 0, 5, 150)];
secondImgView.image = [UIImage imageNamed:@"second"];
secondImgView.contentMode = UIViewContentModeScaleAspectFit;
secondImgView.center = self.view.center;
[self.view addSubview:self.secondImgView = secondImgView];
UIImageView *minuteImgView = [[UIImageView alloc] initWithFrame:CGRectMake(0, 0, 5, 120)];
minuteImgView.image = [UIImage imageNamed:@"minute"];
minuteImgView.contentMode = UIViewContentModeScaleAspectFill;
minuteImgView.center = self.view.center;
[self.view addSubview:self.minuteImgView = minuteImgView];
UIImageView *hourkImgView = [[UIImageView alloc] initWithFrame:CGRectMake(0, 0, 5, 100)];
hourkImgView.image = [UIImage imageNamed:@"hour"];
hourkImgView.contentMode = UIViewContentModeScaleAspectFill;
hourkImgView.center = self.view.center;
[self.view addSubview:self.hourImgView = hourkImgView];
}
#pragma mark - timer
- (void)setupTimer {
self.timer = [NSTimer timerWithTimeInterval:1.0 repeats:YES block:^(NSTimer * _Nonnull timer) {
[self updateTimer];
}];
[[NSRunLoop currentRunLoop] addTimer:self.timer forMode:NSRunLoopCommonModes];
[self tick];
}
- (void)updateTimer {
[self tick];
}
- (void)stopTimer {
}
#pragma mark - tick
- (void)tick {
// convert time to houres 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;
CGFloat minsAngle = (components.minute / 60.0) * M_PI * 2.0;
CGFloat secsAngle = (components.second / 60.0) * M_PI * 2.0;
// rotate hands
self.hourImgView.transform = CGAffineTransformMakeRotation(hoursAngle);
self.minuteImgView.transform = CGAffineTransformMakeRotation(minsAngle);
self.secondImgView.transform = CGAffineTransformMakeRotation(secsAngle);
}
- 运行效果如下
运行项目,看起来有点奇怪(图3.6),因为钟表的图片在围绕着中心旋转,这并不是我们期待的一个支点。
更好的方案是使用anchorPoint属性,我们给每个钟指针的anchorPoint做一些平移。
self.hourImgView.layer.anchorPoint = CGPointMake(0.5, 0.9);
self.minuteImgView.layer.anchorPoint = CGPointMake(0.5, 0.9);
self.secondImgView.layer.anchorPoint = CGPointMake(0.5, 0.9);
- 运行效果如下
1.3 坐标系
和视图一样,图层在图层树当中也是相对于父图层按层级关系放置,一个图层的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背后的内置相机没任何关系。
- 实例代码如下
- (void)zPosition {
UIView *greenView = [[UIView alloc] initWithFrame:CGRectMake(100, 200, 100, 100)];
greenView.backgroundColor = [UIColor greenColor];
[self.view addSubview:greenView];
UIView *redView = [[UIView alloc] initWithFrame:CGRectMake(150, 250, 100, 100)];
redView.backgroundColor = [UIColor redColor];
[self.view addSubview:redView];
}
- 运行效果如下
首先出现在视图层级绿色的视图被绘制在红色视图的后面。
我们希望在真实的应用中也能显示出绘图的顺序,同样地,如果我们提高绿色视图的zPosition
,我们会发现顺序就反了。其实并不需要增加太多,视图都非常地薄,所以给zPosition提高一个像素就可以让绿色视图前置,当然0.1或者0.0001也能够做到,但是最好不要这样,因为浮点类型四舍五入的计算可能会造成一些不便的麻烦。
- 代码如下
greenView.layer.zPosition = 1.0;
- 运行效果如下
1.4 Hit Testing
前面说了图层树
证实了最好使用图层相关视图,而不是创建独立的图层关系。其中一个原因就是要处理额外复杂的触摸事件
。
CALayer并不关心任何响应链事件,所以不能直接处理触摸事件或者手势。但是它有一系列的方法帮你处理事件:
-containsPoint:
-hitTest:
containsPoint:
-containsPoint:
接受一个在本图层坐标系下的CGPoint
,如果这个点在图层frame范围内就返回YES
。
- 实例代码如下
- (void)drawBlueView {
self.layerView = [[UIView alloc] initWithFrame:CGRectMake(0, 0, 200, 200)];
self.layerView.backgroundColor = [UIColor grayColor];
self.layerView.center = self.view.center;
[self.view addSubview:self.layerView];
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<UITouch *> *)touches withEvent:(UIEvent *)event {
// get touch position relative to main view
CGPoint point = [[touches anyObject] locationInView:self.view];
// convert point to the white layers coordinates
point = [self.view.layer convertPoint:point toLayer:self.layerView.layer];
// get layer using containsPoint
if ([self.layerView.layer containsPoint:point]) {
// convert point to blueLayer's coordinates
point = [self.layerView.layer convertPoint:point toLayer:self.blueLayer];
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 gray Layer"
message:nil
delegate:nil
cancelButtonTitle:@"OK"
otherButtonTitles:nil] show];
}
}
}
- 运行效果如下
-hitTest:
-hitTest:
方法同样接受一个CGPoint
类型参数,而不是BOOL类型,它返回图层本身,或者包含这个坐标点的叶子节点图层。这意味着不再需要像使用-containsPoint:
那样,人工地在每个子图层变换或者测试点击的坐标。如果这个点在最外面图层的范围之外,则返回nil。具体使用-hitTest:方法被点击图层的代码如下所示
/// hitTest:
- (void)touchesBegan:(NSSet<UITouch *> *)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 gray Layer"
message:nil
delegate:nil
cancelButtonTitle:@"OK"
otherButtonTitles:nil] show];
}
}
- 运行结果如下
注意当调用图层的-hitTest:
方法时,测算的顺序严格依赖于图层树当中的图层顺序
(和UIView处理事件
类似)。之前提到的zPosition
属性可以明显改变屏幕上图层的顺序,但不能改变事件传递的顺序
。
这意味着如果改变了图层的z轴顺序,你会发现将不能够检测到最前方的视图点击事件,这是因为被另一个图层遮盖住了,虽然它的zPosition值较小,但是在图层树中的顺序靠前。我们将在后面详细讨论这个问题。
1.5 自动布局
你可能用过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
属性做到自适应屏幕旋转。
这也是为什么最好使用视图而不是单独的图层来构建应用程序的另一个重要原因之一。
本文摘自 iOS核心动画高级技巧-图层几何学