iOS现代自动布局

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

使用代码创建的话,苹果官方给了三种方式:

  • 使用NSLayoutConstraint类相关的API(公式版API,使用很麻烦,如果你只是写业务而不是写framework,就别用了)
  • 使用Visual Format Language(创意很好,但是不能在编译时检查错误,可读性一般,几乎很少被采用)
  • 使用NSLayoutAnchor(iOS9推出,支持不完全检查编译错误,官方推荐)

作为长期写业务的程序员,我也知道很多人都在用知名的开源库(比如OC版的Masonry和Swift版的SnapKit),这些库的流行体现了它们链式语法的API使用简洁和可维护的特点,减少了我们的开发时间。作者并没有说不推荐使用开源库,而是强调使用之前首先需要了解自动布局的原理,而不是仅仅停留在只会用的层面。

需要多少约束才足够?

结论是需要满足以下原则,至少需要足够的约束来固定层级中每个视图的大小(size)和位置(position),接着在维度上,size需要在水平和垂直方向各需要至少一个约束,position也是同样的。

比如给一个view添加足够的约束,通常有两种方式:

  • 添加与父视图之间的top, left约束(确定了view的position);还需要添加width, height约束(确定了view的size)。
  • 添加与父视图之间的top, left约束(确定了view的position);继续添加与父视图的bottom, right约束(这样相当于间接确定了view的size)。

有时候简单执行上面的方法也是不够的,比如下面这个例子,按照截图中的规则添加约束。
在这里插入图片描述
当黄色视图水平拉伸的时候,依然无法确定红色和绿色两个视图的宽度该怎么分配,这就造成了歧义(ambiguity)。
在这里插入图片描述
如果再为它们添加一个等宽的约束,才能最终确定子视图的size,这才算是足够。

约束被谁拥有呢?

约束创建出来,总需要有个地方保存(被持有)吧。UIView有个只读属性constraints,即拥有的约束集合,说明约束是被UIView对象所持有的。问题是,每一个创建出来的约束,究竟该分配给哪个视图呢。官方定义是,**视图拥有的约束只能包含该视图本身或其子视图。**举个例子就清楚了,比如有红绿黄三个视图,yellowView是另外两个的父视图,两个子视图有自己的宽高约束,greenView居中对齐,redView为水平居中且垂直距离greenView16个单位。
在这里插入图片描述
这个例子中,每个view的constraints究竟包含哪些约束呢?拿greenView来说,宽高约束都设给自己的,跟其他view没有关系,所以自己拥有它们;中心约束是相对父视图的,对于涉及了父视图的约束根据定义就只能由作为父视图的yellowView拥有;垂直方向16单位的约束所涉及同级的视图,谁拥有都不合适,所以依然是父视图yellowView拥有。
在这里插入图片描述

Layout Guide

自iOS9以后,苹果推出了UILayoutGuide,我把它简单理解为一个既看不见也不能响应事件交互的矩形框。它不是视图,也不会存在于视图层级中,而它可以参与自动布局中约束关系的创建,在布局中起到占位视图的作用。比如某些时候,你可能为了完成一些复杂的布局,需要创建几个不带背景色的UIView仅仅是为了放在那里填充位置,实现等宽等距或视图居中的效果,现在这些工作也可以交给layout guide来完成,因为正好符合它的特点(看不见且无视点击的矩形框)。

Safe Area

但是layout guide只是用在这里就有些小题大做了,苹果在iOS11推出了Safe Area的概念,也就是视图安全区域,它的作用就是让视图不会被状态栏、导航栏、tab栏和刘海头给遮挡,可以通过UIView的safeAreaLayoutGuide属性获取。以下是Safe Area的示意图
在这里插入图片描述
为了兼容低版本的iOS,可以在代码中可以用topLayoutGuidebottomLayoutGuide来适配类似的效果(后来推出了安全区域后就被苹果废弃了),但如果你使用IB来创建约束,即使使用了Safe Layout Guide,系统也会帮你自动兼容到iOS9的。

Margins

如果在父子视图布局中需要额外边距(Margins)的话,可以使用UIView的layoutMarginsGuide来布局。边距大小也是可以通过UIView的layoutMargins来修改的,这些margins直到视图显示在屏幕上时才会被最终确定(viewDidAppear)。在iOS11,苹果还支持了directionalLayoutMargins来满足right-to-left语言的适配。

需要注意的是,对于iOS11以下的版本,开发者不能更改根视图的margins,意思是如果你尝试修改viewController.view.layoutMargins的话是无效的。而且margin guide会忽视掉top/bottomLayoutGuide,这样就导致基于margin guide的视图有可能被导航栏给遮住。

从个人的开发经历来说,我从来没有使用过它,一直是简单粗暴地创建间距约束在做需求,毕竟个人觉得设置约束的constant更加直观一些。

Readable

假如你做了一个跟屏幕同宽的tableView列表,在手机上滑动看着正好,但是放在iPad用横屏一看,是不是会觉得列表太宽了,影响了视觉体验吧。苹果有一个readableContentGuide可以解决这个问题,在宽屏的iPad上,会给列表左右两边留出适当的空白,让整个内容的展示空间限制在中间的区域。实在无法脑补的话,建议在Mac上用Safari浏览器打开一篇文章,点击“显示阅读器”感受一下。这就是苹果为阅读模式提供的布局。

总结

写了这么多layout guide,对于支持iOS 11+的App来说:

  • 建议使用safeAreaLayoutGuide
  • 可以安全地使用layoutMarginsGuide
  • 如有right-to-left布局需要,可以使用directionalLayoutMargins
    对于iOS 9+,如有阅读布局需要,推荐使用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

  • UILabel如果有text,注意当numberOfLines为1时,intrinsicContentSize.width就是文本单行的长度,text很长,label就很长,即使给label设置宽度约束,内容宽度依然很长。
  • 设置UIButton的contentEdgeInsets会增加按钮的内容宽高,可以理解为把多添加的内间距变成“内容”的一部分,从而显得按钮视觉区域更大;但是设置titleEdgeInsets和imageEdgeInsets并不会增加内容宽高,因为后两个属性的设置是在布局结束后才生效的。
  • UISlider和UIProgressView都只有内容高度,没有宽度,所以为它们布局约束时设置好宽度就足够了。
  • UITextView在开启了scrollEnabled的情况下,没有内容宽高,而在滑动行为禁止的情况下就表现得像多行文本的UILabel一样,不过大多数情况下,还是建议主动为它添加宽高约束吧。

对于自定义的UIView,如果希望使用内容自适应的特性,也同样可以重载这个方法来实现:

class CustomView: UIView {
    override var intrinsicContentSize: CGSize {
        // 宽度无法自己决定,但是高度默认是100
        return CGSize(width: UIViewNoIntrinsicMetric, height: 100) 
    }
}

拒绝拉伸和压缩

比如这个截图,有图片和文本,它们的size都是根据内容来自适应的,所以不需要人为设置宽度约束。但是问题在于,当屏幕变宽时,谁可以优先被拉伸?当屏幕变窄时,谁又可以优先被压缩呢?
在这里插入图片描述
解决这个问题,需要引进两个概念:拒绝拉伸优先级Content-Hugging priority和拒绝压缩优先级Compression-Resistance priority,这样就好理解了。再看上面的例子,将图片的两个拒绝值都提高1个单位,当屏幕变宽时,由于图片相对于文本更加拒绝被拉伸,所以文本宽度就被拉伸开了;当屏幕变窄时,由于图片相对于文本更加拒绝被压缩,所以文本的宽度就被挤压而导致换行。
在这里插入图片描述

UIKit组合技

UIStackView

苹果推出的UIStackView非常方便的满足了弹性布局(Flexbox)的需求,使用stack的方式布局,既减少了约束维护成本,而且它的强大之处在于通过嵌套布局完成复杂的UI,比如下面这个截图,只用UIStackView来布局完全足够了:
在这里插入图片描述

对于UIStackView,我总结了几个基本点:

  • 首先需要指明axis,即stack方向是水平布局还是垂直布局
  • 属性distribution表示在axis方向上的布局,细节就不写了,网上有很多带图的博客
  • 属性alignment表示与axis相对方向的布局,比如axis是水平方向,那alignment就是垂直方向的布局
  • 布局时,如果没有设置stackView的宽高约束,那么它会根据自己的arrangedSubviews来计算自己的size,如果设置了宽高约束,那么它会根据“拒绝拉伸/压缩优先级”来相应改变arrangedSubviews的size。
  • 自iOS11以后,可以自定义的间距setCustomSpacing
  • 自iOS12以后,stackView的性能被苹果提升了一个档次,所以对于开发并不追求性能的界面,我肯定会选择节省时间的stackView

UIScrollView

如何把用自动布局实现的UI,放在UIScrollView上滑动呢?这是个好问题。因为scrollView之所以能够滑动的条件是contentSize大于frame.size,但是自动布局是等到运行时才能计算出内容视图最终的size,那有没有可能在得到size之前先设置好约束规则,让scrollView的contentSize自动响应内容的size结果呢?方法如下:

  1. 自定义一个ContentView作为承载所有内容视图的容器,然后设置层级为RootView -> ScrollView -> ContentView,添加好所有边界(Edges)约束
  2. 如果你只允许垂直滑动的话,就给scrollView和contentView添加等宽约束(禁止水平滑动);如果你只允许水平滑动的话,就给它们添加等高约束(禁止垂直滑动)
  3. 将内容都添加到contentView中,设置好它们的边界约束

另外如果UIStackView满足条件的话,可以直接用它来替代上面提到的contentView。作者介绍该技巧的时候,也附赠了相应的demo(点击查看)

UITableView自适应cell

根据文本内容计算高度的列表cell这类的需求,自从iOS发展至今一直都存在。如何利用自动布局来完成自动计算cell的高度呢?这种“鸡生蛋,蛋生鸡”的问题,在iOS8以后就有了解决方案:

  1. 把自定义的视图都放在UITableViewCell的contentView上并设置好约束
  2. 设置tableView.rowHeight = UITableView.automaticDimension来取消固定行高
  3. 设置tableView.estimatedRowHeight = 任何大于等于零的值来启动估算行高,这里指定一个预设值,而实际的行高会由自动布局来计算
    详情也可以参考

布局引擎

布局引擎是用来处理约束的更新和计算frame的地方。当改变了约束的active, constant, priority,或者视图被移除层级的时候,会导致约束更新。但这种更新并不会立即改变view.frame,为了提高效率,布局引擎会把这些批量改动安排到一次传递(Layout Pass)中,等待下一次runloop到来时执行。当传递被执行的时候,会在视图层级中经历两次传递过程:

  1. 第一次是约束更新的传递(也可以通过手动调用setNeedsUpdateConstraints触发)。在此过程中,系统遍历视图层级,调用updateViewConstraintsupdateConstraints方法来更新约束。
  2. 第二次是更新视图frame(也可以通过手动调用setNeedsLayout触发)。在此过程中,系统遍历视图层级,调用layoutSubviews方法,通过引擎根据约束规则计算出frame并设置给每个视图的subviews

可以重载updateConstraints吗?

是可以的。比如Masonry有个示例代码(点击查看),自定义一个视图,通过重载该方法实现更新约束,然后点击按钮时调用setNeedsUpdateConstraints来触发约束更新行为。虽说允许重载,不过苹果明确说明,若不是为了优化等特殊操作,不建议在这里创建约束。一般建立约束关系通常都是在viewDidLoad或者IB里面就可以。注意不要在重载方法里调用setNeedsUpdateConstraints,死循环!

可以重载layoutSubviews吗?

当然可以,比如存在自动布局无法实现的布局,或者根本不使用自动布局的话,都可以通过重载该方法来直接修改子视图的frame。不要在重载方法里调用setNeedsLayoutsetNeedsUpdateConstraints,死循环!

布局动画

由于布局的更新不会立即生效,所以对于UIView动画而言,只有在动画的block里调用layoutIfNeeded方法,让布局立即生效,才能捕捉到视图的结束状态而顺利完成动画。该方法同样适用于iOS10推出的UIViewPropertyAnimator

Alignment Rect

在布局时,引擎是根据视图的Alignment Rect来确定其位置的,而不是frame。为什么会这样呢?比如有些特殊情况,当视图存在阴影、右上角的红点数(Badge),或者这个带阴影的图片等demo示例,而你希望在布局计算时考虑这些因素的话,就需要用到它了。UIView提供了alignmentRectInsetsalignmentRect(forFrame:)这些API以便修改。

调试技巧

AutoresizingMask冲突

最常见的问题,就是在Xcode控制台看到Unable to simultaneously satisfy constraints,然后出现NSAutoresizingMaskLayoutConstraint之类的信息。这种情况原因就是,通过代码创建的UIView时,系统会自动把它默认的autoresizing mask转换成自动布局约束,结果就跟你为视图添加的约束冲突了。所以写布局代码时,一定别忘了加上这句:

view.translatesAutoresizingMaskIntoConstraints = false

(好在MasonrySnapKit这些开源库已经为我们做了这些琐事,真让人省心。)

标记

还有一个技巧就是,给视图和约束添加标记。设置约束的identifier和设置视图的accessibilityIdentifier,就可以在约束日志里看到对应的是哪个视图和哪个约束了,很方便。

可视化工具

开发者可以通过调用hasAmbiguousLayoutexerciseAmbiguityInLayout方法检查视图是否存在约束不明确的bug,甚至可以在调试环境里调用_autolayoutTrace(私有API)来查看视图层级里是否存在Ambiguous Layout,但我基本上没有用过,因为读日志太累。相反,现在倒是有很多可视化工具可以在运行时检查视图层级的布局,相比代码检查方便多了,比如:

  • Xcode自带的Xcode View Debugger
  • 强大的付费调试软件Reveal
  • 腾讯开源的Lookin

视图的生命周期

作者在介绍布局之前,也详细介绍了视图加载和生命周期相关的知识点,我总结一下。

UIViewController如何加载自己的view

懒加载,通常是需要使用到view的时候,UIViewController会调用loadView()方法来加载UIView,loadView()方法会寻找是否存在nib或者storyboard文件并加载,如果没有就创建新的UIView对象。开发者不允许直接调用loadView(),如果需要强制加载view的话,可以调用loadViewIfNeeded(),而且可以通过isViewLoaded来判断view是否加载完成。

loadView是如何寻找nib文件的?

它首先根据vc的nibName属性来作为文件名称去寻找,如果没有给名称的话,就去找与vc相同类名的文件。比如vc的类叫RootViewController,那么loadView就会依次查找RootView.nib和RootViewController.nib,如果有RootViewController~ipad.nibRootViewController~iphone.nib这样的文件,那么loadView也会根据不同设备加载对应的文件。

代码重载loadView

如果不用nib或者storyboard文件来加载view的话,也可以直接重载loadView方法。

class RootViewController: UIViewController { 
    override func loadView() {
        // 不要调super
        let rootView = UIView()
        rootView.backgroundColor = .yellow
        view = rootView
    }
}

手动布局可以在viewDidLoad里实现吗?

严格来说的话,是不可以的。我们首先了解一下这些方法在vc的调用时机:

  • loadView:当需要使用view的时候,如果为nil,vc会调用loadView(懒)加载一个view
  • viewDidLoad:在view加载完成后且没有被加入视图层级时调用,该方法在vc的生命周期中只会调用一次
  • viewWillAppear:在view即将要加入视图层级时调用,该方法在vc的生命周期中会被多次调用,与之相对应的方法是viewWillDisappear
  • viewDidAppear:在view已经加入到视图层级且显示在屏幕上时调用,该方法在vc的生命周期中会被多次调用,与之相对应的方法是viewDidDisappear

因为loadView, viewDidLoad和viewWillAppear调用的时候,vc的view并没有加入到视图层级中,所以view的size没有最终成型,所以手动布局中如果依赖view.bounds或frame不一定是最终的结果;而viewDidAppear已经显示到屏幕上了,此时手动布局时机又太晚。

合适时机在哪里呢?答案是viewWillLayoutSubviewsviewDidLayoutSubviews,因为这是vc的view即将和完成布局subviews的时机。但是这些方法会多次调用,需要注意。

其他附加技能

本书在谈论自动布局的过程中,也结合了其他UIKit的特性,比如:

  • 支持iOS的字体大小设置(Dynamic type)
  • 支持多屏幕适配(UITraitCollection)
  • 响应键盘高度的技巧
  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值