本节摘要:写死坐标的界面换一台设备就散架。Auto Layout 不存坐标,存"关系":某个视图与谁对齐、宽多少、离谁多远,系统解方程算出唯一解。本节讲约束的代数本质、锚点 API 的四类常用关系、固有内容尺寸与优先级,四种布局工具(Storyboard、XIB、纯代码、栈视图)的取舍,最后实战"约束冲突"的排错——这是 iOS 新手翻车率最高的一个坑。
先看反例。把标签钉在写死的坐标上:
// 反例:frame 写死(屏幕宽 393 的机型上勉强能看) nameLabel.frame = CGRect(x: 160, y: 400, width: 80, height: 30) // 换到小屏机型:标签挤到屏幕外;开大字号:文字被截断;转横屏:彻底失踪
写死坐标的本质问题是它只对一个屏幕尺寸成立。Auto Layout 换思路:你不告诉系统"在哪",而是描述"和什么保持什么关系",由它在运行时按真实屏幕解出位置。一条约束就是一个等式:
// 锚点 API:每行是一条"关系语句",翻译在注释里 NSLayoutConstraint.activate([ nameLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor), // nameLabel.水平中心 == 父视图.水平中心 (居中) nameLabel.topAnchor.constraint(equalTo: photo.bottomAnchor, constant: 12), // nameLabel.顶 == photo.底 + 12 (照片下方留 12 点间距) nameLabel.widthAnchor.constraint(lessThanOrEqualToConstant: 300), // nameLabel.宽 ≤ 300 (不等式约束也允许,配优先级用) waterButton.widthAnchor.constraint(equalToConstant: 120), // waterButton.宽 == 120 (定宽:自身足够,不需要别的变量) ])
视图自身有四组锚点(上下左右边、横竖中心、宽高、前缘后缘),几乎能表达所有常见关系。判定一条约束"够不够"的标准只有一条:对每个视图,水平和垂直两个方向各自的位置与大小都要能唯一确定——缺了叫欠定(界面位置不确定,系统自动补约束并告警),多了打架叫过定(冲突,控制台刷屏)。

UILabel、UIButton 这类控件有固有内容尺寸(intrinsic content size):文字多长它想多宽,不指定宽高约束时系统按内容取尺寸。这带来一个高频技巧——贴边内边距:
// 经典组合:标签贴住父视图边缘,留 16 点边距,垂直居中 NSLayoutConstraint.activate([ statusLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16), statusLabel.centerYAnchor.constraint(equalTo: view.centerYAnchor), // 没写宽高:标签按文字内容自己撑开(固有内容尺寸在工作) ]) // 约束优先级:需要"可拉伸但别太宽"时,用不等式 + 优先级 statusLabel.widthAnchor.constraint(lessThanOrEqualToConstant: 300).isActive = true let hug = statusLabel.contentHuggingPriority(for: .horizontal) print("水平抱紧优先级 \(hug.rawValue)") // 251(比默认约束 1000 低,可被拉伸规则压过)
拥抱优先级(内容抱紧自己)与抗压优先级(被压缩的抵抗力)是固有尺寸的两道闸门:两个标签同排、空间不够时,抗压低的先被截断。知道这两个词,搜索排错方案时才搜得中。
| 工具 | 上手 | 团队协作 | 适合 |
|---|---|---|---|
| Storyboard | 可视化拖拽快 | 大文件合并易冲突 | 小项目原型、页面流可视化 |
| XIB 单视图 | 同上,粒度小 | 冲突面小 | 独立复用的自定义单元 |
| 纯代码锚点 | 全键盘流、改动可回溯 | 文本 diff 友好 | 本教程主线、逻辑复杂页 |
| UIStackView | 几乎不写约束 | 高 | 成排成列的表单与工具栏 |
本教程主线选纯代码:约束写出来看得见、版本管理看得见diff、排错看得见数字——学习期没有比这更好的选择。栈视图值得单独给一段代码,它是"约束的打包批发":
// 栈视图:一行代码管一组排列,间距对齐全包 let row = UIStackView(arrangedSubviews: [nameLabel, reminderSwitch]) row.axis = .horizontal // 水平排 row.spacing = 12 // 间距 12 row.alignment = .center // 垂直居中对齐 row.distribution = .fill // 分配方式:按内容填充 row.translatesAutoresizingMaskIntoConstraints = false view.addSubview(row) // 之后只需约束 row 自己——它替你管孩子(水平方向自动排开) NSLayoutConstraint.activate([ row.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16), row.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16), row.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 12), row.heightAnchor.constraint(equalToConstant: 44) ])
背景:给"浇水按钮"同时加了三条约束后,运行时控制台刷出 Unable to simultaneously satisfy constraints,按钮宽度表现诡异。操作:重现约束组,读日志。
NSLayoutConstraint.activate([ waterButton.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16), waterButton.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16), waterButton.widthAnchor.constraint(equalToConstant: 120), // 冲突元凶 waterButton.topAnchor.constraint(equalTo: nameLabel.bottomAnchor, constant: 16) ]) // 控制台输出(节选): // Unable to simultaneously satisfy constraints. ... // "<NSLayoutConstraint:0x... UIButton.width == 120>" // "<NSLayoutConstraint:0x... UIButton.leading == UIView.leading + 16" // "<NSLayoutConstraint:0x... UIButton.trailing == UIView.trailing - 16" // ... Will attempt to recover by breaking constraint: width == 120
结果:系统放弃定宽约束,按钮被拉到全宽减两侧 16。解读:日志直接列出打架的三方——左右贴边已经隐含"宽 = 屏宽 - 32",再叠加定宽 120 就是方程无解;系统按优先级(全部 1000)随机挑一条打断,界面"看起来还行"纯属运气。修复二选一:删掉定宽(要全宽),或删掉右贴边(要定宽居中)。变式:对"通常成立但极端屏幕想让路"的约束,把优先级设 750(required 之下),冲突时它自动让位而不是崩日志——比如长文案的宽度约束设 750,小屏自动截断。
⚠️ 常见坑:忘了设 translatesAutoresizingMaskIntoConstraints = false 就加约束,系统仍按老式 frame 自动布局翻译,出现"约束全对但位置全错"的灵异现场。纯代码建视图的第一行永远先关它。
单页会排了,多页怎么连成园——下一节立主干:导航。