如果你已经厌倦了在 Storyboard 里拖线、牵约束、找 IBOutlet,那这篇内容应该能省下你不少时间。用 Swift 在 Xcode 里写视图控制器,核心就是两件事:在代码里创建控件,以及在不同控制器之间切换。今天要拆解的正是把 UILabel、UIButton 用纯代码加到屏幕上,然后点击按钮,以模态方式弹出另一个视图控制器。
这个场景非常典型。你不需要复杂的架构,不需要第三方库,只要掌握 UIKit 里几个基础类:UIViewController、UILabel、UIButton、UIScrollView(暂时不用),以及 present 和 dismiss 这一对模态切换方法。适合刚接触 Swift 的初学者,也适合已经用 Storyboard 但想转入纯代码开发的开发者。
我个人的习惯是:所有能代码化的东西尽量代码化。原因不是 Storyboard 不好,而是当页面多了以后,文件之间跳来跳去太累。今天这篇文章就把整个流程拆开讲清楚,包括控件怎么创建、约束怎么加、模态页面怎么弹,以及我实际踩过的几个坑。
1. 为什么我推荐在视图控制器里用纯代码搭控件
用代码写界面,最开始会有点反直觉。尤其是你已经习惯了拖拽 UIButton、按住 Control 拖到代码里生成 IBAction,乍一看代码方式会觉得麻烦。但等你写过两三个页面,就会体会到它的爽点:页面结构一目了然,控件配置集中在一处,改一个颜色不用去两个地方找。
纯代码另一个好处是符合"可复用"的思路。比如你做一个自定义弹窗,里面有一个标题 Label、一个确认按钮、一个取消按钮。如果放在 Storyboard 里,你想复制到另一个项目,得把整个 xib 或 storyboard 文件搬过去;用代码写,直接复制一个类文件,微调一下样式就能用。
还有一个很实际的问题:多人协作时,Storyboard 文件特别容易冲突。两个人同时改同一个控制器,哪怕只是挪了一个按钮位置,git 合并时都可能出现一串看不懂的 diff。纯代码文件冲突后还能手动处理,Storyboard 的 XML 冲突处理起来相当头疼。这一点在团队项目里尤其明显。
1.1 纯代码创建控件最适合这几类项目
我帮朋友看过不少项目,总结下来,纯代码方式在下面几种场景里优势最大:
- 列表页或者动态页面:cell 数量不定、内容由接口决定,这时你需要在循环里创建大量 UILabel、UIButton,Storyboard 根本帮不上忙。
- 弹窗和浮层组件:一个弹窗可能在不同控制器里被复用,用代码封装成独立类最方便。
- 团队协作紧密的项目:避免 Storyboard 的 XML 冲突,代码 review 也更容易。
- 需要做自动化测试或者 UI 快照测试的项目:纯代码布局便于在测试里直接改 frame 或者约束,定位问题也更快。
如果只是一个静态展示页,比如隐私政策页、固定格式的欢迎页,用 Storyboard 也不算错。只是我自己现在更倾向代码,因为维护成本低。
1.2 代码 UI 与 Storyboard 的取舍对比
我整理了一个简单的对比,你可以按项目情况取舍:
| 维度 | 纯代码 | Storyboard / XIB |
|---|---|---|
| 上手门槛 | 稍高,需要记住 API | 较低,所见即所得 |
| 可读性 | 集中在类文件里 | 分散在 UI 文件和代码之间 |
| 多人协作 | 冲突少 | 容易产生 XML 冲突 |
| 动态内容 | 非常方便 | 很难动态生成 |
| 界面预览 | 需要真机/模拟器 | 有 IBDesignable、实时预览 |
| 版本迭代 | diff 清晰 | 改动难追溯 |
| 组件复用 | 一个类搞定 | 需要 xib/storyboard 引用 |
这套对比没有绝对答案。我的观点是:如果你的团队已经深度使用 Storyboard,也没必要强行推翻,只要新增页面统一走代码就可以。代码和 Interface Builder 混用的情况我也遇到过,完全可行,只要别在同一个控制器里既做一半代码 UI 又做一半 xib 上的 UI。
2. 纯代码视图控制器的初始化与控件创建
要往视图控制器上添加代码控件,第一步是确保整个 App 的生命周期能正确加载这个控制器。如果你的项目是新建的 Xcode UIKit App,默认模板可能还带着 Main.storyboard,这时直接在 ViewController 里覆盖 viewDidLoad 写代码就能看到效果。
但如果你想完全脱离 storyboard,就得从 App 启动入口开始接管。iOS 13 以后的项目一般使用 SceneDelegate,核心逻辑是这样:
import UIKit class SceneDelegate: UIResponder, UIWindowSceneDelegate { var window: UIWindow? func scene( _ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions ) { guard let windowScene = (scene as? UIWindowScene) else { return } let window = UIWindow(windowScene: windowScene) window.rootViewController = ViewController() window.makeKeyAndVisible() self.window = window } }注意一点:如果你的 Info.plist 里还保留着 Main storyboard file base name 的配置,系统会优先加载 storyboard。想完全代码化,记得把那条配置删掉,否则会出现你明明设置了 rootViewController,屏幕却还在显示 Storyboard 里初始控制器的奇怪现象。
2.1 先给控制器添加一个 UILabel
在视图控制器里创建控件,我习惯用存储属性加闭包来初始化。这样 viewDidLoad 里只需要 addSubview 和设置约束,不会一大堆代码堆在生命周期方法里。
import UIKit final class ViewController: UIViewController { private lazy var titleLabel: UILabel = { let label = UILabel() label.text = "欢迎来到代码界面" label.font = UIFont.systemFont(ofSize: 24, weight: .bold) label.textColor = .label label.textAlignment = .center label.numberOfLines = 0 label.translatesAutoresizingMaskIntoConstraints = false return label }() override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = .systemBackground view.addSubview(titleLabel) } }这里有一个必须写的属性:translatesAutoresizingMaskIntoConstraints = false。如果不写,系统会自动把 autoresizing 转成约束,容易和你手动添加的约束打架。这是纯代码开发里最常见的错误之一。很多新手在 Xcode 里看到约束冲突的红色提示,十有八九是因为漏了这一句。
numberOfLines = 0是我个人比较推荐的习惯。文字内容一长,UILabel 默认只有一行就会截断,设置成 0 后会自动换行,配合约束能达到更自然的排版效果。
2.2 用纯代码添加 UIButton 并绑定点击事件
UIButton 比 UILabel 多一个关键步骤:绑定点击事件。代码里直接调用addTarget(_:action:for:)即可,写法如下:
private lazy var presentButton: UIButton = { let button = UIButton(type: .system) button.setTitle("模态弹出下一个页面", for: .normal) button.setTitleColor(.white, for: .normal) button.backgroundColor = .systemBlue button.layer.cornerRadius = 12 button.titleLabel?.font = UIFont.systemFont(ofSize: 17, weight: .semibold) button.translatesAutoresizingMaskIntoConstraints = false button.addTarget( self, action: #selector(didTapPresentButton), for: .touchUpInside ) return button }() @objc private func didTapPresentButton() { // 暂时留空,下一步在这里写模态弹出 }使用UIButton(type: .system)是最经典的方式,简单、可控。iOS 15 之后还有UIButton.Configuration的新 API,可以配置更多样式,但基础用法上.system依然兼容性最好。addTarget的 selector 方法必须加@objc,否则编译会直接报错。
很多人会问:为什么不用 UITapGestureRecognizer?其实也可以,但按钮本身就是为点击而设计的,自带高亮状态、点击范围、无障碍语义,我个人建议按钮用 UIControl 事件,不要画蛇添足。
2.3 用 Auto Layout 把文字和按钮摆到位
添加完控件别忘了一步:设置约束。纯代码方式最标准的就是 NSLayoutConstraint。把代码补全:
override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = .systemBackground view.addSubview(titleLabel) view.addSubview(presentButton) NSLayoutConstraint.activate([ titleLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor), titleLabel.topAnchor.constraint( equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 100 ), titleLabel.leadingAnchor.constraint( greaterThanOrEqualTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 24 ), titleLabel.trailingAnchor.constraint( lessThanOrEqualTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -24 ), presentButton.centerXAnchor.constraint(equalTo: view.centerXAnchor), presentButton.topAnchor.constraint( equalTo: titleLabel.bottomAnchor, constant: 40 ), presentButton.widthAnchor.constraint(equalToConstant: 220), presentButton.heightAnchor.constraint(equalToConstant: 48) ]) }布局逻辑很直观:标题水平居中,距离安全区顶部 100 点,左右至少留 24 点;按钮在标题下方 40 点,固定宽 220、高 48。这里我有意用greaterThanOrEqualTo和lessThanOrEqualTo而不是equalTo,是为了防止大字体模式下文字被截断。实际项目中,可访问性(Dynamic Type)是一个重要指标,代码里预留一些弹性空间是好事。
如果你嫌约束代码长,也可以把每个控件的约束独立用isActive = true设置。但多个控件时,我更喜欢NSLayoutConstraint.activate一次性激活,视觉上更集中,排查问题也方便。
3. 模态弹出视图控制器的完整实现
控件创建好了,下一步就是标题里最核心的部分:点击按钮,以模态方式弹出另一个视图控制器。UIKit 中对应的方法是:
present(_:animated:completion:)对应关闭方法:
dismiss(animated:completion:)这里有一个很多人容易忽略的点:从 iOS 13 开始,模态默认样式已经不是全屏,而是页面卡片式(.pageSheet)。老项目中你用之前习惯写的modalPresentationStyle没有赋值时,结果可能和预期不一样。建议每次 present 前明确指定样式。
3.1 先想清楚用哪种模态样式
常用 modal presentation style 有这几个:
| 枚举值 | 表现效果 | 典型使用场景 |
|---|---|---|
.fullScreen | 全屏盖住之前的控制器,没有手势关闭 | 登录页、完整流程页 |
.pageSheet | 卡片式,顶部露出部分背景,向下滑动可关闭 | 普通详情、编辑页 |
.formSheet | iPad 上居中显示小面板 | 表单填写、临时配置 |
.overFullScreen | 全屏覆盖,但底层控制器不被移除 | 半透明弹层、过渡动画 |
.automatic | 系统根据上下文自动选择 | 你不想纠结时的默认项 |
.pageSheet这种卡片式是最常见的。用户可以用手势向下滑关闭,但如果你希望禁止手势关闭,可以在被弹出的控制器里设置:
isModalInPresentation = true这个属性很实用。比如你的弹窗里有尚未保存的编辑内容,用户只要一滑就可能丢失,这时候设置isModalInPresentation = true能强制用户只能通过按钮关闭,避免误操作。
3.2 创建一个被弹出的目标控制器
主控制器准备好后,再写一个DetailViewController,同样用代码添加文字和关闭按钮。这个控制器不用复杂,核心是把"关闭"按钮和dismiss绑起来。
import UIKit final class DetailViewController: UIViewController { private lazy var messageLabel: UILabel = { let label = UILabel() label.text = "这是一个模态页面" label.font = UIFont.systemFont(ofSize: 20, weight: .medium) label.textColor = .label label.textAlignment = .center label.translatesAutoresizingMaskIntoConstraints = false return label }() private lazy var closeButton: UIButton = { let button = UIButton(type: .system) button.setTitle("关闭", for: .normal) button.titleLabel?.font = UIFont.systemFont(ofSize: 17, weight: .semibold) button.backgroundColor = .systemRed button.setTitleColor(.white, for: .normal) button.layer.cornerRadius = 12 button.translatesAutoresizingMaskIntoConstraints = false button.addTarget(self, action: #selector(didTapClose), for: .touchUpInside) return button }() override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = .secondarySystemBackground view.addSubview(messageLabel) view.addSubview(closeButton) NSLayoutConstraint.activate([ messageLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor), messageLabel.topAnchor.constraint( equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 120 ), closeButton.centerXAnchor.constraint(equalTo: view.centerXAnchor), closeButton.topAnchor.constraint( equalTo: messageLabel.bottomAnchor, constant: 32 ), closeButton.widthAnchor.constraint(equalToConstant: 160), closeButton.heightAnchor.constraint(equalToConstant: 44) ]) } @objc private func didTapClose() { dismiss(animated: true, completion: nil) } }这里的写法跟主控制器完全一致,只是背景色换成了secondarySystemBackground,视觉上更容易看出这是新页面。dismiss(animated:completion:)不一定要传 completion,默认并不需要做额外事情。
3.3 点击主按钮弹出模态页面
现在回到主控制器的didTapPresentButton,补上 present 逻辑:
@objc private func didTapPresentButton() { let detailVC = DetailViewController() detailVC.modalPresentationStyle = .pageSheet detailVC.modalTransitionStyle = .coverVertical present(detailVC, animated: true, completion: nil) }modalTransitionStyle是转场动画,最常用的是.coverVertical,从底部滑上来。如果你想用淡入淡出、翻页等效果,可以分别换成.crossDissolve、.flipHorizontal,不过日常项目里.coverVertical已经够用了。
模态弹出的本质是"以新的视图控制器去覆盖当前的界面上下文",所以被弹出的detailVC并不需要先加入父控制器,直接 present 即可。这一点和 push 需要 navigationController 完全不同。
3.4 给模态页面传参数和回调
纯代码创建一个全新的控制器,传参特别自然。目标控制器里加一个属性就行:
final class DetailViewController: UIViewController { var userId: String? var onClose: ((String) -> Void)? }在主控制器里,直接赋值:
let detailVC = DetailViewController() detailVC.userId = "12345" detailVC.onClose = { [weak self] result in print("模态页面回传:\(result)") } present(detailVC, animated: true, completion: nil)关闭时,在目标控制器里调用回调再 dismiss:
@objc private func didTapClose() { onClose?("已关闭") dismiss(animated: true, completion: nil) }回调用[weak self]是我相当建议的做法。尤其在控制器被引用链比较复杂时,避免闭包把 self 强持有,减少不必要的内存问题。
4. 常见问题与排查技巧实录
纯代码写 UI 并不难,但实际开发中总会碰到一些磨人的小问题。我把这段时间遇到的和周围同事遇到的集中记录下来,基本覆盖了最容易踩的坑。
4.1 按钮没反应:先查 target/action
如果你发现按钮显示出来了,但点了没反应,优先检查两件事。第一,addTarget里的 selector 方法是否加了@objc;第二,target 传的是不是self,action 方法是否是实例方法。这俩是初学者最常犯的问题。
还有一个容易被忽视的:按钮的isEnabled可能被设置为 false。某些场景下封装按钮时,你可能为了控制点击态把它关掉了,但忘记在特定状态下重新打开。调试时可以在 action 方法里打断点,或者临时打印button.isEnabled,很快就能定位。
4.2 控件不见了或者位置不对:看约束
代码 UI 最常见的异常是"明明 addSubview 了,屏幕上却看不到"。排查思路是分三步:
- 确认
translatesAutoresizingMaskIntoConstraints都设为了 false。 - 检查约束是否完整,有没有缺少决定位置或尺寸的约束。
- 在 Xcode 控制台查看约束冲突,找到系统给出的建议宽高。
比如 UILabel 如果没有高度约束,也没有上下约束,可能会变成 0 高度,看起来像没显示。解决方法是给标题加完整约束,或者借助intrinsicContentSize让系统自动撑开,前提是不要为 label 设置冲突的宽度。
按钮看不到则更多是因为没有宽高约束。UIButton 虽然有 intrinsic content size,但如果你设了背景色,却没有宽高,它依然可能在屏幕上占用很小的一块区域,看起来像没渲染。我一般会为主按钮固定 width 和 height,减少不确定性。
4.3 模态弹出后的样式跟预期不一致
很多从旧项目迁移过来的开发者会遇到一个问题:明明想要全屏,弹出来却是个卡片,还能滑动关闭。原因就是我前面提到的,iOS 13 之后默认 presentation style 变成了.pageSheet,不是.fullScreen。解决方式很简单,present 前手动指定:
detailVC.modalPresentationStyle = .fullScreen另外,modalPresentationStyle必须在 present 之前设置,如果你在目标控制器的 viewDidLoad 里设置,大概率已经晚了。因为 modal style 是 presentation context 在发起时读取的属性,present 之后再改,系统不会重新生效。
4.4 被安全区遮挡的问题
iPhone 上布局最让我头疼的问题之一就是"home indicator 区域"和"灵动岛区域"。纯代码布局时,如果直接使用view.topAnchor、view.bottomAnchor,控件很容易被状态栏或者底部横条盖住。
解决办法是统一使用safeAreaLayoutGuide:
view.safeAreaLayoutGuide.topAnchor view.safeAreaLayoutGuide.bottomAnchor我在代码里已经用到了。安全区这个概念并不复杂,但对新手来说,少用一次就可能导致页面显示不完整。模态卡片页面也一样,系统会处理顶部圆角和安全区,但如果你在卡片顶部手动加控件,依然要参考安全区约束。
4.5 内存与循环引用问题
代码 UI 用到闭包时,需要格外留意循环引用。目标控制器里的onClose是闭包,主控制器赋值时如果闭包内强引用了self,而主控制器又通过 present 持有了目标控制器,目标控制器再持有闭包,就可能形成强引用环。
我的经验是:凡是在闭包里用到 self,先写[weak self]。即使当前场景可能不会立刻产生泄漏,养成这个习惯能省下很多排查时间。
按钮的addTarget一般不用担心循环引用,因为 UIControl 对 target 持有关系并不是强持有,而且控制器本身生命周期比较明确。真正要关注的是闭包属性、Block 回调、NotificationCenter 观察者这些场景。
最后分享一个小技巧
代码写 UI 写到后面,你会发现最花时间的不是创建控件,而是把每个控件的初始化代码写得清爽。我现在习惯把控件声明成private lazy var,然后用闭包初始化,在闭包内集中配置样式和事件。这样控制器内部结构非常清晰:上面是一排控件定义,中间是 viewDidLoad 的 addSubview 和约束,下面才是一堆 action 方法,读起来就像一份清单。
另外,如果同一个样式按钮在多个页面出现,可以给 UIButton 写一个扩展方法,比如func applyPrimaryStyle(),把 backgroundColor、cornerRadius、font 集中封装。这样以后要统一改主按钮样式,只动扩展文件即可,不用每个页面翻一遍。这算是我从纯代码开发里受益最深的一点,推荐你也试试。