IOS开发通过代码方式使用AutoLayout (NSLayoutConstraint + Masonry) 转载
http://blog.csdn.net/he_jiabin/article/details/48677911
随着iPhone6/6+设备的上市,如何让手头上的APP适配多种机型多种屏幕尺寸变得尤为迫切和必要。(包括:iPhone4/4s,iPhone5/5s,iPhone6/6s,iPhone 6p/6ps)。
在iPhone6出现以前,我们接触的iPhone屏幕只有两种尺寸:320 x 480和320 x 568。所以在那个时候使用传统的绝对定位(Frame)方式进行界面控件的布局还是比较轻松的,因为我们只需要稍微调整一下Frame就可以适配这两种大小的屏幕了。也许这也是为什么虽然AutoLayout从IOS6就已经出现了,但是对于AutoLayout的使用和普及好像都不怎么火热。不过直到最近随着iPhone6/6+设备的出现,AutoLayout又被众多开发者重新审视和重视了。毕竟APPLE推出AutoLayout就是为了帮助开发者的APP更方便简单的适配将来不同苹果设备的不同大小屏幕。
首先我们来看一下APPLE官方是如何描述Auto Layout的:Auto Layout 是一个系统,可以让你通过创建元素之间关系的数学描述来布局应用程序的用户界面,是一种基于约束的,描述性的布局系统。所以我们现在要开始摒弃使用传统的设置 frame 的布局方式的思维来开发视图界面了。因为在 Auto Layout 中,当你描述完视图对象之间的约束之后, Auto Layout 会自动帮你计算出视图对象的位置和大小,也就间接的设定了视图的Frame。反过来,如果我们还使用传统的绝对定位的方式,通过设定视图的Frame来布局的话,那么随着苹果设备屏幕尺寸的碎片化,那么每一种屏幕尺寸都要给界面控件设定一套合适该尺寸的Frame,这种方式想想就够吓人的!另外还需要说明的是,如今确实还有不少人仍然使用设定Frame的方式进行布局,并且通过取设备屏幕的宽高进行一定比例的换算确实可以达到正确的定位布局,但是在大多数情况下,如果页面支持屏幕旋转的话,这种设定Frame的方式就完全失效了,旋转屏幕需要做大量的额外处理。还有一点是我们必须注意的,很多人习惯上是在viewdidload方法中初始化控件(包括init 和设置frame),但是viewController要在viewWillLayoutSubviews的时候才能真正确定view和子view的frame。所以在没有确定view的frame的时候就去操作修改view的frame是不友好的,设置frame的效果也是无法预料的。所以按照这个特性,如果我们用设置Frame的方式布局的话就必须在viewWillLayoutSubviews中重新设定view的坐标大小,布局逻辑会变得更不清晰。而使用AutoLayout则不会有这些问题。
那么接下来我们来讲一下如何使用AutoLayout。
大家都应该清楚,我们可以在XIB、StoryBoard中通过拉线的形式给控件视图添加布局约束,通过苹果强大的可视化界(Interface Builder)我们能够轻松的使用AutoLayout完成界面视图的布局。另外一种方式就是通过纯代码的形式使用AutoLayout,即NSLayoutConstraint。本人是个代码控,个人比较倾向于代码写界面,所以本文主要讲一下最近本人通过纯代码的方式使用AutoLayout和使用第三方界面布局库Masonry进行代码布局的总结和分享。
首先谈一下在如今AutoLayout的时代,是使用XIB、StoryBoard好些还是使用纯代码布局好!?本人根据自己的经验觉得,这个没有一个绝对的界限或者什么一刀切。但是在权衡这个问题的时候,我个人觉得有几个原则应该要去遵守的:
1、在一些比较简单、固定的界面。比如登录、注册或者其他只是进行内容展示的界面使用XIB、StoryBoard开发起来会更简单快一些,这个时候我们也应该使用XIB、StoryBoard开发。
2、在一些复杂、控件较多和功能多的界面尽量使用代码进行布局开发。因为控件多功能复杂的界面如果使用XIB、StoryBoard。那么通过拉线的形式添加约束布局,大家应该都有经历过,一个XIB里拉满了密密麻麻的约束线,可以肯定的是过不了多久连自己都看晕了。如果这个模块要交给第二个人维护,那么这些密密麻麻的约束线肯定是一个让人头疼的问题。因为XIB中约束过多的话,首先可读性是非常差的,带来的后续问题是开发思路不清晰、维护难。
3、需要复用的模块尽量使用代码布局。如果使用XIB、StoryBoard则无法很好的对代码模块进行复用。
NSLayoutConstraint 篇
进入正题,我们首先来谈一下如何使用官方提供的API(NSLayoutConstraint)进行代码布局。
谈到NSLayoutConstraint,大家都有一个不怎么好的感觉。哎,可以肯定的是APPLE一直在推AutoLayout。只是貌似在可视化的布局设计(XIB、StoryBoard)下的力度和功夫远比代码布局要大。因为通过APPLE提供的API进行代码布局确实不怎么好用,但是还是在可以接受的范围,呵呵!
一、Autoresizing Mask
在使用AutoLayout之前我们先介绍Autoresizing Mask。
必须要注意的是在使用 Auto Layout 时,首先需要将视图的 setTranslatesAutoresizingMaskIntoConstraints 属性设置为 NO。这个属性默认为 YES。当它为 YES 时,运行时系统会自动将 Autoresizing Mask 转换为 Auto Layout 的约束,这些约束很有可能会和我们自己添加的产生冲突。 我们常常会忘了做这一步,然后引起的约束报错就是这样的:
所以如果你是使用 Xib/StoryBoard 的话,系统会自动帮你把这个属性设置为 NO。通过Interface Builder,打开某个Xib或者StoryBoard,在右侧Show in file inspector里面就能看到Ues Autolayout选项,系统默认将其勾选。如下图:
即在XIB上开启Autolayout后,autoresizingMask就被废弃了。避免了约束冲突的情况。
如果你是通过代码布局的话,在给view添加约束之前,只需要通过代码把view的 setTranslatesAutoresizingMaskIntoConstraints 属性设置为 NO。
- [view setTranslatesAutoresizingMaskIntoConstraints:NO];
二、NSLayoutConstraint
Auto Layout 中约束对应的类为 NSLayoutConstraint,一个 NSLayoutConstraint 实例代表一条约束。
NSLayoutConstraint有两个方法,我们主要介绍 constraintWithItem:也是最常用的:
- +(instancetype)constraintWithItem:(id)view1 attribute:(NSLayoutAttribute)attr1 relatedBy:(NSLayoutRelation)relation toItem:(nullable id)view2 attribute:(NSLayoutAttribute)attr2 multiplier:(CGFloat)multiplier constant:(CGFloat)c;
这个API给我们的第一印象就是参数有点多。其实仔细一看表达的意思无非就是:view1的某个属性(attr1)等于view2的某个属性(attr2)的值的多少倍(multiplier)加上某个常量(constant)。描述的是一个view与另外一个view的位置和大小约束关系。其中属性attribute有上、下、左、右、宽、高等,关系relation有小于等于、等于、大于等于。需要注意的是,小于等于 或 大于等于 优先会使用 等于 关系,如果 等于 不能满足,才会使用 小于 或 大于。例如设置一个 大于等于100 的关系,默认会是 100,当视图被拉伸时,100 无法被满足,尺寸才会变得更大。
那么下面我们来看一下,如何运用NSLayoutConstraint进行代码布局。
场景一:
假如我们设计一个简单的页面。一个子view在父view中,其中子view的上下左右边缘都离父view的边缘40个像素。这个我们该如何写呢?如下:
- [self.view setBackgroundColor:[UIColor redColor]];
- //创建子view
- UIView *subView = [[UIView alloc] init];
- [subView setBackgroundColor:[UIColor blackColor]];
- //将子view添加到父视图上
- [self.view addSubview:subView];
- //使用Auto Layout约束,禁止将Autoresizing Mask转换为约束
- [subView setTranslatesAutoresizingMaskIntoConstraints:NO];
- //layout 子view
- //子view的上边缘离父view的上边缘40个像素
- NSLayoutConstraint *contraint1 = [NSLayoutConstraint constraintWithItem:subView attribute:NSLayoutAttributeTop relatedBy:NSLayoutRelationEqual toItem:self.view attribute:NSLayoutAttributeTop multiplier:1.0 constant:40.0];
- //子view的左边缘离父view的左边缘40个像素
- NSLayoutConstraint *contraint2 = [NSLayoutConstraint constraintWithItem:subView attribute:NSLayoutAttributeLeft relatedBy:NSLayoutRelationEqual toItem:self.view attribute:NSLayoutAttributeLeft multiplier:1.0 constant:40.0];
- //子view的下边缘离父view的下边缘40个像素
- NSLayoutConstraint *contraint3 = [NSLayoutConstraint constraintWithItem:subView attribute:NSLayoutAttributeBottom relatedBy:NSLayoutRelationEqual toItem:self.view attribute:NSLayoutAttributeBottom multiplier:1.0 constant:-40.0];
- //子view的右边缘离父view的右边缘40个像素
- NSLayoutConstraint *contraint4 = [NSLayoutConstraint constraintWithItem:subView attribute:NSLayoutAttributeRight relatedBy:NSLayoutRelationEqual toItem:self.view attribute:NSLayoutAttributeRight multiplier:1.0 constant:-40.0];
- //把约束添加到父视图上
- NSArray *array = [NSArray arrayWithObjects:contraint1, contraint2, contraint3, contraint4, nil nil];
- [self.view addConstraints:array];
效果如图:
这里讲一下比较容易犯错的地方:
1、添加约束前确定已经把需要布局的子view添加到父view上了
2、一定要禁止将Autoresizing Mask转换为约束
3、要把子view的约束加在父view上
4、因为iOS中原点在左上角所以使用offset时注意right和bottom用负数
场景二:
子view在父view的中间,且子view长300,高200。
- [self.view setBackgroundColor:[UIColor redColor]];
- //创建子view
- UIView *subView = [[UIView alloc] init];
- [subView setBackgroundColor:[UIColor blackColor]];
- [self.view addSubview:subView];
- //使用Auto Layout约束,禁止将Autoresizing Mask转换为约束
- [subView setTranslatesAutoresizingMaskIntoConstraints:NO];
- //layout 子view
- //子view的中心横坐标等于父view的中心横坐标
- NSLayoutConstraint *constrant1 = [NSLayoutConstraint constraintWithItem:subView attribute:NSLayoutAttributeCenterX relatedBy:NSLayoutRelationEqual toItem:self.view attribute:NSLayoutAttributeCenterX multiplier:1.0 constant:0.0];
- //子view的中心纵坐标等于父view的中心纵坐标
- NSLayoutConstraint *constrant2 = [NSLayoutConstraint constraintWithItem:subView attribute:NSLayoutAttributeCenterY relatedBy:NSLayoutRelationEqual toItem:self.view attribute:NSLayoutAttributeCenterY multiplier:1.0 constant:0.0];
- //子view的宽度为300
- NSLayoutConstraint *constrant3 = [NSLayoutConstraint constraintWithItem:subView attribute:NSLayoutAttributeWidth relatedBy:NSLayoutRelationGreaterThanOrEqual toItem:nil attribute:NSLayoutAttributeNotAnAttribute multiplier:1.0 constant:300.0];
- //子view的高度为200
- NSLayoutConstraint *constrant4 = [NSLayoutConstraint constraintWithItem:subView attribute:NSLayoutAttributeHeight relatedBy:NSLayoutRelationGreaterThanOrEqual toItem:nil attribute:NSLayoutAttributeNotAnAttribute multiplier:1.0 constant:200.0];
- //把约束添加到父视图上
- NSArray *array = [NSArray arrayWithObjects:constrant1, constrant2, constrant3, constrant4, nil nil];
- [self.view addConstraints:array];
效果如图:
这里需要注意的是:
1、如果是设置view自身的属性,不涉及到与其他view的位置约束关系。比如view自身的宽、高等约束时,方法constraintWithItem:的第四个参数view2(secondItem)应设为
nil;且第五个参数attr2(secondAttribute)应设为 NSLayoutAttributeNotAnAttribute 。
2、在设置宽和高这两个约束时,relatedBy参数使用的是 NSLayoutRelationGreaterThanOrEqual,而不是 NSLayoutRelationEqual。因为 Auto Layout 是相对布局,所以通常你不应该直接设置宽度和高度这种固定不变的值,除非你很确定视图的宽度或高度需要保持不变。
三、更新/修改约束
Auto Layout 的更新、修改约束操作,也不怎么友好和方便。
先来看一下 NSLayoutConstraint 的API,貌似只有constant属性可以修改,其它都是只读的。所以对于现有约束的修改和更新都是围绕NSLayoutConstraint实例的constant属性展开的。
1、如果你是使用 Xib/StoryBoard 的话,在程序运行时想动态的改变视图的约束,你可以这样做:
约束Constraint也可以像控件一样做IBOutlet链接,通过拖线关联到文件。(事例这里是改变子view相对于父view的top约束)
首先在你的ViewController的头文件里定义一个约束属性:
@property (nonatomic, weak) IBOutlet NSLayoutConstraint *topConstraint ;
- #import <UIKit/UIKit.h>
- @interface LayoutConstraintViewController : UIViewController
- @property (nonatomic, weak) IBOutlet NSLayoutConstraint *topConstraint;
- @end
然后在XIB里选中 File's Owner,选中Outlet栏目。将对应的Outlet拖动到View对应Constraint连接起来就可以了。跟button/label做链接一摸一样的。
这样我们就可以获得view上面的某个具体约束了,然后就可以在文件中对这个约束进行我们想要的修改。
- //更新约束
- self.topConstraint.constant = 10;
总结来说就是:拖线关联到文件获得约束,修改约束的constant属性。
2、如果你是使用 Xib/StoryBoard ,但是不想通过上述拉线的方式获得约束然后再去更新它,你还可以采用代码的方式修改约束:
但是无论采用哪种方式,我们都要遵循Auto Layout 的约束更新机制:想要更新视图上面的约束,就要先找到对应的约束再去更新它。遍历view上面的所有约束,查找到要更新的约束再进行更新。
所以我们要像上面一样要先获得top约束。在代码中的体现就是通过约束的标识字段,在其父view的constraints数组中遍历查找。这是因为每个view的constraints数组中保存的实际上是 layout 子view所需的约束的集合。
我们可以通过下面的辅助函数实现:
- NSArray *constrains = self.view.constraints;
- for (NSLayoutConstraint* constraint in constrains) {
- if (constraint.firstAttribute == NSLayoutAttributeTop) {
- constraint.constant = 10;
- }
- }
这里只判断了一个标识字段(NSLayoutAttributeTop),但是大多数情况下view上面的约束依赖不会那么简单,所以需要查找判断多个标识字段,才能找到。
3、如果你是使用代码布局,那就用上面2介绍的方法更新约束,或者在添加约束之前先将该约束记录下来,方便后面的更新修改。
到这里,我们已经知道了:要想修改/更新视图上已经添加的某个具体的约束,首先就是要获取该约束然后再去更新它。
4、除了上面说的几个方法获得约束之外,我教给大家一个我最近发现的很方便好用的方法。我们可以看一下 NSLayoutConstraint 这个类的具体API,我们可以看到它有一个属性:identifier
- /* For ease in debugging, name a constraint by setting its identifier, which will be printed in the constraint's description.
- Identifiers starting with UI and NS are reserved by the system.
- */
- @property (nullable, copy) NSString *identifier NS_AVAILABLE_IOS(7_0);
通过上面的注释我们可以知道,这个 identifier 属性就是这个约束的描述字段,而且是可以修改的。所以我们可以通过设置这个identifier值,来标识区分它们。于是,上面说的三个步骤中获取某个具体约束的过程将会变得很简单很简单。无需通过IB拖线关联到文件获得约束,或者通过遍历约束然后一一匹配依赖标识,甚至不需要设置全局变量去记录它们。我们只需要创建约束的时候,为约束设置一个唯一的标识名就可以了。
- //子view的宽度为300
- NSLayoutConstraint *constrantWidth = [NSLayoutConstraint constraintWithItem:subView attribute:NSLayoutAttributeWidth relatedBy:NSLayoutRelationGreaterThanOrEqual toItem:nil attribute:NSLayoutAttributeNotAnAttribute multiplier:1.0 constant:300.0];
- //设置宽约束的标识名
- constrantWidth.identifier = @"Bounds_Width";
修改的时候,只要遍历约束,然后匹配唯一的标识 identifier 就可以了。
- //遍历视图的约束,然后根据identifier进行查找、匹配
- NSArray *arr = [subView constraints];
- for (NSLayoutConstraint *constraint in arr) {
- if([@"Bounds_Width" isEqualToString:constraint.identifier]){
- constraint.constant = 20;
- }
- }
这里我自己也封装了一个简单的工具类:UIView+Constraint,它封装了对原生NSLayoutConstraint约束的修改API。主要的目的是简化原生约束修改的操作,方便使用。感兴趣的可以去看一下。github地址:https://github.com/jaybinhe/JBLayoutConstraintTool
四、Auto Layout 关于更新约束的几个方法
setNeedsLayout:告知页面需要更新,但是不会立刻开始更新。执行后会立刻调用layoutSubviews。
layoutIfNeeded:告知页面布局立刻更新。所以一般都会和setNeedsLayout一起使用。如果希望立刻生成新的frame需要调用此方法,利用这点一般布局动画可以在更新布局后直接使用这个方法让动画生效。
layoutSubviews:系统重写布局。
setNeedsUpdateConstraints:告知需要更新约束,但是不会立刻开始。
updateConstraintsIfNeeded:告知立刻更新约束。
updateConstraints:系统更新约束。
这么多方法中,目前我使用比较多的是 layoutIfNeeded 。因为在Auto Layout 实现动画的时候,layoutIfNeeded 方法可以立刻生成新的frame特性是一大利器。
五、使用 Auto Layout (NSLayoutConstraint)实现动画
目前貌似还有很多人对于 Auto Layout 的动画实现还不是很了解。毕竟以前我们处理动画之类的交互大都是和view的frame属性打交道,即使用传统的 animation方法修改view的frame。大致类似于
- CGRect newFrame = view.frame;
- newFrame.size.height = 300;
- [UIView animateWithDuration:3.0 animations:^{
- view.frame = newFrame;
- } completion:^(BOOL finished) {
- }];
那么在Auto Layout下我们又该如何处理相关的动画呢?根据前面说到的Auto Layout布局约束的原理,在某个时刻约束也是会被还原成frame使视图显示,这个时刻可以通过layoutIfNeeded这个方法来进行控制,可以立刻生成新的frame并展示出来,从而实现动画效果。
- //start animations
- //先根据初始化添加的约束生成最初的frame并显示view
- [self.view layoutIfNeeded];
- [UIView animateWithDuration:3.0 animations:^{
- //遍历查找view的heigh约束,并修改它
- NSArray *constrains = self.view.constraints;
- for (NSLayoutConstraint* constraint in constrains) {
- if (constraint.firstAttribute == NSLayoutAttributeHeight) {
- constraint.constant = 300;
- }
- }
- //更新约束 在某个时刻约束会被还原成frame使视图显示
- [self.view layoutIfNeeded];
- } completion:^(BOOL finished) {
- }];
这样我们就可以通过 AutoLayout 实现传统的animation方法。需要注意的是在调用animation方法之前一定要先调用layoutIfNeeded,这是为了让view先生成最初的frame并显示,否则动画效果会失效。
六、UITableView 在IOS6 下关于Auto Layout的约束报错问题
在项目的开发过程中特别是在低版本的系统上,总会遇到各种让人摸不着头脑的约束报错问题。当然一些是由于我们代码本身的问题造成的,一方面是苹果自身在系统和API升级过程中的缺陷。比如下面要提到的这个关于 UITableView 的约束报错问题。因为苹果在IOS6的时候就推出了AutoLayout,那个时候AutoLayout还不是那么的完善。所以在IOS6下UITableView会有一些关于AutoLayout的报错。报错信息一般是这样的:
- 2015-10-01 09:55:39.245 JaybinAutolayoutTest[723:27327] *** Assertion failure in -[UITableView layoutSublayersOfLayer:], /SourceCache/UIKit/UIKit-2262/UIView.m:3682
- 2015-10-01 09:55:39.530 JaybinAutolayoutTest[723:27327] *** Terminating app due to uncaught exception 'NSInternalInconsistencyException', reason: 'Auto Layout still required after executing -layoutSubviews. UITableView's implementation of -layoutSubviews needs to call super.'
根据上面的报错信息可以得出结论:在iOS6上,UITableView 的-layoutSubviews 方法中并没有调用super 的layoutSubviews。
由于Autolayout的自动布局是在UIView的layoutSubviews中实现的,所以TableView上的子view(如:UITableViewCell,headerView,footerView)使用了Autolayout,那么tableView在布局的时候就会去调用layoutSubviews布局子view。而tableView的-layoutSubviews 并没有调用super 的layoutSubview,所以就会抛出异常了。
那么我们怎么去解决这个报错问题呢?
1、如果是cell,我们经常使用[cell addSubview:view]再对view做一个相对cell的约束,这时候就会出现问题。解决方案就是使用[cell.contentView addSubview:view]。我们约束是对cell的contentView添加,跟cell无关。tableView就不会调用layoutSubviews了。
2、如果是headerView或者footView。解决方案是直接使用frame,或者自己定义一个类似Cell的contentView的view,子view相对contentView布局使用Autolayout,contentView对headerView布局使用frame
需要注意的是:直接对TableView使用Autolayout是不会有问题的,TableView是否调用layoutSubviews在于他上面的子view是否使用Autolayout,而不是他本身。