我要提问
ARTICLE DETAIL

资讯详情

前沿编程新知与开发实战干货的深度解读。

Swift 5 UIStackView教程:告别Auto Layout手写约束的优雅布局方案

Swift 5 UIStackView教程:告别Auto Layout手写约束的优雅布局方案 1. 从布局代码的“泥土味”聊起做iOS开发的人应该都经历过这种场景屏幕上要放一个登录表单、几个标签、或者一排按钮于是你在ViewDidLoad里写十来行frame计算或者用Auto Layout一条一条地添加约束。代码一多谁都头大特别是当界面需要增减元素或调整间距时那些约束就像一团乱麻牵一发而动全身。UIStackView就是来帮你解决这个问题的——它把“排列子视图并管理间距、对齐、分布”这类重复劳动彻底封装起来让你只用几行代码就能搭出整齐、灵活、自适应屏幕的界面。这篇教程就从StackView的核心概念讲起带你在Swift 5的环境下把这个控件彻底弄明白。这篇文章适合谁刚接触iOS布局、被Auto Layout虐过的初学者已经会用Storyboard但想用纯代码搭建界面的中级开发者还有那些想优化布局代码、降低维护成本的老手。UIStackView并不是什么高深技术但它的设计思路非常符合“组合优于继承”的原则——把繁杂的约束细节收敛到一个容器内部可谓小控件解决大问题。2. 明确需求为什么UIStackView能让你告别手写约束先想一个最简单的场景三个等宽的按钮水平排列按钮间距8个点按钮与父视图的左右边距各16个点。用Auto Layout写你需要6个横向约束加3个宽度约束如果还要处理高度、对齐、优先级报表会越来越长。如果用UIStackView你只需要把三个按钮扔进一个StackView设置axis为.horizontal、spacing为8再给StackView加四条贴边的约束完事。按钮的宽度只要给它们设置等宽约束或者让StackView自己按比例分配。这就是UIStackView存在的意义它把“排列”这件逻辑从约束代码里剥离出来变成一种声明式的容器语义。从系统层面看UIStackView并不是一个真正参与渲染的视图它继承自UIView但在内部它只负责布局子视图并不绘制任何内容。这意味着你完全不用担心它带来额外的绘制性能开销。它可以管理Vertical、Horizontal两种排列方向还能处理子视图的伸缩、对齐、分布策略可以说一个StackView就是一个小型布局引擎。我见过很多人一开始把StackView等同于“分组容器”其实它和普通的UIView容器最大的区别在于普通UIView的子视图位置和大小完全由你的约束决定而StackView的子视图位置和大小主要由排列规则决定你只需要关心“内容本身”的约束。这条思路一旦转变你的布局代码会立刻清爽很多。另外要提一下StackView是从iOS 9开始引入的系统版本完全不需要担心。iOS 9及以上包括iOS 14、15、16、17StackView都没有兼容性问题。而且Swift 5与iOS 12以后的所有系统都兼容现在写项目基本都支持。所以看完这篇教程你可以在任何新项目里直接上手。3. 核心属性拆解熟练掌握Axis、Alignment和DistributionUIStackView拥有几个核心属性理解它们是灵活使用的关键。这里我会把每一个属性都掰开揉碎并结合实际效果来说明。3.1 Axis确定你的布局方向axis属性决定StackView的主轴方向只有两个取值.horizontal水平排列和.vertical垂直排列。这很好理解horizontal就是从左到右放子视图vertical是从上到下放。但有一个细节值得注意你设置的是“主轴”方向而子视图在“交叉轴”方向上的对齐则由alignment属性控制。举个例子如果你设置axis为.horizontal那么子视图在竖直方向上的对齐由alignment管理如果你设置axis为.vertical那么子视图在水平方向上的对齐由alignment管理。这条对应关系一旦搞混后面改约束会莫名其妙。3.2 Alignment交叉轴方向的对齐策略alignment作用于交叉轴它决定所有子视图在交叉轴方向的尺寸与位置。系统提供了八种取值但常用的是这几个.fill子视图沿交叉轴方向被拉伸填充以填满StackView的交叉轴尺寸。比如水平StackView的高度如果等于200子视图的高也会被拉伸到200前提是子视图愿意被拉伸。.leading / .trailing / .top / .bottom让子视图在交叉轴方向上靠某一边对齐子视图的尺寸保持固有内容尺寸。.center子视图在交叉轴方向居中尺寸不变。这里的关键点是“子视图的尺寸如何被决定”。当alignment为.fill时StackView会对子视图的交叉轴方向增加一个“填充优先”的约束导致子视图的固有尺寸可能被拉伸当alignment为非fill值时交叉轴方向的尺寸由子视图自己的内容或约束决定。所以如果你发现一个Label的高度被莫名其妙拉长了第一反应就该检查alignment是不是.fill。3.3 Distribution主轴方向的分布策略distribution是StackView里最“魔法”的属性它控制子视图在主轴方向上的填充与间距策略。常用的有五种.fill按子视图的固有内容尺寸排列如果空间有富余会拉伸某个子视图去填充具体拉伸优先级由hugging/compression优先级决定。如果空间不足会压缩某些子视图。.fillEqually所有子视图在主轴方向等宽或等高不管内容尺寸完全均分。.fillProportionally按照子视图内容比例的相对大小分配空间内容长的视图占用更多空间。.equalSpacing子视图保持各自内容尺寸但会均匀分配它们之间的间距让堆栈总体均匀分布。.equalCentering所有子视图的中心点间距相等边缘间距不一定相等。这五种策略有鲜明的使用场景。比如底栏按钮组需要三个等宽按钮直接用.fillEqually如果要做一个标签页效果希望每个标签宽度自适应内容但间距相等用.equalSpacing最舒服如果想让两个不同长度的文本标签按照内容比例分配宽度那就选.fillProportionally。我再强调一个容易踩坑的点.fillEqually并不是设置每个子视图的宽度相等约束而是StackView内部为所有子视图添加等宽约束并把它们压缩到相等的宽度。如果你在子视图内部又添加了固定宽度约束并且该约束优先级高于压缩优先级那么布局就可能会起冲突甚至导致约束警告。所以要学会控制优先级。3.4 Spacing、LayoutMargins与自定义间距spacing属性就是主轴方向相邻子视图的间距默认值是0。iOS 11以后还支持一个setCustomSpacing(_:after:)方法可以单独设置某个子视图后面的间距。这个方法非常实用比如最后一个按钮与底部之间想多留一些空白你完全不用包一层空视图直接调用stackView.setCustomSpacing(32, after: lastView)就行。layoutMargins则是StackView内容与边框之间的内边距。默认情况下StackView的margins是zero很多人发现子视图贴合了边界就是因为没有设置margins。你可以通过stackView.layoutMargins UIEdgeInsets(top: 16, left: 16, bottom: 16, right: 16)以及isLayoutMarginsRelativeArrangement true来开启相对边距排列。注意isLayoutMarginsRelativeArrangement这个开关如果不打开设置layoutMargins是无效的。这是很多人容易忽略的细节。4. 实操用Swift 5写一个自适应表单控件理论知识再多不实践都是空谈。接下来我带着你用UIKit UIStackView写一个完整的登录表单界面它包含一个图标、一个用户名输入框、一个密码输入框、一个登录按钮以及一个提示Label。所有元素垂直排列并且在不同屏幕尺寸下都能自动适应。4.1 创建项目与基础界面先新建一个iOS App项目Swift 5。在ViewController里我们把UIStackView作为根容器然后添加各个子视图。我习惯用纯代码布局这样所有UI都在一个文件里清晰可见也方便复用。以下是核心代码import UIKit final class ViewController: UIViewController { private let stackView: UIStackView { let stack UIStackView() stack.axis .vertical stack.alignment .fill stack.distribution .fill stack.spacing 16 stack.translatesAutoresizingMaskIntoConstraints false return stack }() private let iconImageView: UIImageView { let imageView UIImageView() imageView.image UIImage(systemName: person.crop.circle) imageView.contentMode .scaleAspectFit imageView.translatesAutoresizingMaskIntoConstraints false return imageView }() private let usernameField: UITextField { let field UITextField() field.placeholder 用户名 field.borderStyle .roundedRect field.translatesAutoresizingMaskIntoConstraints false return field }() private let passwordField: UITextField { let field UITextField() field.placeholder 密码 field.isSecureTextEntry true field.borderStyle .roundedRect field.translatesAutoresizingMaskIntoConstraints false return field }() private let loginButton: UIButton { let button UIButton(type: .system) button.setTitle(登录, for: .normal) button.backgroundColor UIColor.systemBlue button.setTitleColor(.white, for: .normal) button.layer.cornerRadius 8 button.translatesAutoresizingMaskIntoConstraints false return button }() private let tipLabel: UILabel { let label UILabel() label.text 请输入正确的用户名和密码 label.textAlignment .center label.textColor .secondaryLabel label.font .systemFont(ofSize: 14) label.translatesAutoresizingMaskIntoConstraints false return label }() override func viewDidLoad() { super.viewDidLoad() setupLayout() } private func setupLayout() { stackView.addArrangedSubview(iconImageView) stackView.addArrangedSubview(usernameField) stackView.addArrangedSubview(passwordField) stackView.addArrangedSubview(loginButton) stackView.addArrangedSubview(tipLabel) view.addSubview(stackView) NSLayoutConstraint.activate([ stackView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 32), stackView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 32), stackView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -32), // 底部不设固定约束让它自适应内容高度 ]) iconImageView.heightAnchor.constraint(equalToConstant: 80).isActive true loginButton.heightAnchor.constraint(equalToConstant: 48).isActive true } }这段代码非常直白。注意我故意没有给stackView设置底部约束这样StackView的高度就由内容决定。由于alignment为.fill子视图在水平方向会被拉伸到与StackView等宽——这正是我们希望的效果输入框和按钮都能撑满内容区域。iconImageView和loginButton分别设置了固定高度其他控件的高度由自身内容决定最终StackView的高度自动适配。有人会问这里translatesAutoresizingMaskIntoConstraints false是不是每个视图都要设置是的只要你要用Auto Layout约束控制UIView都必须关闭自动转换。即使这个视图被放进StackViewStackView内部约束同样依赖它。忘记这一行会导致约束冲突而且是那种“莫名其妙”的崩溃排查起来很头痛。4.2 灵活调整让表单自适应屏幕宽度上面的表单在竖屏上显示完美但如果设备旋转到横屏StackView宽度变大整个表单被拉伸得过于宽反而不好看。这时候可以让容器的宽度有一个最大值比如360。处理方法有很多最简单的做法是设置stackView的宽度约束小于等于360同时让它水平居中。我一般再包一层外层视图或者直接用约束优先级来做。示例NSLayoutConstraint.activate([ stackView.centerXAnchor.constraint(equalTo: view.centerXAnchor), stackView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 32), stackView.widthAnchor.constraint(lessThanOrEqualToConstant: 360), stackView.leadingAnchor.constraint(greaterThanOrEqualTo: view.leadingAnchor, constant: 32), stackView.trailingAnchor.constraint(lessThanOrEqualTo: view.trailingAnchor, constant: -32), ])这里用leading大于等于32、trailing小于等于-32来限定最小边距同时用width小于等于360限制最大宽度再配合centerX约束让它在屏幕内居中。这套约束组合在Auto Layout里属于很经典的“紧凑型容器”模式。StackView的高度仍然由内容决定。这样横竖屏旋转时表单始终保持在屏幕中央且宽度不超过360像素。4.3 间距微调setCustomSpacing的应用继续刚才的登录界面假设我希望图标与输入框之间间距24而其他元素之间都是16。如果只用spacing只能把所有间距统一设置。好在setCustomSpacing可以单独调整。我们在setupLayout里加入一行stackView.setCustomSpacing(24, after: iconImageView)这行代码需要放在addArrangedSubview之后调用才可靠。Swift 5的UIKit对它的支持很完善你也可以在界面布局完成后动态调整。要注意的是setCustomSpacing的“after”是指这个子视图后面那条间距不是前面。如果你想要图标前边有更大间距应该调用setCustomSpacing(after:上一个视图)或者在图标前面插入一个自定义的Spacer视图。用空视图做Spacer这也是老手们的常用技巧。另外iOS 15以后还支持对每个spacing单独指定比如spacing可以是一个NSDirectionalEdgeInsets或不同维度的间距值不过那属于新特性目前大多数项目还停留在基础API我们这里不多展开。5. 进阶玩法嵌套StackView构建复杂布局实际App里的界面很少是一条直线排到底。常见的是卡片内既有水平又有垂直混合布局这时候嵌套使用UIStackView就是最佳实践。比如做一个个人信息卡片左边是头像右边是名字和简介简介下面还有一行“关注”按钮。这属于典型的水平垂直混合。5.1 嵌套结构的设计思路我们先定义一个水平StackView左侧放头像右侧放一个垂直StackView。右垂直StackView里再放名字Label、简介Label、按钮水平StackView。这个按钮水平StackView内部有两个按钮。结构如下HStack(整体) ├── 头像ImageView (宽度高度固定) └── VStack(右侧) ├── 名字Label ├── 简介Label └── HStack(按钮组) ├── 关注按钮 └── 私信按钮用代码实现时每个StackView都要设置为translatesAutoresizingMaskIntoConstraints false并且通过addArrangedSubview添加子视图。嵌套的好处是每一层内部相对独立修改内层排列方式不会影响外层。这种组合思路非常像Flexbox如果你以前做过Web布局理解起来会更顺手。5.2 代码实现与细节处理private func setupCard() { let avatarImageView UIImageView(image: UIImage(systemName: person.fill)) avatarImageView.contentMode .scaleAspectFit avatarImageView.backgroundColor .systemGray5 avatarImageView.layer.cornerRadius 30 avatarImageView.clipsToBounds true avatarImageView.translatesAutoresizingMaskIntoConstraints false NSLayoutConstraint.activate([ avatarImageView.widthAnchor.constraint(equalToConstant: 60), avatarImageView.heightAnchor.constraint(equalToConstant: 60) ]) let nameLabel UILabel() nameLabel.text 某开发者 nameLabel.font .boldSystemFont(ofSize: 18) let bioLabel UILabel() bioLabel.text 擅长Swift与iOS架构喜欢折腾自动化布局。 bioLabel.numberOfLines 0 bioLabel.font .systemFont(ofSize: 14) bioLabel.textColor .secondaryLabel let followButton UIButton(type: .system) followButton.setTitle(关注, for: .normal) let chatButton UIButton(type: .system) chatButton.setTitle(私信, for: .normal) let buttonStack UIStackView(arrangedSubviews: [followButton, chatButton]) buttonStack.axis .horizontal buttonStack.spacing 12 buttonStack.alignment .leading let rightStack UIStackView(arrangedSubviews: [nameLabel, bioLabel, buttonStack]) rightStack.axis .vertical rightStack.spacing 6 rightStack.alignment .leading let cardStack UIStackView(arrangedSubviews: [avatarImageView, rightStack]) cardStack.axis .horizontal cardStack.spacing 16 cardStack.alignment .top cardStack.translatesAutoresizingMaskIntoConstraints false view.addSubview(cardStack) NSLayoutConstraint.activate([ cardStack.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 20), cardStack.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20), cardStack.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20) ]) }几个细节值得强调。第一avatarImageView我设置了固定width和height这样StackView就不会尝试拉伸它保持头像成正方形。第二rightStack的alignment设置为.leading这表示子视图在水平方向靠左对齐而不会填充整个宽度。bioLabel的numberOfLines 0是为了让它可以换行否则长文本会被截断。第三cardStack的alignment设置为.top这样当bioLabel是多行、右侧整体高度大于头像时头像会保持在顶部不会居中或拉伸。如果希望头像垂直居中可以把alignment改成.center。这些align的选择会直接决定卡片观感建议你调试时多切换试试很快能建立手感。6. 常见的坑与排查技巧UIStackView虽然好用但踩坑也不少。我把实际开发中遇到过的典型问题整理成一张表格你可能也会碰到。现象原因解决方案子视图被拉伸变形alignment为.fill导致交叉轴被拉伸修改alignment为.leading/.center或给子视图添加固定尺寸约束子视图没有出现在期望位置忘记关闭translatesAutoresizingMaskIntoConstraints每个视图都显式设置为falseStackView内部约束冲突子视图固定宽度/高度与distribution策略冲突调整优先级或避免同时使用固定尺寸与fillEqually调用setCustomSpacing无效未在addArrangedSubview之后调用或使用了未添加的子视图确保子视图已在StackView中再设置调用顺序StackView宽度不随内容变化没有为StackView设置限制或没有关闭自动转换根据目的设置约束或使用intrinsicContentSize列表页使用StackView卡顿在TableViewCell里重度使用多个StackView嵌套避免深层嵌套改用更轻量的约束或纯布局其中“子视图被拉伸”是我见过最多的问题。举个例子你在水平StackView里放了一个UIImageView没有给它固定宽高alignment又是.fillStackView中的交叉轴竖直方向会被拉伸图片看起来就是扁的。解决方法很简单给imageView加一个宽高比约束或者固定width和heightStackView就不会强制拉伸它。实际上StackView遵循的规则是能拉伸就拉伸子视图的约束优先级默认是999低于内部拉伸约束的1000所以才会被拉伸。理解了优先级机制后你就能精准修复这类问题。还有一种诡异情况是StackView在TableViewCell里复用时会闪烁。这通常是因为在prepareForReuse中修改子视图或约束太频繁。我的建议是尽量避免在cell里写复杂的动态增删StackView子视图使用固定结构或更新内容而不是增删子视图如果需要动态增减可以做子视图的isHidden切换而不是removeArrangedSubview。隐藏子视图不会破坏StackView的约束网络也不会导致动画卡顿这是性能上的关键技巧。7. 深入原理理解StackView的hidden与约束优先级机制UIStackView一个鲜为人知的特性是当某个arrangedSubview的isHidden设置为true时StackView会自动把它从布局中移除同时保持隐藏状态。这听起来很简单但应用场景超乎你想象。比如做一个“展开更多”功能你只需要切换一个子视图的isHiddenStackView就会自动将下方内容上移并伴随动画无需手写一个个约束的constant变化。这是StackView和普通UIView容器最大的区别之一。普通UIView隐藏子视图只是看不见位置还是会占据但StackView隐藏arrangedSubview它会彻底从布局空间里消失。这种机制非常适合做条件视图的展示和收起。背后的原理是StackView维护了一个arrangedSubviews数组它记录了参与排列的视图顺序。当你把一个视图从arrangedSubviews中移除removeArrangedSubview它只是从排列名单里消失但该视图仍然是StackView的子视图所以如果你不额外给它加约束它可能会回到默认位置或者被压缩。如果你想彻底移除一个视图需要同时调用removeArrangedSubview和removeFromSuperview。这一个坑很经典不搞清楚会让人怀疑人生。再说优先级。StackView内部会为每个arrangedSubview生成一组约束包括主轴对齐、尺寸、间距等。这些约束的默认优先级通常是999或1000而子视图自身的抗压缩优先级compression resistance和抗拉伸优先级hugging默认是750或250。这就是为什么StackView能按自己的策略改变子视图尺寸——因为它的约束优先级更高。如果你想某个子视图保持固定大小就需要把自己的固定宽高约束优先级提高到750以上或者直接把hugging/compression设为required1000。但注意不要随意把全部优先级都设为1000否则可能跟StackView的分配逻辑冲突造成布局无法满足约束而崩掉。最好的办法是只对真正需要固定的视图设置required优先级其他保持默认。8. 工具选型与代码管理心得抛开纯代码许多人也会在Storyboard或XIB里使用UIStackView。这两种方式本质上一致只是管理方式不同。我个人的经验是对于复杂界面Storyboard里的StackView嵌套非常难维护拖来拖去很容易乱尤其是团队成员协作时合并不好还容易冲突。而纯代码创建的StackView结构清晰用View层级一目了然修改也方便配合懒加载属性更是如虎添翼。所以我更推荐在代码里用UIStackView不过如果你是Storyboard高手那也没问题关键还是熟练。如果你在项目中频繁使用StackView不妨封装一个factory方法简化创建extension UIStackView { convenience init(axis: NSLayoutConstraint.Axis, spacing: CGFloat 0, alignment: UIStackView.Alignment .fill, distribution: UIStackView.Distribution .fill) { self.init() self.axis axis self.spacing spacing self.alignment alignment self.distribution distribution self.translatesAutoresizingMaskIntoConstraints false } }这样创建StackView时就可以写let stack UIStackView(axis: .vertical, spacing: 12, alignment: .center, distribution: .fill)代码简洁不少。另外在清理子视图的时候要注意一个常见陷阱直接调用removeFromSuperview并不会同时从arrangedSubviews中移除。正确姿势是func removeAllArrangedSubviews() { arrangedSubviews.forEach { subview in stackView.removeArrangedSubview(subview) subview.removeFromSuperview() } }许多博主写过这个工具方法但你真正在项目里遇到时才意识到它有多救急。比如动态表单切换配置项时清理StackView的旧内容如果漏掉removeFromSuperview视图还会残余在界面上排查半天都找不到问题。9. 最后一个实用技巧让StackView陪你应对动态数据对于动态数据驱动的界面比如消息列表、订单详情我用StackView搭配懒加载或配置方法可以迅速生成整块UI。一个比较经典的实践是每当数据模型变化就清空StackView的所有子视图然后根据模型重新添加对应控件。这个模式非常直观虽然每次会重建UI但StackView的布局开销很低只要不在cell滚动的热路径上频繁调用完全没问题。更进阶一点你可以用StackView实现简单的表单状态切换。比如提交按钮上面要显示加载指示器或错误信息你只需要在StackView里插入或删除一个子视图系统的约束会自动更新。如果想让这个过程平滑把布局变化包在UIView.animate里即可例如UIView.animate(withDuration: 0.25) { self.stackView.addArrangedSubview(errorLabel) self.stackView.layoutIfNeeded() }这种写法可以让新增子视图时StackView的高度变化有过渡动画比单纯约束更新优雅得多。我做过的项目里凡是用StackView管理区块化布局后期调整间距和顺序都相当省心。比如运营临时需要在按钮上方加一个提示横幅只需要在arrangedSubviews数组的相应位置插入一个新视图其余全部自动调整。这种体验用纯约束几乎难以想象。如果你现在还在手动写约束不妨把部分高频复用区域改用StackView坚持一两个月你会回来感谢这个控件的。所以Swift 5的UIStackView绝不只是“摆放控件的基础容器”它更是一种布局思维方式让声明式的排列规则替你管理约束把精力聚焦在内容本身。从这个角度去理解和实践你在iOS布局上会迈上一个新的台阶。好了布局代码该重构了。
返回列表