资讯动态

Swift纯代码实现 UIKit 控件创建与模态页面切换实战指南

发布时间:2026/10/16 23:05:26 来源:尧图企业网站定制
如果你已经厌倦了在 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、UIButtonStoryboard 根本帮不上忙。弹窗和浮层组件一个弹窗可能在不同控制器里被复用用代码封装成独立类最方便。团队协作紧密的项目避免 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卡片式顶部露出部分背景向下滑动可关闭普通详情、编辑页.formSheetiPad 上居中显示小面板表单填写、临时配置.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)关闭时在目标控制器里调用回调再 dismissobjc private func didTapClose() { onClose?(已关闭) dismiss(animated: true, completion: nil) }回调用[weak self]是我相当建议的做法。尤其在控制器被引用链比较复杂时避免闭包把 self 强持有减少不必要的内存问题。4. 常见问题与排查技巧实录纯代码写 UI 并不难但实际开发中总会碰到一些磨人的小问题。我把这段时间遇到的和周围同事遇到的集中记录下来基本覆盖了最容易踩的坑。4.1 按钮没反应先查 target/action如果你发现按钮显示出来了但点了没反应优先检查两件事。第一addTarget里的 selector 方法是否加了objc第二target 传的是不是selfaction 方法是否是实例方法。这俩是初学者最常犯的问题。还有一个容易被忽视的按钮的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控件很容易被状态栏或者底部横条盖住。解决办法是统一使用safeAreaLayoutGuideview.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 集中封装。这样以后要统一改主按钮样式只动扩展文件即可不用每个页面翻一遍。这算是我从纯代码开发里受益最深的一点推荐你也试试。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑