使用 Auto Layout

前言

原本好久没有写过UI了。最近新版本写了一大波UI。可能由于我是从Storyboard开始接触iOS开发的,所以对Auto Layout有着深厚的情感。这次趁着手热写一篇文章,希望能对准备使用和正在使用Auto Layout的各位有所帮助。

1、关于Auto Layout

Auto Layout做了什么

UIView的展示区域是由center和size来决定的。拆开就是4个实数:centerX, centerY, width和height。Auto Layout说白了就是通过给定的 约束 (也就是方程)来求解这4个未知数,本质上是求解线性规划的问题。

例如,我想要声明“这个按钮在那张图片下面3dp处,二者尺寸相同,中央对齐”,这句话可以转换成四句约束:

图片

假如图片的展示区域是已知的,那么显然也可以求出btn的所有布局信息。

众所周知,求四个未知数需要四个独立的方程,也就是说,一般需要四句约束才能唯一固定一个view。当然也有例外,有时你可能只想固定一个textView的上、左、右,来让它随着用户输入向下扩张。或者你只需要指定一个label的中心点在哪里,它内部会自动算出自己的尺寸。这时只要更少的约束就够了。

何时用Auto Layout?听说它的性能很差

遗憾的是,Auto Layout的性能确实不太好。它使用的算法是Cassowary)。

但也不必过分忧虑,以我们的经验来看,除非在性能高度敏感,且频繁发生重新布局的页面——如feed流——以外,用Auto Layout都不会对你的App产生实质性的性能影响,不论是否在iOS12以上。

而另一方面,Auto Layout也有它的优点:更好的可读性和可维护性。想象你需要在一个复杂的用户名片布局中再加一个徽章icon,已有代码中有一坨莫名其妙的逻辑控制左边的爱心icon是否隐藏、vip icon是否隐藏……你可没闲工夫重构这一坨东西。现在要把这个徽章插进去,就得依次判断:前面的东西不隐藏我的frame是什么?隐藏了又是什么?导致写出一堆if-else,代码更加混乱。

再想象如果你要为已有页面适配iPad……更是顶级折磨了。但如果页面原本就是用Auto Layout写的,就可大大改善这些问题。对此,相信你在习惯用Auto Layout布局时就能有所体会。

因此,在性能过剩的环境下,这些微小的性能影响比起代码的可维护性、可读性,是完全可以舍弃的。

就我个人而言,手Q支持最低iOS9,我在除feed页面外都会尽可能使用Auto Layout,除非我觉得使用Auto Layout反而会降低代码的可读性和可维护性。例如产品逻辑要求一个view的size有两个可能值,根据用户操作在这两个size间切换。为了能在其他方法里更新约束,我不得不把它作为一个成员变量保存起来,使我的类看起来更加糟糕。这样还不如手动设置size了。

我的Layout代码(约束声明)应该写在哪里?

一般来讲,哪里创建view就写在哪里即可。比如viewDidLoad里。不过如果你实在想追求更好的效率,且你的约束又频繁更新的话,可以把所有需要更新的约束写在你定义的同一个方法里。由于系统对批量更新的友好,这样可以提高一些效率。

当然,后者牺牲了可读性,我不太建议。如果你的页面性能敏感到使用Auto Layout会影响其流畅性的程度,那你应该放弃Auto Layout,去寻找一个异步排版的方案。

2、Constraints

在这一部分里,我们将开始讨论如何在代码中使用Auto Layout进行布局。

声明约束

前面我们口头使用了“这个按钮在那张图片下面3dp处,二者尺寸相同,中央对齐”这样的描述,现在我们要在代码中把它给声明出来。例如“按钮在那张图片下面3dp处”这句约束,声明它有以下几种办法:

  • 最原始的方法,使用NSLayoutConstraint的类接口,代码是这样的:[NSLayoutConstraint constraintWithItem:button attribute:NSLayoutAttributeLeading relatedBy:NSLayoutRelationEqual toItem:imageView attribute:NSLayoutAttributeTrailing multiplier:1 constant:3]; 这个接口如你所见,又臭又长,现在可以认为狗都不用。
  • 苹果设计了一个VFL(Visual Format Language),通过一串字符串来描述布局,例如: @"V:[imageView]-3-[button]"。VFL旨在简化NSLayoutConstraint复杂的语法,但它的规则似乎更加复杂,而且纯字符串更容易出错。现在除了一些绝活哥,应该没有人会用这种方式。
  • 苹果在iOS9提供了NSLayoutAnchor,语法是这样的:[button.leadingAnchor constraintEqualToAnchor:imageView.trailingAnchor constant:3] 不得不说还是有一点小啰嗦,但起码可以接受了。我推荐使用这种方法。
  • 一些第三方库封装了系统的接口(说的就是Massonry)。虽然接口更加优雅,但毕竟是第三方库。这部分与Auto Layout本身无关,因此只是提一下,不介绍。感兴趣可以自己取舍。
  • 当然还有和Auto Layout本身天作地和的Interface Builder。但这个东西实在无法多人协作,因此只能忍痛当做没有它了。

综上,我准备通过NSLayoutAnchor来添加约束。

使用NSLayoutAnchor

苹果为UIView提供了以下Anchors:

  • X轴方向:leading, trailing, left, right, centerX
  • Y轴方向:top, bottom, centerY, firstBaseline, lastBaseline
  • 其他:width, height

为了介绍这些Anchor,请先观察这行代码:

[button.leftAnchor constraintEqualToAnchor:imageView.rightAnchor constant:20]

Auto Layout接口的自解释性做的很不错,应该可以猜出它代表的方程,即:left_{button} = right_{imageView} + 20leftbutton=rightimageView+20。

但在实际应用中,一般会用leadingAnchortrailingAnchor来代替leftAnchorrightAnchor。它们的区别在于,在一些喜欢从右往左阅读的地区,leadingAnchor实际代表右边,而trailingAnchor代表左边。在我国这种从左往右阅读的国家则可以认为leading==left,trailing==right。比起直接使用left和right,使用leading和trailing可以免去做适配的麻烦。这也是Auto Layout的优势之一。

此外,上面还提到了两个不太直观的Anchor名字:firstBaseline和lastBaseline,这代表多行文字组件第一行和最后一行对应的baseline。不过确实很不常用,如果没太理解,可以暂时无视它们。

再看回上面那行代码。它只是创建了Constraint,但实际上并没有生效。这行代码的返回值是一个NSLayoutConstraint实例,你还需要将它的active字段置为YES,这样它就生效啦。

但要小心,在创建这个约束之前,你至少还要做两件事情:首先要把button和imageView放到一个视图树里。换句话说,就是都要addSubview过。其次要执行这句代码:

button.translatesAutoresizingMaskIntoConstraints = NO;

translatesAutoresizingMaskIntoConstraints是为了兼容前朝遗老Auto Resizing所做出的遗憾逻辑。它会把Auto Resizing中描述的关系转化成Auto Layout中的约束。如果你要自己添加约束,就必须把这个字段设为NO

现在我们应该可以比较完整的写出“这个按钮在那张图片下面3dp处,二者尺寸相同,中央对齐”的代码了:

// 首先要保证它们在同一个视图树里(可以是父子节点,也可以是兄弟节点,也可以更加“远房”,只要有公共祖先),否则会Crash
[contentView addSubview:imageView];
[contentView addSubview:button];

button.translatesAutoresizingMaskIntoConstraints = NO;

// button顶部与imageView底部相距3dp
[button.topAnchor constraintEqualToAnchor:imageView.bottomAnchor constant:3].active = YES;

// 相同宽高,水平方向中央对齐
[button.widthAnchor constraintEqualToAnchor:imageView.widthAnchor].active = YES;
[button.heightAnchor constraintEqualToAnchor:imageView.heightAnchor].active = YES;
[button.centerXAnchor constraintEqualToAnchor:imageView.centerXAnchor].active = YES;

上面声明约束的方法是[Anchor1 constraintEqualToAnchor:Anchor2]。这个接口还有两个可选参数constantmultiplier,后者作用于Anchor2上。即:

[Anchor1 constraintEqualToAnchor:Anchor2 mutiplier:m constant:c]

等价于:

图片

如果你想只用一个常量来约束view,而不涉及其他Anchor也是可以的。例如声明button的宽度宽度为40,只需要使用这个接口:[button.widthAnchor constraintEqualToConstant:40]

此外,NSLayoutAnchor还有另外两种声明约束的方法,即把上面接口中的 Equal 替换为 GreaterThanOrEqualToLessThanOrEqualTo。顾名思义,这是一个“软”的约束,让你的view有一定的灵活空间。例如,你的textView随着用户输入增长,但你不想让它增长到200dp以上,那么你可以作如下声明:

[textView.heightAnchor constraintLessThanOrEqualToConstant:200].active = YES;

只要你时刻意识到:你写的每一句约束都是一个方程(或不等式),那么根本不需要记忆,你很快就可以轻松应用这些接口。

Constraint 的 Priority

想象一个场景:有一个选中框,用户可以拖动来放大和缩小它。这个选中框上有一个imageView来展示背景图,它和选中框有着相同size,也随着用户的操作进行缩放。但背景图的大小有所限制,高度不能超过320dp。也就是说,用户缩放选中框高度在320dp以内时,背景图要和它一样大;一旦超过320dp,它就不再随之增大了。

为了约束它的高度,你很可能做出以下声明(其中expandingViewexpandingView表示选中框,bgViewbgView表示背景图。这不是好的命名,这样做只是为了看起来简洁一些):

图片

显然,在下面这种情况下,两个约束出现了冲突

图片

我应该希望约束②的优先级更高一些,这样在冲突发生时,Auto Layout可以做出合适的取舍,使用②而忽略①。

还记得[bgView.heightAnchor constraintLessThanOrEqualToConstant:320]返回的是一个NSLayoutConstraint实例,它有一个属性priority,就是干这个的。这是一个float值,取值范围在0-1000之间。数字越大,说明优先级越高。苹果已经预定义了一些值,比如UILayoutPriorityRequired就是1000。默认情况下,你添加的所有约束都是UILayoutPriorityRequired的。

因此,你可以通过如下方式,减少约束①的优先级:

NSLayoutConstraint *equalHeightConstraint = [bgView.heightAnchor constraintEqualToAnchor:expandingView.heightAnchor];
equalHeightConstraint.priority = UILayoutPriorityRequired - 1;
equalHeightConstraint.active = YES;

在比较复杂的动态布局中,使用约束往往能帮你更容易理清思路。这样比起手动布局省下了许多代码,也省去了到处回调来更新frame的麻烦。这对代码的整洁和内聚也有益处。

3、StackView

在iOS 9,除了NSLayoutAnchor外,iOS还带来了另一个小礼物:UIStackView。

列表式的布局应该是我们最常用的了。一个一个算它们的frame实在很蠢,用tableView、collectionView很多时候又感觉是叉车开瓶盖,有点太重了。UIStackView就是一个比较好的轻量的列表式布局组件。它内部完全使用Auto Layout布局。据一些比较古老的评测所说,它的性能不算太好。不过那是很久之前的事了。与Auto Layout本身相同,产生性能问题的一定是在多层嵌套且频繁layout的场景,在一般场景下日常使用是不会有问题的。在我使用的过程中,也从来没遇到过因为它产生性能影响的场景。建议大家放心使用。

UIStackView支持水平和竖直的列表布局,通过axis属性来决定。方便起见,在下面的介绍中,我大多使用水平布局(UILayoutConstraintAxisHorizontal)作为例子。

Alignment

现在我已创建了一个水平方向的stackView用于列表式布局,然而列表里的views高度实在不尽相同。就像一群个子参差不齐的木乃伊躺成一排,怎么对齐它们最美观?你可能想让它们按头顶对齐,或者让它们脚底在同一条水平线上,甚至把它们抻一抻压一压,保证每个的高度都一模一样,这样就完全对齐了。这些都可以。alignment字段就是做这个事情的。

alignment属性(UIStackViewAlignment)有以下可选值:

  • 水平方向:top, bottom, firstBaseline, lastBaseline
  • 垂直方向:leading, trailing
  • 通用:center, fill

光看它们的名字,应该足以知道对应的对齐方式了。但保险起见我还是在下面贴出一些示例图。这些实例是我用Storyboard实际操作后截图的,其中绿色为stackView区域,而灰色则是子view。顺便一提,stackView虽然是UIView的子类,但它不会实际渲染——这是它号称比UIView更轻量的地方——因此对它设置backgroundColor并不会生效。如果要像图中这样将背景染成绿色,要么在底下垫一个view,要么用它的layerbackgroundColor

图片

UIStackViewAlignmentTop

图片

UIStackViewAlignmentCenter

图片

UIStackViewAlignmentBottom

图片

UIStackViewAlignmentFill

图片

UIStackViewAlignmentFirstBaseline

图片

UIStackViewAlignmentLastBaseline

其中,fill模式拉伸了子view。而firstBaseline先把最高的view固定在底部,然后再让文本组件的first baseline对齐它的top,最终将其它view的top也对齐。lastBaseline则是相反。

Distribution

在上面我们解决了子view高度的问题,但对宽度我刚刚避而不谈。上面的示例图中,虽然每个子view的高度不同,但它们的宽度都是一样的。这可不是因为我额外加了什么约束,我只是简单的将stackView的distribution属性值置为了fillEqually

顾名思义,这个值会让stackView对所有子view的宽度进行缩放,以保证它们宽度相同且填满stackView。distribution是stackView决策如何轴向排布子view的依据。它的所有取值如下(对于这些取值的说明你可能一时看不明白,不必过分纠结,看下一节就好):

  • fill: 默认行为,它保证子view在轴向(在我们的例子里,水平方向)填满stackView。
  • fillProportionally: 除了填满外,它会尽量保持每个子view的intrictionContentSize[1]。这是一个系统推测出来的size,例如对UILabel,即使你不指定bounds,它也能算出正确的大小。
  • fillEqually: 正如刚才所说,会保证每个子view的宽度或高度相同,取决于axis的值
  • euqalSpacing: 顾名思义,会保证每个子view的间距相同。
  • equalCentering: 类似equalSpacing,不过相同的不是子view的间距,而是每个子view的 中线 之间的距离。

做这些属性的示例图颇为麻烦,所以我直接从这篇文章[2]里截过来了。这篇文章对UIStackView的介绍更加详细,时间充裕的话是值得一看的:

图片
图片

Priority

上面对UIStackViewDistributionFillProportionally的介绍中,我用了“尽量保持”这种模棱两可的说法,这是因为stackView实在无法保证完全按照intrictionContentSize进行布局。

不只是fillProportionally,stackView的每种distribution都会保证子view轴向填满stackView。这样不可避免就会出现子view宽度/高度不够填满或者溢出的情况。本着多退少补的原则,stackView不得不对这些子view进行 拉伸压缩

如果出现这种情况,要对哪个子view下手呢?按照传统,柿子要挑软的捏。UIView有两个属性:contentHuggingPrioritycontentCompressionResistancePriority。正如其名,前者标识着这个view抱紧的程度:它抱得越紧,你越难把它拉伸开;后者则表示view反抗压缩的决心:这个值越高便越难压缩它。这两个prority在X轴和Y轴是相互独立的。它们的取值范围和之前说的NSLayoutConstraintpriority相同。你可以通过UIVew的这两个接口设置它们:

- (void)setContentHuggingPriority:(UILayoutPriority)priority forAxis:(UILayoutConstraintAxis)axis API_AVAILABLE(ios(6.0));

- (void)setContentCompressionResistancePriority:(UILayoutPriority)priority forAxis:(UILayoutConstraintAxis)axis API_AVAILABLE(ios(6.0));

在很多时候,这两个priority是你解决UIStackView排版错乱问题的关键。

使用StackView进行排版

在上面,我们已经填好了stackView的三大属性:axisdistributionalignment。一般你在初始化stackView后,都要指定这三个属性的值。现在我们准备把子view加在stackView里,这很简单,只需使用它的addArrangedSubview:方法或insertArrangedSubview:atIndex:方法。

stackView的规则只会管理通过addArrangedSubview:insertArrangedSubview:atIndex:添加的子view。这两个方法内部会自动调用addSubview:,所以大多数时候,你不需要手动调用UIStackViewaddSubview:方法。

但反过来,UIStackViewremoveArrangedSubview:方法并不会自动调用子view的removeFromSuperview方法。因此你一般不会用到这个方法。如果想移除一个子view,简简单单地调用[theSubview removeFromSuperview]即可。

通过stackView的spacing属性,可以指定子view之间的间距。这个属性有一个默认值,对这个默认值苹果给出如下解释:

System spacing between views depends on the views involved, and may vary across the stack view.

这样的说明实在不是我能看懂的。我偷一下Mattt的梗:除非你家有亲戚在苹果UIKit部门上班,不然就别用这个默认值了。

除了spacingUIStackView还有一个接口setCustomSpacing:afterView:也可以定制子view之间的间距,不过这个接口是iOS 11才有的。

使用StackView的例子

接下来我用一个小例子来展示UIStackView的魅力:

假设你需要设计如下的一个胶囊view:左边有一个icon,右边是一个按钮,中间则是文本框,根据文本的长短,整个view也要随之缩放。如图所示:

图片

空输入时。我得承认,画得不是特别好看

图片

入了一些内容时

图片

输入了大量内容,以致于宽度溢出时。两边的黑色表示手机边框

想象一下使用传统排版会怎么做?恐怕你得监听中间textField的内容变化,然后触发一个可能叫updateWidth之类的方法,在那里计算并更新整个view的宽度。你还得小心判断宽度是否超过了屏幕宽,从而截断输入。

事实上,这还是布局比较简单的情况。如果中间的不是输入框,而是一个自定义的复杂业务view,那么你甚至可能要在那个view里监听内容变化,通过delegate传给外部,从而更新宽度。这对代码的内聚性来说是非常不利的。

然而使用了StackView/Auto Layout,只需在创建view时额外添加几行代码,就可以做到这一切。具体的步骤如下:

  • 创建stackView,将它的的distribution设置为fillProportionally,并将三个子view通过addArrangedSubview:方法添加上去。
  • 约束stackView的宽度不大于(LessOrEqualThan)屏幕宽度。
  • 最后的点睛之笔,调低textField的contentCompressionReistancePriority,保证在宽度溢出时,优先委屈它的宽度,而不是压缩左右两个view的宽度。

仅仅通过这些简单的处理,我们便轻松完成了传统排版中需要不少功夫才能做到的事情。这不仅节省了你的工作量,更是提升了代码的可读性和内聚性。

4、常见问题

为什么我的Constraint没有生效?

嗯,最有可能的是你忘记设置translatesAutoresizingMaskIntoConstraintsNO了。这事经常发生,习惯就好。

如果不是这个问题,你可能得借助Xcode的“Debug View Hierarchy”功能,在左边的Debug Navigator中查看出问题的view的所有约束,看看是不是少加或者冲突了。一般如果出现了冲突,会有一个紫色的感叹号提醒你。在这种情况下,你可以移除不必要的约束。如果不能移除,可以试试用priority来整理一下。

使用Auto Layout怎么做动画?

这个场景我遇到的其实并不多。不过这当然是可以做到的。例如你要让一个view的高度动态缩放到0,可以试试如下代码:

// 这里的 viewHeightConstraint 是你通过 view.heightAnchor 生成的NSLayoutConstraint实例
viewHeightConstraint.constant = 0;
[UIView animateWithDuration:0.25 animations:^{
    [view layoutIfNeeded];
}];

为什么StackView瞎他妈的缩放我的view?

这种情况,首先你要确认distributionalignment的选择是正确的,且子view上的约束确实如你预期。如果你的alignment不是fill,那么你可能需要添加径向的约束,或者给该方向的尺寸一个初始值。否则这个方向的尺寸会被置为默认的0,导致它不会展示。

在轴向上,不论你使用哪种distribution,stackView都会保证子view填满自己。因此,如果你的stackView比实际需要的更长,那你要么把它缩放到适合的尺寸,要么添加一个contentHuggingPriority较低的占位view做挡箭牌,任stackView拉伸——如果你听说过UILayoutGuide,你会发现这件事情听起来很像是它该做的事。不过很遗憾,UILayoutGuide无法和UIStackView默契配合,你只能使用占位view这种原始的方法。

最后,如果约束没有问题,那调整priority一定能解决问题。stackView内部添加的约束优先级为1000,小心你的约束别和它冲突。另外,调整子view的contentHuggingPrioritycontentCompressionResistancePriority来保证它们按照预期缩放。

总结

我在前面多次提到了“内聚性”和“可读性”,我认为这是Auto Layout最大的优势。虽然在初期调试布局错乱的问题会有点pain in the ass,但当习惯之后,你会经常感受到它带给你的惊喜。因此,我推荐大家尝试Auto Layout和UIStackView。希望能够对大家有所帮助。

你可能感兴趣的:(使用 Auto Layout)