赞
踩
2020年12月看完了一本关于iOS自动布局的作品,叫Modern Auto Layout,作者是Keith Harrison
,知名博客Use Your Loaf的作者,一位元老级程序员。这本书里介绍了iOS自动布局的概念、发展、技术细节、API更新和使用场景,即使懂iOS但是自动布局方面零基础的开发者,只要坚持看完并且完成每章的课后练习,也能做到布局菜鸟变大师。这篇博客就简单做个总结性的笔记,提炼一些重点和自我理解。
在自动布局出现以前,我们都是通过设置view.frame来做到布局的,但是随着iPhone和iPad的迭代升级,出现不同大大小小的屏幕,导致手动布局的代码变得难以维护。为了做到视图自适应的效果,苹果最开始给出了弹簧与支柱(springs and struts)
的解决方案,你可以在Interface Builder里看到这样的截图:
它的意思就是:通过设置视图外在的上下左右四个支柱
来固定与父视图的间距,以及设置内部的宽高弹簧
来决定是否自适应宽高。这样的话,视图就会跟随父视图frame的变化而变化。换成代码的话,就叫做Autoresizing Mask
。特别注意,用IB做和用代码做是不同的,代码中没有支柱的概念,比如设置固定上、左、右边距和自动宽度效果的话,用代码就得这么写:
greenView.autoresizingMask = [.flexibleWidth, .flexibleBottomMargin]
弹簧与支柱的方法,解决了父子视图之间自适应的问题,但是却无法解决兄弟视图的自适应。比如视图A有两个子视图B和C,可以用弹簧与支柱来确定A与B、A与C的自适应关系,但是B和C如果想要保持一个固定的间距,这种问题就超越了弹簧与支柱的能力,但是可以用自动布局实现。
自动布局的核心是约束(NSLayoutConstraint)
,约束是指定视图之间的关系和规则,然后在运行时再将规则转换成视图的frame。而约束的本质其实是数学公式,比如指定两个视图之间的距离,其实就是创建一个约束公式:
约束可以设置的关系有很多,比如这些例子:
// 垂直方向间距
redView.top == greenView.bottom + 8.0
// 宽高倍数
redView.height == 0.5 x greenView.height
// 不等关系
redView.width <= greenView.width
// 长宽比
greenView.height = 0.5 x greenView.width
// 宽高常数
redView.height = 50.0
使用代码创建的话,苹果官方给了三种方式:
作为长期写业务的程序员,我也知道很多人都在用知名的开源库(比如OC版的Masonry和Swift版的SnapKit),这些库的流行体现了它们链式语法的API使用简洁和可维护的特点,减少了我们的开发时间。作者并没有说不推荐使用开源库,而是强调使用之前首先需要了解自动布局的原理,而不是仅仅停留在只会用的层面。
结论是需要满足以下原则,至少需要足够的约束来固定层级中每个视图的大小(size)和位置(position),接着在维度上,size需要在水平和垂直方向各需要至少一个约束,position也是同样的。
比如给一个view添加足够的约束,通常有两种方式:
有时候简单执行上面的方法也是不够的,比如下面这个例子,按照截图中的规则添加约束。
当黄色视图水平拉伸的时候,依然无法确定红色和绿色两个视图的宽度该怎么分配,这就造成了歧义(ambiguity)。
如果再为它们添加一个等宽的约束,才能最终确定子视图的size,这才算是足够。
约束创建出来,总需要有个地方保存(被持有)吧。UIView有个只读属性constraints
,即拥有的约束集合,说明约束是被UIView对象所持有的。问题是,每一个创建出来的约束,究竟该分配给哪个视图呢。官方定义是,**视图拥有的约束只能包含该视图本身或其子视图。**举个例子就清楚了,比如有红绿黄三个视图,yellowView是另外两个的父视图,两个子视图有自己的宽高约束,greenView居中对齐,redView为水平居中且垂直距离greenView16个单位。
这个例子中,每个view的constraints究竟包含哪些约束呢?拿greenView来说,宽高约束都设给自己的,跟其他view没有关系,所以自己拥有它们;中心约束是相对父视图的,对于涉及了父视图的约束根据定义就只能由作为父视图的yellowView拥有;垂直方向16单位的约束所涉及同级的视图,谁拥有都不合适,所以依然是父视图yellowView拥有。
自iOS9以后,苹果推出了UILayoutGuide
,我把它简单理解为一个既看不见也不能响应事件交互的矩形框。它不是视图,也不会存在于视图层级中,而它可以参与自动布局中约束关系的创建,在布局中起到占位视图
的作用。比如某些时候,你可能为了完成一些复杂的布局,需要创建几个不带背景色的UIView仅仅是为了放在那里填充位置,实现等宽等距或视图居中的效果,现在这些工作也可以交给layout guide来完成,因为正好符合它的特点(看不见且无视点击的矩形框)。
但是layout guide只是用在这里就有些小题大做了,苹果在iOS11推出了Safe Area
的概念,也就是视图安全区域,它的作用就是让视图不会被状态栏、导航栏、tab栏和刘海头给遮挡,可以通过UIView的safeAreaLayoutGuide
属性获取。以下是Safe Area的示意图
为了兼容低版本的iOS,可以在代码中可以用topLayoutGuide
和bottomLayoutGuide
来适配类似的效果(后来推出了安全区域后就被苹果废弃了),但如果你使用IB来创建约束,即使使用了Safe Layout Guide,系统也会帮你自动兼容到iOS9的。
如果在父子视图布局中需要额外边距(Margins)的话,可以使用UIView的layoutMarginsGuide
来布局。边距大小也是可以通过UIView的layoutMargins
来修改的,这些margins直到视图显示在屏幕上时才会被最终确定(viewDidAppear)。在iOS11,苹果还支持了directionalLayoutMargins
来满足right-to-left语言的适配。
需要注意的是,对于iOS11以下的版本,开发者不能更改根视图的margins,意思是如果你尝试修改viewController.view.layoutMargins的话是无效的。而且margin guide会忽视掉top/bottomLayoutGuide,这样就导致基于margin guide的视图有可能被导航栏给遮住。
从个人的开发经历来说,我从来没有使用过它,一直是简单粗暴地创建间距约束在做需求,毕竟个人觉得设置约束的constant更加直观一些。
假如你做了一个跟屏幕同宽的tableView列表,在手机上滑动看着正好,但是放在iPad用横屏一看,是不是会觉得列表太宽了,影响了视觉体验吧。苹果有一个readableContentGuide
可以解决这个问题,在宽屏的iPad上,会给列表左右两边留出适当的空白,让整个内容的展示空间限制在中间的区域。实在无法脑补的话,建议在Mac上用Safari浏览器打开一篇文章,点击“显示阅读器”感受一下。这就是苹果为阅读模式提供的布局。
写了这么多layout guide,对于支持iOS 11+的App来说:
safeAreaLayoutGuide
layoutMarginsGuide
directionalLayoutMargins
readableContentGuide
来避免内容在iPad上过于拉伸。讲内容自适应之前,先要明确一个优先级(priority)的概念。给约束设置优先级是为了防止多个约束冲突,从而优先执行级别高的。优先级是1~1000范围内的数字,UIKit给优先级设置了三个档位:defaultLow(250), defaultHeight(750), required(1000),无论是IB还是代码,通常情况下创建的约束都是最高优先级required(1000)。
我有时候因为需要而创建一些“备用”约束作为“额外的保护性措施”,避免视图在极端情况下不会出问题,但是又不想跟现有的约束发生冲突,那么可以把这种约束的低优先级设置相对低一些。这样布局引擎就会在运行时优先满足优先级最高(required)的约束,必要时尽可能满足优先级偏低的约束。
有些特殊的UIView其实是自带内容的,比如UILabel有文字,UIImageView有图片,像这些视图,即使不去主动设置宽高约束,它们也可以根据自己的内容大小来决定应该在布局中如何正确展示宽高。这是怎么做到的呢?
每个UIView对象都有一个intrinsicContentSize
的属性,如果视图在布局的时候没有宽高约束的话,布局引擎会根据它返回的CGSize给视图补上相应的宽高约束,UIImageView重载了这个方法,并且返回自身图片的大小,所以布局UIImageView的话,无论代码还是IB,在它设置了图片的情况下只需要添加UIImageView的水平和垂直位置约束就足够了。
这里提到了布局引擎根据内容而自动添加的宽高约束,可以理解成我前面提到的“备用”约束。如果你觉得图片本身的尺寸不合适,也可以主动添加宽高约束,因为人工主动添加的约束优先级默认都是最高的,因此布局引擎会优先满足它。
对于没有内容的视图,intrinsicContentSize返回的宽高都是-1,UIKit专门定义了一个UIViewNoIntrinsicMetric
的常量,用来描述某一个(宽或高)维度中没有明确长度的情况。
这里摘录一些UIKit常用组件的intrinsicContentSize
对于自定义的UIView,如果希望使用内容自适应的特性,也同样可以重载这个方法来实现:
class CustomView: UIView {
override var intrinsicContentSize: CGSize {
// 宽度无法自己决定,但是高度默认是100
return CGSize(width: UIViewNoIntrinsicMetric, height: 100)
}
}
比如这个截图,有图片和文本,它们的size都是根据内容来自适应的,所以不需要人为设置宽度约束。但是问题在于,当屏幕变宽时,谁可以优先被拉伸?当屏幕变窄时,谁又可以优先被压缩呢?
解决这个问题,需要引进两个概念:拒绝拉伸优先级Content-Hugging priority
和拒绝压缩优先级Compression-Resistance priority
,这样就好理解了。再看上面的例子,将图片的两个拒绝值都提高1个单位,当屏幕变宽时,由于图片相对于文本更加拒绝被拉伸,所以文本宽度就被拉伸开了;当屏幕变窄时,由于图片相对于文本更加拒绝被压缩,所以文本的宽度就被挤压而导致换行。
苹果推出的UIStackView
非常方便的满足了弹性布局(Flexbox)的需求,使用stack的方式布局,既减少了约束维护成本,而且它的强大之处在于通过嵌套布局完成复杂的UI,比如下面这个截图,只用UIStackView来布局完全足够了:
对于UIStackView,我总结了几个基本点:
axis
,即stack方向是水平布局还是垂直布局distribution
表示在axis方向上的布局,细节就不写了,网上有很多带图的博客alignment
表示与axis相对方向的布局,比如axis是水平方向,那alignment就是垂直方向的布局setCustomSpacing
如何把用自动布局实现的UI,放在UIScrollView上滑动呢?这是个好问题。因为scrollView之所以能够滑动的条件是contentSize大于frame.size,但是自动布局是等到运行时才能计算出内容视图最终的size,那有没有可能在得到size之前先设置好约束规则,让scrollView的contentSize自动响应内容的size结果呢?方法如下:
ContentView
作为承载所有内容视图的容器,然后设置层级为RootView -> ScrollView -> ContentView,添加好所有边界(Edges)约束另外如果UIStackView满足条件的话,可以直接用它来替代上面提到的contentView。作者介绍该技巧的时候,也附赠了相应的demo(点击查看)。
根据文本内容计算高度的列表cell这类的需求,自从iOS发展至今一直都存在。如何利用自动布局来完成自动计算cell的高度呢?这种“鸡生蛋,蛋生鸡”的问题,在iOS8以后就有了解决方案:
contentView
上并设置好约束tableView.rowHeight = UITableView.automaticDimension
来取消固定行高tableView.estimatedRowHeight = 任何大于等于零的值
来启动估算行高,这里指定一个预设值,而实际的行高会由自动布局来计算布局引擎是用来处理约束的更新和计算frame的地方。当改变了约束的active, constant, priority
,或者视图被移除层级的时候,会导致约束更新。但这种更新并不会立即改变view.frame,为了提高效率,布局引擎会把这些批量改动安排到一次传递(Layout Pass)中,等待下一次runloop到来时执行。当传递被执行的时候,会在视图层级中经历两次传递过程:
setNeedsUpdateConstraints
触发)。在此过程中,系统遍历视图层级,调用updateViewConstraints
和updateConstraints
方法来更新约束。setNeedsLayout
触发)。在此过程中,系统遍历视图层级,调用layoutSubviews
方法,通过引擎根据约束规则计算出frame并设置给每个视图的subviews是可以的。比如Masonry有个示例代码(点击查看),自定义一个视图,通过重载该方法实现更新约束,然后点击按钮时调用setNeedsUpdateConstraints
来触发约束更新行为。虽说允许重载,不过苹果明确说明,若不是为了优化等特殊操作,不建议在这里创建约束。一般建立约束关系通常都是在viewDidLoad或者IB里面就可以。注意不要在重载方法里调用setNeedsUpdateConstraints
,死循环!
当然可以,比如存在自动布局无法实现的布局,或者根本不使用自动布局的话,都可以通过重载该方法来直接修改子视图的frame。不要在重载方法里调用setNeedsLayout
和setNeedsUpdateConstraints
,死循环!
由于布局的更新不会立即生效,所以对于UIView动画而言,只有在动画的block里调用layoutIfNeeded
方法,让布局立即生效,才能捕捉到视图的结束状态而顺利完成动画。该方法同样适用于iOS10推出的UIViewPropertyAnimator
。
在布局时,引擎是根据视图的Alignment Rect
来确定其位置的,而不是frame。为什么会这样呢?比如有些特殊情况,当视图存在阴影、右上角的红点数(Badge),或者这个带阴影的图片等demo示例,而你希望在布局计算时考虑这些因素的话,就需要用到它了。UIView提供了alignmentRectInsets
和alignmentRect(forFrame:)
这些API以便修改。
最常见的问题,就是在Xcode控制台看到Unable to simultaneously satisfy constraints
,然后出现NSAutoresizingMaskLayoutConstraint
之类的信息。这种情况原因就是,通过代码创建的UIView时,系统会自动把它默认的autoresizing mask转换成自动布局约束,结果就跟你为视图添加的约束冲突了。所以写布局代码时,一定别忘了加上这句:
view.translatesAutoresizingMaskIntoConstraints = false
(好在Masonry
和SnapKit
这些开源库已经为我们做了这些琐事,真让人省心。)
还有一个技巧就是,给视图和约束添加标记。设置约束的identifier
和设置视图的accessibilityIdentifier
,就可以在约束日志里看到对应的是哪个视图和哪个约束了,很方便。
开发者可以通过调用hasAmbiguousLayout
和exerciseAmbiguityInLayout
方法检查视图是否存在约束不明确的bug,甚至可以在调试环境里调用_autolayoutTrace
(私有API)来查看视图层级里是否存在Ambiguous Layout,但我基本上没有用过,因为读日志太累。相反,现在倒是有很多可视化工具可以在运行时检查视图层级的布局,相比代码检查方便多了,比如:
作者在介绍布局之前,也详细介绍了视图加载和生命周期相关的知识点,我总结一下。
懒加载,通常是需要使用到view的时候,UIViewController会调用loadView()
方法来加载UIView,loadView()方法会寻找是否存在nib或者storyboard文件并加载,如果没有就创建新的UIView对象。开发者不允许直接调用loadView(),如果需要强制加载view的话,可以调用loadViewIfNeeded()
,而且可以通过isViewLoaded
来判断view是否加载完成。
它首先根据vc的nibName属性来作为文件名称去寻找,如果没有给名称的话,就去找与vc相同类名的文件。比如vc的类叫RootViewController,那么loadView就会依次查找RootView.nib和RootViewController.nib,如果有RootViewController~ipad.nib
和RootViewController~iphone.nib
这样的文件,那么loadView也会根据不同设备加载对应的文件。
如果不用nib或者storyboard文件来加载view的话,也可以直接重载loadView方法。
class RootViewController: UIViewController {
override func loadView() {
// 不要调super
let rootView = UIView()
rootView.backgroundColor = .yellow
view = rootView
}
}
严格来说的话,是不可以的。我们首先了解一下这些方法在vc的调用时机:
loadView
:当需要使用view的时候,如果为nil,vc会调用loadView(懒)加载一个viewviewDidLoad
:在view加载完成后且没有被加入视图层级时调用,该方法在vc的生命周期中只会调用一次viewWillAppear
:在view即将要加入视图层级时调用,该方法在vc的生命周期中会被多次调用,与之相对应的方法是viewWillDisappearviewDidAppear
:在view已经加入到视图层级且显示在屏幕上时调用,该方法在vc的生命周期中会被多次调用,与之相对应的方法是viewDidDisappear因为loadView, viewDidLoad和viewWillAppear调用的时候,vc的view并没有加入到视图层级中,所以view的size没有最终成型,所以手动布局中如果依赖view.bounds或frame不一定是最终的结果;而viewDidAppear已经显示到屏幕上了,此时手动布局时机又太晚。
合适时机在哪里呢?答案是viewWillLayoutSubviews
和viewDidLayoutSubviews
,因为这是vc的view即将和完成布局subviews的时机。但是这些方法会多次调用,需要注意。
本书在谈论自动布局的过程中,也结合了其他UIKit的特性,比如:
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。