关于自动布局和iPhone X、iOS11的适配
自从提供了Auto Layout之后,官方文档中就建议开发者尽量的在布局中使用自动布局技术,虽然使用frame布局可以应付一些屏幕尺寸不是很多的设备,但是遇到iPad中可以进行多界面操作、iPhone X安全区内布局这样的迭代出来的适配问题,以前的frame布局会越来越不实用,对界面的掌控力度越来越弱,因此下面所有讨论都是基于使用Auto Layout布局而不是frame布局。
1.安全区域
危险区:传感器区域
在新机型iPhone X中由于全面屏的特性,顶部和底部分别有两个特别的传感器,顶部是一个凹形,底部则是一个短矩形。Apple的指导规则是,所有的控件都要在safeArea
内,如果有可能,尽量在safeAreaMargin内以增加横屏下的控件的误操作间距。比如下面这个在标签管理界面的底部操作按钮就应该这样进行适配:
1 | [finishButton mas_makeConstraints:^(MASConstraintMaker *make) { |
同理,顶部的适配也是类似的,只不过是bottom和top的区别而已。
安全区域下的Statusbar和Tabbar
由于传感器的存在,iPhone X机型上会有一些危险区域,这些区域内部是不允许有任何app内部的可操作控件,相应的为了适应safeArea,状态栏和Tabbar的高度在iPhone X上也有所改变。
如图可以看出来,在iPhone X机型下,状态栏增加了24px (@2x),Tabbe增加了34px (@2x)。
UIView的安全区域
自定义的视图添加到控制器中,在iOS11以前由于没有安全区域的概念,所以,一般的布局没有问题,但是到了iPhone X中,显示会有一些偏差,具体就是:上面太靠上、下面太贴下。
全屏显示的界面
我们的项目中有许多地方是需要全屏显示。针对于全屏显示的视图,其布局可以使用frame根据屏幕尺寸的大小进行设置rect;也可以使用自动布局进行布局,但是,约束就不再跟safe area有关了,可以直接根据superView的top
、bottom
来设置约束。比如右边的这个侧滑出来的效果,整个模糊视图是添加在控制器上的,然后上面的控件添加到这个全屏显示的视图上。
虽然对于全屏视图不需要使用safe area,但是,其子视图则需要使用safe area来适配iPhone X机型。具体适配可以参考上面适配底部传感器中的方式:
1 | [self.bottomSubview mas_makeConstraints:^(MASConstraintMaker *make) { |
半屏幕显示的界面
项目中比较多的是直播间从底部弹出一些视图,这些视图有的为展示类型,有的为可操作类型,展示类型中又分为表视图可滚动以及标签静态展示。对于表视图,Apple的文档中说可以设置底部紧贴父视图的底部,UIKit会为底部传感器遮挡的部分流出来间距,而如果是标签或者按钮之类的非滚动控件,则需要开发人员自行进行安全区域的适配。
这部分我的设计建议是对这些半屏可弹出视图进行重新设计:
对于
弹出视图
,让他们都全屏显示然后添加
遮罩视图
提供毛玻璃、无颜色、透明黑色等显示效果,并且具备点击隐藏的响应事件内部添加需要展示的
内容视图
,根据需要展示的具体内容撑起整个内容视图
。
因为有的具体内容需要避开底部的传感器,因此会有不同版本之间的适配。或者不使用遮罩视图
,让弹出视图
自己成为遮罩视图
。如右所示:
对弹出视图做约束:
1 | BottomFollowView * followView = [BottomFollowView followView]; |
依照上面的设计规则,对内容视图做约束:
1 | [_contentView mas_makeConstraints:^(MASConstraintMaker *make) { |
Xib中的安全区域
难保一些界面使用xib进行构建,这些界面中有部分需要适配iPhone X,就需要用到安全区域。在xib中打开安全区域的办法很简单,由于这个是iOS 11中添加的,所以要求Xcode版本至少9.0以上,如图:
打开使用safeAreaLayoutGuide
之后,子视图就可以根据安全区域来进行设置约束了。在设置约束的时候不再是根据superView来进行布局,而是根据safe area:
这样设置之后,实测在低版本(iOS 11以下)中,也不会出现系统不匹配运行崩溃的问题,应该是UIKit内部做了适配。但是如果项目最低版本为iOS 8.0,则不可以在xib中使用safe area功能。
2.导航栏
在iOS11之后新增了一个lagreTitle
属性,就是大标题,默认是不开启的,也可以选择在那一个控制器中开启,由于我们的项目中暂时没有用到大标题,所以没有适配需求。
1 | @interface UINavigationBar |
对于返回按钮,项目中导航栏的返回效果是一个返回箭头,没有文字。在iOS10之前使用自定义的返回按钮是没有问题的,不过到了iOS11之后,返回按钮就是这个样子,有一个向下大约10px的位移。不过在新建一个工程之后,却没有这样的情况发生,有可能是老版本遗留下的问题,不过这都可以使用下面的自定义返回按钮进行解决。
目前有两种不同系统下的四种情况:
使用系统提供的返回按钮
可以看出来,在iOS11以前,系统只是使用了两个简单的类:UINavigationItemButtonView、_UINavigationBarBackIndicatorView来展示返回按钮和前一个界面的title
。而到了iOS11,则使用了更多的类来进行返回按钮的组装,应该是由于在iOS 11之后,导航栏也支持自动布局了,简单的两个类不能够适用于多种场景,增加了一些容器类:_UINavigationBarContentView、_UIButtonBarStackView等。
使用自定义的返回按钮
而如果使用自定义返回按钮,在iOS11系统下,系统提供了一些StackView来实现对自定义按钮的自动布局约束,具体为,navigationBar
会添加在_UIButtonBarStackView上面,而_UIButtonBarStackView则添加在_UINavigationBarContentView上面。
title和titleView
导航栏中的title
和titleView
在iOS11 以后也发生了变化。
由于我们不需要自定义titleView
,因此这部分没有什么适配问题。
3.UIScrollView
iOS11中废弃了automaticallyAdjustsScrollViewInsets
,取而代之的是contentInsetAdjustmentBehavior属性和adjustedContentInset属性决定UIScrollView与边缘的距离。
1 | @interface UIViewContrller |
UITableView
UITableView在iOS 11中默认启用Self-Sizing
。在iOS8引入Self-Sizing
之后,我们可以通过实现estimatedRowHeight相关的属性来展示动态的内容,实现了estimatedRowHeight
属性后,得到的初始contentSize
是个估算值,是通过(estimatedRowHeight x
cell的个数)得到的,并不是最终的contentSize
,tableView不会一次性计算所有的cell的高度了
,只会计算当前屏幕能够显示的cell个数再加上几个,滑动时,tableView不停地得到新的cell,更新自己的contenSize,在滑到最后的时候,会得到正确的contenSize。如果底部区域不存在可交互的固定组件,那么tableView需要延伸到屏幕底部,而不是安全区域以内,UIKit会为滚动视图提供一个安全的contentOffset。
由于我们的项目中没有使用self-sizing
,基本上都是固定或者计算动态高度,所以需要关闭默认开启的Self-Sizing
:
1 | self.tableView.estimatedRowHeight = 0; |
UITableView除了在iOS11下自动开启了Self-Sizing
,还对UITableViewCell的contentView进行了安全区域的修改。在竖屏情况下是不会有影响的,在横屏下,由于左右的传感器区域,UIKit会将contentView内嵌入安全区域内,如果不需要内嵌到安全区域,可以手动关闭这个特性,使用UITableView的insetsContentViewsToSafeArea
属性来进行控制:
1 | @property (nonatomic) BOOL insetsContentViewsToSafeArea API_AVAILABLE(ios(11.0), tvos(11.0)); // default value is YES。 |
4.自动布局
如果使用Frame设置用户界面,必须计算视图层次结构中每个视图的大小和位置,然后,当发生变化(比如旋转屏幕、iPad多屏幕任务等),则必须重新计算所有受影响的视图的位置。由于必须自己管理所有更改,因此,设计一个简单的用户界面需要花费大量的精力进行设计,调试和维护,创建一个真正的自适应用户界面增加了一个数量级的困难。
自动布局(Auto Layout)使用一系列约束
来定义用户界面。约束通常代表两个视图之间的关系。自动布局然后基于这些约束来计算每个视图的大小和位置。这产生了动态响应内部和外部变化的布局效果。
一个约束的意义
视图层次结构的布局被定义为一系列线性方程式。每个约束表示一个单一的等式。设置约束的目的是书写一系列只有一个可能解决方案的方程。
Auto Layout经常提供多种方法来解决同样的问题。理想情况下,应该选择最清楚地描述设置约束的方案。但是,不同的开发人员会有不同的设置约束习惯。官方文档推荐使用以下经验法则:
- 整数乘法器比分数乘法器更容易理解
- 正常数比负常数更好
- 设置约束的时候最好有一个固定的顺序:首尾,上下
优先级
您也可以创建可选约束,所有约束条件的优先级都在1到1000之间。优先级为1000的约束条件是必需的,所有其他限制是可选的。
设计约束的解决方案时,自动布局尝试按优先级顺序从最高到最低来满足所有约束条件。如果它不能满足可选约束,则跳过该约束并继续到下一个约束。
不要觉得有义务使用全部1000个优先级值。事实上,优先级应该围绕系统定义的
低(250)
中(500)
高(750)
必需(1000)
优先级进行。可能需要制定高于或低于这些值一个或两个点的约束,以解决约束的时候出现的多重约束问题。如果超出这个范围,你可能想要重新检查布局的逻辑。
边距
在使用Xib创建视图并使用自动布局的时候,子视图相对于父视图会有一个间距,兄弟视图之间也有一个间距,这个间距有时候并不是我们想要的。
NSLayoutConstraint
NSLayoutConstraint就是上面对一个约束的抽象类,可以根据这个类的实例进行两个视图的约束设置,遵循
item1.attribute1 = multiplier × item2.attribute2 + constant
公式。
另外,约束不限于平等关系。它们也可以使用大于或等于(> =)
或小于或等于(<=)
来描述两个属性之间的关系。制约因素也有优先级在1和1,000之间。
这种是Auto Layout框架下最基础的约束设置,但也是书写起来最麻烦的约束设置。
NSLayoutAnchor
上面的布局方程式简单的介绍了一个约束其实是什么,NSLayoutAnchor这个类就是可以简化布局方程式的一个工厂类,因此他可以极大的简化创建NSLayoutConstraint的过程。
UIView
不提供布局边距属性的锚定属性。相反,有一个layoutMarginsGuide
属性提供了一个UILayoutGuide
的对象代表这些边距,使用layoutGuide的锚点属性来创建您的约束。
1 | // 使用 NSLayoutConstraint 创建约束 |
但是在使用NSLayoutAnchor的时候,需要注意一点,记得将视图的translatesAutoresizingMaskIntoConstraints
属性设置为NO,意思是视图使用autolayout。
默认情况下,视图上的自动调整掩码会产生完全确定的约束视图的位置。这允许自动布局系统跟踪其视图的帧布局是手动控制的(例如通过-setFrame:)。
当您选择通过添加自己的约束来使用自动布局来定位视图时,您必须将此属性设置为NO。 IB将为你做这个。@property(nonatomic) BOOL translatesAutoresizingMaskIntoConstraints NS_AVAILABLE_IOS(6_0); // Default YES
虽然文档上说的UIView不会提供布局的边距锚点,可以通过layoutMarginGuide
来获取,但是通过这个属性可以看出来,这样的布局是有一个margin的,默认是20。如果不使用layoutMarginGuide
,直接使用self.view.leftAnchor
也是可以进行锚点布局,的这时候就不会有一个20的间距了。
1 | UILayoutGuide = self.view.layoutMarginsGuide; |
UILayoutGuide
这是iOS9.0以后新增的类,用来对使用Auto Layout布局的时候提供虚拟占位,不会渲染到视图层级结构中。
这个类设计的巧妙的地方就在于他不会渲染到视图层级中,却可以决定有关联的视图之间的布局。
想象一个场景:三个视图,宽高一样,但是要在父视图中等间距的排列,这是一个很常遇到的需求。
如果是使用Frame来布局,那就简单了,仅仅是计算问题。但如果是自动布局,那就要增加一些『辅助视图』,『辅助视图』不会显示出来,但是会对布局有帮助,听起来和UILayoutGuide的作用一样,但是辅助视图的缺点就是会渲染到视图层级结构中。现在如果使用UILayoutGuide来实现,这很『简单』。
看起来需要写很多样板代码,如果使用Masonry来进行自动布局,就会减少很多代码。
UILayoutGuide更多的是用在UIView的自动布局中,类似的UIViewController中的topLayoutGuide
和bottomLayoutGuide
已经废弃,替换的是使用UIView中的safeAreaLayoutGuide
获取layoutGuide:
Visual Format Language
Apple在布局方面为方便实现自动布局做的一个语法糖,作用类似于上面的NSLayoutAnchor
,可以简洁直观的设置约束。
UIStackView
很不巧,这个也是在iOS 9以后提供的。这个类为布局提供了简单的方式:对齐方式、排列方式、填充方式等。从上面导航栏那一节可以知道,在iOS11中导航栏使用自动布局,就是使用的UIStackView的私有子类,而且在自动布局指南中,也有提到UIStackView内部是使用的自动布局技术。
5.布局相关的方法
无论使用frame设置子视图的布局,还是使用自动布局设置,不同的人会有不同的方法在不同的地方设置。比如,在init
或者initWithFrame:
方法中、在layoutSubviews
中、在viewDidLoad
中、在viewWillLayoutSubviews
中。需要注意的是,如果使用frame进行布局,有的地方是拿不到正确的视图尺寸的,那么这些方法又是在生命周期中什么时候调用,具体又是什么含义,下面,摘抄记录一下自动布局指南中关于设置、更新约束的时机和方法。
更改约束
以下所有操作都会改变一个或多个约束条件:
- 激活或停用约束
- 更改约束的常量值
- 更改约束的优先级
- 从视图层次结构中移除视图
其他更改(如设置控件属性或修改视图层次结构)也可以更改约束。发生更改时,系统将进行延期布局。
一般来说,可以随时进行这些更改。理想情况下,大多数约束条件应该在Interface Builder中设置,或者在控制器的初始设置(例如, viewDidLoad
)期间由视图控制器以编程方式创建。如果需要在运行时动态更改约束,通常最好在应用程序状态更改时更改它们。例如,如果想要更改约束来响应按钮点击,请直接在按钮的操作方法中进行更改约束。
延期布局(The Deferred Layout Pass)
自动布局不是立即更新受影响的视图的框架,而是安排不久的将来布局。先延迟传递更新布局的约束,然后计算视图层次结构中所有视图的frame。
可以通过调用setNeedsLayout
方法或setNeedsUpdateConstraints
方法来主动的进行自己的延期布局。
延期布局过程实际上涉及两个修改视图层次的过程:
- 更新过程根据需要更新约束
- 布局过程根据需要重新定位视图的frame
更新过程(Update Pass)
系统遍历视图层次,并调用所有视图控制器上的updateViewConstraints
,以及所有视图上的updateConstraints
方法。可以重写这些方法来优化对约束的更改,比如下面的批量更改。
布局过程(Layout Pass)
系统再次遍历视图层次,并调用所有视图控制器上的viewWillLayoutSubviews
,并在所有视图上调用layoutSubviews
。默认情况下,该layoutSubviews
方法中可以使用Auto Layout引擎计算的出来的矩形来更新每个子视图的框架。可以覆盖这些方法来修改布局。
可以看出来,视图的布局中主要的函数调用顺序为:
updateConstraints
->layoutSubViews
->drawRect
批量更改(Batching Changes)
在发生影响变化之后,立即更新约束几乎总是更清晰和更容易。将这些更改推迟到以后的方法会使代码更复杂,更难理解。
但是,出于性能方面的原因,有时可能需要批量更改。这应该只在更改约束的地方太慢,或者当一个视图正在进行一些冗余的更改时才能完成。
要批量更改,而不是直接进行更改,请调用 setNeedsUpdateConstraints
包含约束的视图上的方法。然后,重写视图的updateConstraints
方法来修改受影响的约束。
注意
updateConstraints
方法必须尽可能高效。在这个方法中不要停用所有约束,然后重新激活所需的约束。相反,应用程序必须有一些方法来跟踪约束,并在每次更新过程中验证它们,只更改需要更改的项目。在每次更新过程中,都必须确保对应用程序的当前状态有适当的限制。
始终将调用父类方法放置在最后一步。不要在updateConstraints
方法中调用setNeedsUpdateConstraints
。调用setNeedsUpdateConstraints
会开启另一个更新通道,导致反馈循环。
Auto Layout指南中的自定义布局
应该重写viewWillLayoutSubviews
或layoutSubviews
方法来修改布局引擎返回的结果。
如果可能的话,使用约束来定义所有的布局。生成的布局更健壮,更易于调试。当您需要创建无法单独使用约束表达的布局时,您应该只覆盖
viewWillLayoutSubviews
或layoutSubviews
方法。
覆盖这些方法时,布局处于不一致的状态。已经有一些意见。其他人没有。您需要非常小心如何修改视图层次结构,或者您可以创建反馈循环。以下规则可以避免反馈循环:
- 调用超类的方法
- 在调用超类方法之前可以设置子视图中的布局无效,且必须是在调用超类方法之前
- 不要使你的子树以外的任何视图的布局失效。这可能会创建一个反馈循环
- 不要调用
setNeedsUpdateConstraints
函数,因为刚刚完成更新通行证,调用这个方法会导致反馈循环 - 不要调用
setNeedsLayout
函数,调用这个方法会创建一个反馈循环 - 要小心改变限制。你不想意外地使你的子树外的任何视图的布局失效
Masonry中建议设置布局的位置
在Masonry的README中可以看到,他们建议创建约束的位置为下图所示,
和上面文档中所说的一致,只不过他们选择的是在更新过程
中来进行设置约束,而且是建议remake、update。另外,为了告诉UIKit这个视图是使用的Auto Layout,重写了+ (BOOL)requiresConstraintBasedLayout
函数,返回YES,这个和在外面设置translatesAutoresizingMaskIntoConstraints
=NO来禁用Autoresizing Masks是一样的。
6.远古布局技术Autoresizing
在iOS2.0系统中引入的用于屏幕适配的技术。但是仅仅适用于子视图和父视图之间的布局关系
,这项技术的作用是,当父视图的bounds发生变化的时候,如何自动调整子视图的布局。表现形式在xib中是使用6个线,在code中使用一个UIView的枚举属性autoresizingMask:
1 | typedef NS_OPTIONS(NSUInteger, UIViewAutoresizing) { |
另外,对于一个视图来说,是否使用autoresizing技术可以通过autoresizingSubviews这个布尔属性来决定。一句话就是,autoresizingSubviews决定是否使用,autoresizingMask决定如何使用。
1 | @property(nonatomic) BOOL autoresizesSubviews;// default is YES |
具体前面提到的6根线是什么,可以参考下面的文章链接。
在这个说AutoLayout的文章里面提及Autoresizing主要是因为他们两个不能共存,或者说AutoLayout是相对于Autoresizing更加完备的布局技术,Autoresizing只能指明父视图改变bounds的时候如何去适应子视图,却没有办法决定子视图之间如何适应改变,而AutoLayout却可以做到这些。Xcode5之后在xib、sb内加入了autolayout的选项开关,并且默认是开启的,这也是Apple推荐使用AutoLayout,如果不想使用AutoLayout仍然想使用Autoresizing,关闭autolayout的选项开关
即可。
另外说一下,如果项目中有以前使用xib布局的类在设备上显示的时候有frame相关的bug,排查过没有主动修改frame,那么百分之八九十都是由于Autoresizing引起的。
参考文章
你需要为你的APP适配iOS11 - WWDC session 204