开篇:为什么我们要学 SwiftUI?
如果你曾经想过开发 iPhone、iPad、Mac、Apple Watch 甚至 Apple TV 上的应用,那么你大概率听说过 UIKit、AppKit,或者近些年越来越火的 SwiftUI。
过去,苹果平台的界面开发主要依赖 UIKit 和 AppKit。它们非常强大,也非常成熟,但有一个共同点:命令式。什么意思呢?就像你请一位装修师傅装修房子,你不能只说“我想要一个温馨的客厅”,你得一步步告诉他:先把墙刷成米白色,再把沙发放在左边,茶几放中间,灯挂在上面,如果用户点了开关,就把灯打开,如果用户又点了开关,就把灯关掉。你得管理每一个步骤、每一个状态、每一个变化。
SwiftUI 的出现,改变了很多东西。它让界面开发变成了声明式。你只需要说:“我这里要一个客厅,里面有沙发、茶几和灯,灯的状态由 isLightOn 决定。”当 isLightOn 变成 true,SwiftUI 自己知道该把灯点亮;变成 false,它自己知道该把灯关掉。你不再需要手动去找到那盏灯、修改它的颜色、刷新界面。你描述“界面应该是什么样”,系统负责“怎么变成那样”。
这就是 SwiftUI 最核心的思想:状态驱动界面,界面是状态的函数。
不过,要真正理解 SwiftUI 的好,我们不能只盯着 SwiftUI 自己看。我们还要看看以前的 UIKit 是怎么写的。没有对比,就不容易体会声明式的轻松。所以这一课,我们会不断拿 SwiftUI 和 UIKit 做对比。每一个小功能,我都尽量给出两边代码。你看完就会明白:为什么 SwiftUI 会让界面代码变短、变清晰。
这一课会很长,但请你不要急着一次全部记住。你可以把每一节当成一个小故事,边看边在 Xcode 里敲一敲。学习 SwiftUI 最好的方式,不是背 API,而是动手改一改,看看界面怎么变。
好,我们开始。
一、SwiftUI 到底是什么?
1.1 一句话解释
SwiftUI 是苹果在 2019 年推出的一个用于构建用户界面的框架。它使用 Swift 语言,采用声明式语法,可以跨苹果平台使用。
你可以把它理解成一套“界面乐高”:
- Text 是一块显示文字的积木;
- Image 是一块显示图片的积木;
- Button 是一块可以点击的积木;
- VStack、HStack、ZStack 是把积木竖着、横着、叠着摆的积木;
- List 是自动帮你排好一列内容的积木;
- NavigationStack 是管理页面跳转的积木。
你把这些积木组合起来,就得到了一个界面。组合的方式不是写一堆“创建、添加、约束、更新”的命令,而是用代码描述出一棵树。这棵树叫 View 树。
1.2 声明式与命令式的区别:计数器例子
我们用一个例子感受一下。
假设我们要显示一个计数器:屏幕上有一个数字,下面有一个按钮,点一下数字加一。
SwiftUI 的写法:
import SwiftUI
struct CounterView: View {
@State private var count = 0
var body: some View {
VStack(spacing: 16) {
Text("你点了 \\(count) 次")
.font(.title2)
Button("点我") {
count += 1
}
.buttonStyle(.borderedProminent)
}
.padding()
}
}
你只描述了:
- 界面上有一个垂直排列;
- 上面是文字,文字内容包含 count;
- 下面是按钮,点击后 count 加一。
至于 count 变化后,文字怎么刷新、界面怎么重绘,SwiftUI 自己会处理。
UIKit 的写法:
import UIKit
class CounterViewController: UIViewController {
private var count = 0
private let label: UILabel = {
let label = UILabel()
label.text = "你点了 0 次"
label.font = .preferredFont(forTextStyle: .title2)
label.textAlignment = .center
return label
}()
private let button: UIButton = {
let button = UIButton(type: .system)
button.setTitle("点我", for: .normal)
button.titleLabel?.font = .preferredFont(forTextStyle: .headline)
return button
}()
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemBackground
button.addTarget(self, action: #selector(increment), for: .touchUpInside)
let stack = UIStackView(arrangedSubviews: [label, button])
stack.axis = .vertical
stack.spacing = 16
stack.alignment = .center
stack.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(stack)
NSLayoutConstraint.activate([
stack.centerXAnchor.constraint(equalTo: view.centerXAnchor),
stack.centerYAnchor.constraint(equalTo: view.centerYAnchor)
])
}
@objc private func increment() {
count += 1
label.text = "你点了 \\(count) 次"
}
}
你看,UIKit 里我们要:
每一步都要你亲自做。SwiftUI 里,你只描述结果,系统负责过程。这就是声明式与命令式的最大区别。
1.3 SwiftUI 能做什么?
SwiftUI 能做的事情已经非常多:
- iPhone、iPad App;
- Mac App;
- Apple Watch App;
- Apple TV App;
- 小组件 Widget;
- 实时活动 Live Activity;
- 部分 visionOS 应用。
而且同一套代码,很多时候只需要少量调整,就能跑在不同设备上。当然,不同平台差异仍然存在,但 SwiftUI 大大降低了跨平台界面的成本。
1.4 SwiftUI 和 UIKit 的关系
你可能会问:学了 SwiftUI,还需要学 UIKit 吗?
答案是:入门和大多数新项目,可以先用 SwiftUI。遇到 SwiftUI 暂时做不到或做不好的地方,再用 UIKit 桥接。
SwiftUI 并不是要立刻消灭 UIKit。它们可以共存。比如你可以用 UIViewRepresentable 把 UIKit 的视图包装进 SwiftUI,也可以用 UIHostingController 把 SwiftUI 视图放进 UIKit。
所以,不要有心理负担。你不需要先精通 UIKit 才能学 SwiftUI。相反,SwiftUI 更适合初学者建立界面开发的整体感觉。但了解 UIKit 的写法,能让你更懂 SwiftUI 为什么这样设计。
二、第一个 SwiftUI 应用:从 App 到 View
2.1 新建项目时发生了什么?
在 Xcode 里新建一个 SwiftUI 项目,你会看到类似这样的代码:
import SwiftUI
@main
struct MyFirstApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
另一个文件是:
import SwiftUI
struct ContentView: View {
var body: some View {
VStack {
Image(systemName: "globe")
.imageScale(.large)
.foregroundStyle(.tint)
Text("Hello, world!")
}
.padding()
}
}
我们一点点拆开看。
2.2 @main 是什么?
@main 表示这是程序的入口。以前在 UIKit 里,入口通常是 AppDelegate 和 SceneDelegate。在 SwiftUI 里,入口是一个遵循 App 协议的结构体。
@main
struct MyFirstApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
- MyFirstApp 是整个 App 的类型;
- body 返回一个 Scene;
- WindowGroup 表示一个窗口组,在 iPhone 上通常就是一个全屏窗口;
- ContentView() 是窗口里显示的第一个视图。
你可以把 App 理解成“整个应用的外壳”,Scene 理解成“一个窗口或一组窗口”,View 理解成“窗口里的内容”。
UIKit 的入口则通常长这样:
import UIKit
@main
class AppDelegate: UIResponder, UIApplicationDelegate {
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
return true
}
func application(
_ application: UIApplication,
configurationForConnecting connectingSceneSession: UISceneSession,
options: UIScene.ConnectionOptions
) -> UISceneConfiguration {
return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role)
}
}
还有 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 = UINavigationController(
rootViewController: CounterViewController()
)
self.window = window
window.makeKeyAndVisible()
}
}
对比一下:UIKit 要配置 AppDelegate、SceneDelegate、Window、rootViewController;SwiftUI 只要一个 WindowGroup { ContentView() }。这就是声明式入口的简洁。
2.3 ContentView 是什么?
struct ContentView: View {
var body: some View {
…
}
}
- ContentView 是一个结构体;
- 它遵循 View 协议;
- 它必须实现 body 属性;
- body 返回 some View,表示“某种具体的 View”,但我们不需要写出具体类型。
这里的 some View 叫不透明返回类型。你不用管它背后到底是什么类型,编译器知道。你只需要知道:body 里写的就是界面。
UIKit 里对应的概念通常是 UIViewController:
class ContentViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemBackground
let label = UILabel()
label.text = "Hello, world!"
label.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(label)
NSLayoutConstraint.activate([
label.centerXAnchor.constraint(equalTo: view.centerXAnchor),
label.centerYAnchor.constraint(equalTo: view.centerYAnchor)
])
}
}
UIViewController 有生命周期:viewDidLoad、viewWillAppear、viewDidAppear。SwiftUI 的 View 没有这些传统生命周期,它更像一个描述,SwiftUI 会在需要时重新计算 body。
2.4 body 里为什么不能写普通代码?
body 是一个计算属性。它里面通常只写 View 的构建代码。你可以写 if、switch、ForEach,但不能随便写 var x = 1 这种语句,除非用 let 或某些允许的构建方式。
这是因为 SwiftUI 使用了一个叫 ViewBuilder 的结果构建器。它会把你在 body 里写的一堆视图,自动组合成一个整体。
例如:
var body: some View {
Text("第一行")
Text("第二行")
}
这看起来像两个并列的 View。实际上,在 ViewBuilder 里,它们会被组合成一个元组视图。但更常见的是,我们会把它们放进 VStack 里,因为直接并列在 body 里,布局行为可能不是你想要的。
三、View:SwiftUI 世界的积木
3.1 View 不是 UIView
在 UIKit 里,UIView 是一个类,是屏幕上的一块矩形区域。它有坐标、大小、背景色、子视图。
在 SwiftUI 里,View 是一个协议。它更像一个“描述”。Text、Image、Button、VStack 都是 View。它们不是屏幕上的实体,而是对界面的描述。SwiftUI 会根据这些描述,在底层创建、更新、销毁真正的渲染对象。
所以,请不要把 SwiftUI 的 View 直接等同于 UIKit 的 UIView。它更轻、更抽象。
3.2 View 是结构体,不是类
SwiftUI 里大多数 View 都是结构体。结构体是值类型。这意味着当你传递一个 View 时,传递的是一份描述,而不是一个共享的引用。
这带来很多好处:
- 状态管理更清晰;
- 不容易意外修改;
- 组合和复用更容易。
但也要注意:结构体里不能像类那样随意修改属性。如果你想修改状态,需要用 @State 等属性包装器。
UIKit 的 UIView 和 UIViewController 是类,是引用类型。你可以持有它们的指针,随时修改它们。比如:
class MyView: UIView {
var count = 0
}
你可以直接改 myView.count,然后手动刷新界面。SwiftUI 不鼓励这种方式,它希望你通过状态变化来驱动界面。
3.3 组合优于继承
UIKit 里经常用继承:创建一个 BaseViewController,然后子类化。SwiftUI 更推荐组合:把小的 View 组合成大的 View。
例如 SwiftUI 的 UserCard:
struct UserCard: View {
var body: some View {
HStack {
Image(systemName: "person.circle.fill")
.font(.largeTitle)
VStack(alignment: .leading) {
Text("张三")
.font(.headline)
Text("iOS 开发者")
.font(.subheadline)
.foregroundStyle(.secondary)
}
Spacer()
}
.padding()
.background(Color(.systemGray6))
.clipShape(RoundedRectangle(cornerRadius: 12))
.padding(.horizontal)
}
}
UIKit 里,你通常要自定义一个 UIView 子类:
import UIKit
class UserCardView: UIView {
private let iconView = UIImageView()
private let nameLabel = UILabel()
private let jobLabel = UILabel()
override init(frame: CGRect) {
super.init(frame: frame)
setup()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
setup()
}
private func setup() {
backgroundColor = .systemGray6
layer.cornerRadius = 12
layer.masksToBounds = true
iconView.image = UIImage(systemName: "person.circle.fill")
iconView.tintColor = .label
iconView.contentMode = .scaleAspectFit
iconView.translatesAutoresizingMaskIntoConstraints = false
nameLabel.text = "张三"
nameLabel.font = .preferredFont(forTextStyle: .headline)
jobLabel.text = "iOS 开发者"
jobLabel.font = .preferredFont(forTextStyle: .subheadline)
jobLabel.textColor = .secondaryLabel
let textStack = UIStackView(arrangedSubviews: [nameLabel, jobLabel])
textStack.axis = .vertical
textStack.spacing = 4
let stack = UIStackView(arrangedSubviews: [iconView, textStack])
stack.axis = .horizontal
stack.spacing = 12
stack.alignment = .center
stack.translatesAutoresizingMaskIntoConstraints = false
addSubview(stack)
NSLayoutConstraint.activate([
iconView.widthAnchor.constraint(equalToConstant: 44),
iconView.heightAnchor.constraint(equalToConstant: 44),
stack.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16),
stack.trailingAnchor.constraint(equalTo: trailingAnchor, constant: –16),
stack.topAnchor.constraint(equalTo: topAnchor, constant: 16),
stack.bottomAnchor.constraint(equalTo: bottomAnchor, constant: –16)
])
}
}
这个 UserCardView 可以到处使用。它不是通过继承得到的,而是通过组合 UIImageView、UILabel、UIStackView 得到的。SwiftUI 把这种组合变得非常自然。
3.4 自定义 View 的好处
当界面复杂时,把一部分抽成自定义 View,会让代码更清晰。
SwiftUI:
struct TitleView: View {
let title: String
var body: some View {
Text(title)
.font(.title)
.bold()
.foregroundStyle(.blue)
}
}
使用:
VStack {
TitleView(title: "欢迎")
TitleView(title: "SwiftUI")
}
UIKit 里通常要自定义 UIView 或封装一个方法。SwiftUI 的自定义 View 更轻,组合更容易。
四、Text 与 Image:最基础的两块积木
4.1 Text 与 UILabel
SwiftUI 显示文字:
Text("Hello, SwiftUI")
设置字体:
Text("标题")
.font(.title)
颜色:
Text("红色文字")
.foregroundStyle(.red)
UIKit 显示文字:
let label = UILabel()
label.text = "Hello, UIKit"
label.font = .preferredFont(forTextStyle: .title1)
label.textColor = .systemRed
label.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(label)
对比一下:SwiftUI 的 Text 是一行描述,UIKit 要创建对象、设置属性、加入视图、设置约束。SwiftUI 的修饰符链可以不断叠加,UIKit 则要一行行改属性。
4.2 Image 与 UIImageView
SwiftUI 显示系统图标:
Image(systemName: "star.fill")
.foregroundStyle(.yellow)
显示资源图片:
Image("myPhoto")
.resizable()
.scaledToFit()
UIKit 显示图片:
let imageView = UIImageView()
imageView.image = UIImage(systemName: "star.fill")
imageView.tintColor = .systemYellow
imageView.contentMode = .scaleAspectFit
imageView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(imageView)
如果要圆角裁剪:
SwiftUI:
Image("myPhoto")
.resizable()
.scaledToFill()
.frame(width: 100, height: 100)
.clipShape(Circle())
UIKit:
let imageView = UIImageView()
imageView.image = UIImage(named: "myPhoto")
imageView.contentMode = .scaleAspectFill
imageView.frame = CGRect(x: 0, y: 0, width: 100, height: 100)
imageView.layer.cornerRadius = 50
imageView.clipsToBounds = true
4.3 SF Symbols:苹果的图标库
SF Symbols 是苹果提供的图标库,数量很多,风格统一。SwiftUI 使用方式:
Image(systemName: "heart.fill")
UIKit 使用方式:
let image = UIImage(systemName: "heart.fill")
let imageView = UIImageView(image: image)
4.4 文字与图标组合
SwiftUI:
Label("设置", systemImage: "gear")
UIKit 里通常要组合 UIImageView 和 UILabel,或者用 UIButton 的 configuration:
var config = UIButton.Configuration.plain()
config.title = "设置"
config.image = UIImage(systemName: "gear")
config.imagePadding = 8
let button = UIButton(configuration: config)
SwiftUI 的 Label 更简单,而且会自动适配不同平台。
五、布局三剑客:VStack、HStack、ZStack
5.1 VStack 与 UIStackView 垂直排列
SwiftUI:
VStack(spacing: 20) {
Text("第一行")
Text("第二行")
Text("第三行")
}
UIKit:
let stack = UIStackView(arrangedSubviews: [
UILabel(),
UILabel(),
UILabel()
])
stack.axis = .vertical
stack.spacing = 20
stack.alignment = .center
stack.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(stack)
如果你不想用 UIStackView,那就要给每个 label 加约束,代码会更多。SwiftUI 的 VStack 就是布局容器,写起来非常直接。
5.2 HStack 与 UIStackView 水平排列
SwiftUI:
HStack {
Image(systemName: "person")
Text("张三")
}
UIKit:
let iconView = UIImageView(image: UIImage(systemName: "person"))
let nameLabel = UILabel()
nameLabel.text = "张三"
let stack = UIStackView(arrangedSubviews: [iconView, nameLabel])
stack.axis = .horizontal
stack.spacing = 8
stack.alignment = .center
5.3 ZStack 与重叠视图
SwiftUI:
ZStack {
Color.blue
Text("我在上面")
.foregroundStyle(.white)
}
UIKit 里要重叠视图,通常是把两个 view 加到同一个父视图,然后通过约束或 frame 让它们重叠:
let backgroundView = UIView()
backgroundView.backgroundColor = .systemBlue
backgroundView.translatesAutoresizingMaskIntoConstraints = false
let label = UILabel()
label.text = "我在上面"
label.textColor = .white
label.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(backgroundView)
view.addSubview(label)
NSLayoutConstraint.activate([
backgroundView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
backgroundView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
backgroundView.topAnchor.constraint(equalTo: view.topAnchor),
backgroundView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
label.centerXAnchor.constraint(equalTo: view.centerXAnchor),
label.centerYAnchor.constraint(equalTo: view.centerYAnchor)
])
SwiftUI 的 ZStack 把“叠放”变成了一个明确的容器,顺序就是层级,非常直观。
5.4 Spacer 与 Auto Layout 弹簧
SwiftUI:
HStack {
Text("左边")
Spacer()
Text("右边")
}
UIKit 里通常用约束:
let leftLabel = UILabel()
leftLabel.text = "左边"
let rightLabel = UILabel()
rightLabel.text = "右边"
leftLabel.translatesAutoresizingMaskIntoConstraints = false
rightLabel.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(leftLabel)
view.addSubview(rightLabel)
NSLayoutConstraint.activate([
leftLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
leftLabel.centerYAnchor.constraint(equalTo: view.centerYAnchor),
rightLabel.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: –16),
rightLabel.centerYAnchor.constraint(equalTo: view.centerYAnchor)
])
SwiftUI 的 Spacer() 就像弹簧,自动占据剩余空间。
5.5 padding 和 frame 与约束
SwiftUI:
Text("按钮文字")
.padding()
.frame(maxWidth: .infinity)
.background(.blue)
.foregroundStyle(.white)
.clipShape(RoundedRectangle(cornerRadius: 12))
UIKit 里要设置内边距、宽度、背景色、圆角:
let label = UILabel()
label.text = "按钮文字"
label.textAlignment = .center
label.textColor = .white
label.backgroundColor = .systemBlue
label.layer.cornerRadius = 12
label.clipsToBounds = true
label.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(label)
NSLayoutConstraint.activate([
label.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
label.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: –16),
label.heightAnchor.constraint(equalToConstant: 44)
])
SwiftUI 的修饰符链把样式和布局写在一起,阅读起来像一句话。UIKit 则是分散的属性设置和约束代码。
5.6 alignment 和 spacing 的小结
- VStack 控制垂直排列,alignment 控制水平方向对齐;
- HStack 控制水平排列,alignment 控制垂直方向对齐;
- ZStack 控制叠放,alignment 控制整体对齐。
这些概念一开始容易混,多写几个界面就熟悉了。
六、修饰符:给 View 穿衣服
6.1 什么是修饰符?
SwiftUI 里,你经常看到:
Text("Hello")
.font(.title)
.foregroundStyle(.blue)
.padding()
这些 .font、.foregroundStyle、.padding 就是修饰符。它们返回一个新的 View。所以链式调用的顺序很重要。
UIKit 里没有完全对应的“修饰符链”。UIKit 更多是直接改属性:
let label = UILabel()
label.text = "Hello"
label.font = .preferredFont(forTextStyle: .title1)
label.textColor = .systemBlue
label.layoutMargins = UIEdgeInsets(top: 8, left: 8, bottom: 8, right: 8)
SwiftUI 把“设置属性”变成了“包装 View”。每次修饰都产生一个新 View,最终组合成完整的界面。
6.2 顺序为什么重要?
看两个例子:
Text("Hello")
.padding()
.background(.yellow)
先加内边距,再加背景。背景会包含内边距区域。
Text("Hello")
.background(.yellow)
.padding()
先加背景,再加内边距。内边距在背景外面,背景只包住文字。
所以,修饰符的顺序会改变结果。你可以把修饰符理解成“一层层包装”。
UIKit 里,顺序通常不影响属性本身,但会影响视图层级和约束。例如你先加背景视图还是先加 label,会决定谁在上面。SwiftUI 用修饰符顺序把这种层级关系表达得更直接。
6.3 常用修饰符与 UIKit 对应
- .font() 对应 UILabel.font;
- .foregroundStyle() 对应 UILabel.textColor / UIImageView.tintColor;
- .background() 对应 UIView.backgroundColor 或额外背景视图;
- .padding() 对应约束常量或 layoutMargins;
- .frame() 对应 NSLayoutConstraint 的宽高;
- .clipShape() 对应 layer.cornerRadius + clipsToBounds;
- .shadow() 对应 layer.shadowColor、shadowOpacity、shadowRadius 等;
- .opacity() 对应 UIView.alpha。
6.4 自定义 ViewModifier 与 UIKit 封装
SwiftUI 可以自定义:
struct CardModifier: ViewModifier {
func body(content: Content) -> some View {
content
.padding()
.background(.white)
.clipShape(RoundedRectangle(cornerRadius: 16))
.shadow(radius: 8)
.padding()
}
}
extension View {
func cardStyle() -> some View {
modifier(CardModifier())
}
}
使用:
Text("卡片内容")
.cardStyle()
UIKit 里也可以封装一个 UIView 子类或扩展:
extension UIView {
func applyCardStyle() {
backgroundColor = .white
layer.cornerRadius = 16
layer.shadowColor = UIColor.black.cgColor
layer.shadowOpacity = 0.1
layer.shadowRadius = 8
layer.shadowOffset = CGSize(width: 0, height: 4)
}
}
但 SwiftUI 的 ViewModifier 更统一,因为它本身就是 View 组合的一部分。
七、状态:让界面动起来
7.1 没有状态,界面就是静态的
如果界面只是显示固定文字,那很简单。但 App 需要交互:点击、输入、切换、滑动。交互会改变数据,数据改变后界面要更新。这个“会变化的数据”就是状态。
SwiftUI 里最常用的状态包装器是 @State。
struct CounterView: View {
@State private var count = 0
var body: some View {
VStack {
Text("当前计数:\\(count)")
Button("加一") {
count += 1
}
}
}
}
7.2 @State 做了什么?
- 它把 count 交给 SwiftUI 管理;
- 当 count 改变时,SwiftUI 会重新计算 body;
- 界面根据新的 count 自动更新。
注意:@State 通常用于 View 内部的私有状态,所以习惯写成:
@State private var count = 0
UIKit 里对应的状态就是普通属性:
class CounterViewController: UIViewController {
private var count = 0
private let label = UILabel()
@objc private func increment() {
count += 1
label.text = "当前计数:\\(count)"
}
}
区别在于:SwiftUI 改 count 就够了,界面自动更新;UIKit 改完 count 还要手动更新 label.text。如果有十个控件依赖这个状态,你就要手动更新十次。SwiftUI 不需要。
7.3 状态驱动界面
SwiftUI 的核心公式:
界面 = f(状态)
你改变状态,界面自动变。你不需要手动刷新。
再看一个开关例子:
struct ToggleView: View {
@State private var isOn = false
var body: some View {
VStack {
Toggle("开启通知", isOn: $isOn)
Text(isOn ? "通知已开启" : "通知已关闭")
.foregroundStyle(isOn ? .green : .secondary)
}
.padding()
}
}
UIKit 里:
class ToggleViewController: UIViewController {
private let toggle = UISwitch()
private let statusLabel = UILabel()
override func viewDidLoad() {
super.viewDidLoad()
toggle.addTarget(self, action: #selector(toggleChanged), for: .valueChanged)
statusLabel.text = "通知已关闭"
statusLabel.textColor = .secondaryLabel
// 这里省略布局代码
}
@objc private func toggleChanged() {
if toggle.isOn {
statusLabel.text = "通知已开启"
statusLabel.textColor = .systemGreen
} else {
statusLabel.text = "通知已关闭"
statusLabel.textColor = .secondaryLabel
}
}
}
SwiftUI 用 isOn 一个状态,文字自动跟着变。UIKit 要在事件里手动改文字颜色和内容。
7.4 多个状态
struct ProfileView: View {
@State private var name = "张三"
@State private var age = 28
@State private var isVip = false
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text("姓名:\\(name)")
Text("年龄:\\(age)")
Text("VIP:\\(isVip ? "是" : "否")")
}
.padding()
}
}
状态可以很多,但不要把所有东西都塞进一个 View。复杂状态可以抽成模型。
八、Binding:把状态传给子视图
8.1 为什么需要 Binding?
如果子视图需要修改父视图的状态,直接传值不行,因为值类型是拷贝。这时需要 @Binding。
struct ParentView: View {
@State private var name = "张三"
var body: some View {
VStack {
Text("父视图:\\(name)")
ChildView(name: $name)
}
}
}
struct ChildView: View {
@Binding var name: String
var body: some View {
TextField("输入名字", text: $name)
.textFieldStyle(.roundedBorder)
.padding()
}
}
$name 把 @State 变成了绑定。子视图通过 @Binding 读写同一个状态。
8.2 Binding 与 UIKit 的代理/闭包
UIKit 里子视图要通知父视图,通常用:
- 代理 delegate;
- 闭包 closure;
- target-action;
- NotificationCenter。
例如一个自定义输入视图:
class NameInputView: UIView {
var onNameChanged: ((String) -> Void)?
private let textField = UITextField()
@objc private func textChanged() {
onNameChanged?(textField.text ?? "")
}
}
父视图:
let inputView = NameInputView()
inputView.onNameChanged = { [weak self] name in
self?.nameLabel.text = name
}
SwiftUI 的 @Binding 把这种“双向同步”变得非常简单。你不需要写代理方法,也不需要手动调用闭包。绑定本身就是连接。
8.3 自定义 Binding
有时需要把绑定转换:
struct ContentView: View {
@State private var isOn = false
var body: some View {
Toggle("开关", isOn: Binding(
get: { isOn },
set: { newValue in
print("变成 \\(newValue)")
isOn = newValue
}
))
}
}
这适合在状态变化时做额外处理。UIKit 里你会在 valueChanged 事件里做类似事情。
九、List 与 ForEach:展示一列数据
9.1 静态 List 与 UITableView
SwiftUI:
List {
Text("第一项")
Text("第二项")
Text("第三项")
}
UIKit 里,你要用 UITableView,实现数据源:
class SimpleListViewController: UITableViewController {
let items = ["第一项", "第二项", "第三项"]
override func viewDidLoad() {
super.viewDidLoad()
tableView.register(UITableViewCell.self, forCellReuseIdentifier: "Cell")
}
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
items.count
}
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath)
cell.textLabel?.text = items[indexPath.row]
return cell
}
}
SwiftUI 的 List 自动处理复用、滚动、样式。你只描述每一项是什么。
9.2 动态 List 与 ForEach
SwiftUI:
struct FruitListView: View {
let fruits = ["苹果", "香蕉", "橙子", "西瓜"]
var body: some View {
List(fruits, id: \\.self) { fruit in
Text(fruit)
}
}
}
UIKit 里要维护数组、实现 numberOfRows、cellForRowAt。数据变化时还要 reloadData 或 performBatchUpdates。
9.3 使用 Identifiable 与 UITableViewDiffableDataSource
SwiftUI 推荐模型遵循 Identifiable:
struct Fruit: Identifiable {
let id = UUID()
let name: String
}
UIKit 现代写法可以用 UITableViewDiffableDataSource,但配置更复杂:
enum Section {
case main
}
var dataSource: UITableViewDiffableDataSource<Section, Fruit>!
func configureDataSource() {
dataSource = UITableViewDiffableDataSource<Section, Fruit>(tableView: tableView) { tableView, indexPath, fruit in
let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath)
cell.textLabel?.text = fruit.name
return cell
}
}
func applySnapshot() {
var snapshot = NSDiffableDataSourceSnapshot<Section, Fruit>()
snapshot.appendSections([.main])
snapshot.appendItems(fruits)
dataSource.apply(snapshot, animatingDifferences: true)
}
SwiftUI 的 List 和 ForEach 把这套流程简化成声明。
9.4 删除与移动
SwiftUI:
@State private var fruits = ["苹果", "香蕉", "橙子"]
List {
ForEach(fruits, id: \\.self) { fruit in
Text(fruit)
}
.onDelete { indexSet in
fruits.remove(atOffsets: indexSet)
}
}
UIKit:
override func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCell.EditingStyle, forRowAt indexPath: IndexPath) {
if editingStyle == .delete {
fruits.remove(at: indexPath.row)
tableView.deleteRows(at: [indexPath], with: .automatic)
}
}
SwiftUI 的 onDelete 更声明式:你只需要说“删除时怎么改数据”,界面更新交给系统。
十、导航:从一页到另一页
10.1 NavigationStack 与 UINavigationController
SwiftUI:
NavigationStack {
List {
NavigationLink("进入详情") {
Text("详情页面")
}
}
.navigationTitle("首页")
}
UIKit:
let listViewController = UITableViewController()
let navigationController = UINavigationController(rootViewController: listViewController)
// 在点击某一行时:
let detailViewController = UIViewController()
detailViewController.view.backgroundColor = .systemBackground
let label = UILabel()
label.text = "详情页面"
// 省略布局
detailViewController.view.addSubview(label)
navigationController.pushViewController(detailViewController, animated: true)
SwiftUI 用 NavigationLink 描述“点了去哪”。UIKit 要手动 pushViewController。
10.2 传值跳转
SwiftUI:
struct User: Identifiable {
let id = UUID()
let name: String
}
struct UserListView: View {
let users = [User(name: "张三"), User(name: "李四")]
var body: some View {
NavigationStack {
List(users) { user in
NavigationLink(user.name) {
UserDetailView(user: user)
}
}
.navigationTitle("用户")
}
}
}
UIKit:
class UserListViewController: UITableViewController {
let users = [User(name: "张三"), User(name: "李四")]
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
let user = users[indexPath.row]
let detail = UserDetailViewController(user: user)
navigationController?.pushViewController(detail, animated: true)
}
}
SwiftUI 把导航目标写在 NavigationLink 里,UIKit 要在 didSelectRowAt 里手动创建和推入。
10.3 工具栏
SwiftUI:
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button {
// 添加操作
} label: {
Image(systemName: "plus")
}
}
}
UIKit:
navigationItem.rightBarButtonItem = UIBarButtonItem(
barButtonSystemItem: .add,
target: self,
action: #selector(addTapped)
)
SwiftUI 的工具栏也是声明式的,UIKit 要创建 UIBarButtonItem 并绑定 target-action。
十一、表单与常用控件
11.1 Form 与 UITableView 设置页
SwiftUI:
Form {
Section("基本信息") {
TextField("姓名", text: $name)
TextField("年龄", text: $age)
}
Section("偏好") {
Toggle("接收通知", isOn: $isNotificationOn)
}
}
UIKit 里做设置页通常用 UITableView 的 .insetGrouped 样式:
class SettingsViewController: UITableViewController {
let nameField = UITextField()
let ageField = UITextField()
let notificationSwitch = UISwitch()
override func viewDidLoad() {
super.viewDidLoad()
tableView = UITableView(frame: .zero, style: .insetGrouped)
// 这里还要实现 numberOfSections、numberOfRows、cellForRowAt
// 每个 cell 里放不同的控件
}
}
SwiftUI 的 Form 自动处理分组、背景、间距,非常像系统设置页。
11.2 TextField 与 UITextField
SwiftUI:
@State private var username = ""
TextField("请输入用户名", text: $username)
.textFieldStyle(.roundedBorder)
UIKit:
let textField = UITextField()
textField.placeholder = "请输入用户名"
textField.borderStyle = .roundedRect
textField.addTarget(self, action: #selector(textChanged), for: .editingChanged)
@objc private func textChanged() {
username = textField.text ?? ""
}
SwiftUI 的 $username 自动双向绑定。UIKit 要手动监听变化并赋值。
11.3 Toggle 与 UISwitch
SwiftUI:
@State private var isOn = true
Toggle("Wi-Fi", isOn: $isOn)
UIKit:
let switchControl = UISwitch()
switchControl.isOn = true
switchControl.addTarget(self, action: #selector(switchChanged), for: .valueChanged)
@objc private func switchChanged() {
isOn = switchControl.isOn
}
11.4 Slider 与 UISlider
SwiftUI:
@State private var volume = 0.5
Slider(value: $volume, in: 0…1)
UIKit:
let slider = UISlider()
slider.minimumValue = 0
slider.maximumValue = 1
slider.value = 0.5
slider.addTarget(self, action: #selector(sliderChanged), for: .valueChanged)
@objc private func sliderChanged() {
volume = slider.value
}
11.5 Picker 与 UIPickerView / UISegmentedControl
SwiftUI:
@State private var selected = "苹果"
let options = ["苹果", "香蕉", "橙子"]
Picker("选择水果", selection: $selected) {
ForEach(options, id: \\.self) { option in
Text(option)
}
}
UIKit 可以用 UISegmentedControl:
let segmented = UISegmentedControl(items: ["苹果", "香蕉", "橙子"])
segmented.selectedSegmentIndex = 0
segmented.addTarget(self, action: #selector(segmentChanged), for: .valueChanged)
@objc private func segmentChanged() {
selected = options[segmented.selectedSegmentIndex]
}
SwiftUI 的 Picker 会根据平台自动选择合适样式,UIKit 要自己选控件。
11.6 Button 与 UIButton
SwiftUI:
Button("点击我") {
print("点击了")
}
带样式:
Button {
print("点击了")
} label: {
Label("添加", systemImage: "plus")
}
.buttonStyle(.borderedProminent)
UIKit:
let button = UIButton(type: .system)
button.setTitle("点击我", for: .normal)
button.addTarget(self, action: #selector(buttonTapped), for: .touchUpInside)
@objc private func buttonTapped() {
print("点击了")
}
SwiftUI 的按钮把“动作”和“标签”放在一起,UIKit 要分开设置。
十二、数据流与架构:从 @State 到 ObservableObject
12.1 简单状态用 @State
如果状态只在一个 View 内部使用,@State 就够了。
12.2 跨视图共享用 ObservableObject
当数据模型复杂、多个 View 需要共享时,可以创建一个类:
class TodoStore: ObservableObject {
@Published var todos: [String] = []
func add(_ todo: String) {
todos.append(todo)
}
func delete(at offsets: IndexSet) {
todos.remove(atOffsets: offsets)
}
}
在 View 中使用:
struct TodoListView: View {
@StateObject private var store = TodoStore()
@State private var newTodo = ""
var body: some View {
NavigationStack {
VStack {
HStack {
TextField("新待办", text: $newTodo)
.textFieldStyle(.roundedBorder)
Button("添加") {
guard !newTodo.isEmpty else { return }
store.add(newTodo)
newTodo = ""
}
}
.padding()
List {
ForEach(store.todos, id: \\.self) { todo in
Text(todo)
}
.onDelete(perform: store.delete)
}
}
.navigationTitle("待办清单")
}
}
}
12.3 UIKit 里对应什么?
UIKit 没有 ObservableObject 这种内建机制。你通常会用:
- 模型对象;
- 代理;
- 闭包;
- NotificationCenter;
- KVO;
- MVVM 里的 ViewModel。
例如一个简单的 UIKit ViewModel:
class TodoViewModel {
private(set) var todos: [String] = []
var onChange: (() -> Void)?
func add(_ todo: String) {
todos.append(todo)
onChange?()
}
func delete(at index: Int) {
todos.remove(at: index)
onChange?()
}
}
控制器里:
class TodoViewController: UITableViewController {
let viewModel = TodoViewModel()
override func viewDidLoad() {
super.viewDidLoad()
viewModel.onChange = { [weak self] in
self?.tableView.reloadData()
}
}
}
SwiftUI 的 @Published 和 @StateObject 把这种“变化通知”内建化了。你不需要手动写 onChange,SwiftUI 会自动观察。
12.4 单向数据流
SwiftUI 推崇单向数据流:
- 状态在某个地方定义;
- 状态通过绑定或环境向下传递;
- 子视图通过事件向上通知;
- 状态改变后,界面重新计算。
UIKit 也可以做单向数据流,但需要你自己搭建。SwiftUI 从框架层面鼓励这种模式,所以代码更容易保持清晰。
十三、综合小项目:待办清单
我们把前面学的串起来,做一个简单待办清单。先看 SwiftUI 完整版。
import SwiftUI
struct Todo: Identifiable {
let id = UUID()
var title: String
var isDone: Bool = false
}
class TodoStore: ObservableObject {
@Published var todos: [Todo] = [
Todo(title: "学习 SwiftUI"),
Todo(title: "写一个 Demo"),
Todo(title: "复习状态管理", isDone: true)
]
func add(title: String) {
todos.append(Todo(title: title))
}
func toggle(_ todo: Todo) {
if let index = todos.firstIndex(where: { $0.id == todo.id }) {
todos[index].isDone.toggle()
}
}
func delete(at offsets: IndexSet) {
todos.remove(atOffsets: offsets)
}
}
struct TodoRowView: View {
let todo: Todo
let onToggle: () -> Void
var body: some View {
HStack {
Image(systemName: todo.isDone ? "checkmark.circle.fill" : "circle")
.foregroundStyle(todo.isDone ? .green : .secondary)
.onTapGesture {
onToggle()
}
Text(todo.title)
.strikethrough(todo.isDone)
.foregroundStyle(todo.isDone ? .secondary : .primary)
Spacer()
}
.padding(.vertical, 4)
}
}
struct TodoListView: View {
@StateObject private var store = TodoStore()
@State private var newTitle = ""
var body: some View {
NavigationStack {
VStack(spacing: 0) {
HStack {
TextField("输入新待办", text: $newTitle)
.textFieldStyle(.roundedBorder)
Button {
guard !newTitle.trimmingCharacters(in: .whitespaces).isEmpty else { return }
store.add(title: newTitle)
newTitle = ""
} label: {
Image(systemName: "plus.circle.fill")
.font(.title2)
}
}
.padding()
List {
ForEach(store.todos) { todo in
TodoRowView(todo: todo) {
store.toggle(todo)
}
}
.onDelete(perform: store.delete)
}
.listStyle(.plain)
}
.navigationTitle("待办清单")
}
}
}
这个项目虽小,但包含了:
- 模型 Todo;
- 状态管理 TodoStore;
- @StateObject;
- @Published;
- List、ForEach;
- NavigationStack;
- TextField、Button;
- 自定义 Row;
- 删除、切换完成状态。
现在看 UIKit 的简化版,感受一下同样的功能需要多少代码。
import UIKit
struct Todo {
var title: String
var isDone: Bool = false
}
class TodoListViewController: UITableViewController {
private var todos: [Todo] = [
Todo(title: "学习 SwiftUI"),
Todo(title: "写一个 Demo"),
Todo(title: "复习状态管理", isDone: true)
]
private let newTodoField = UITextField()
override func viewDidLoad() {
super.viewDidLoad()
title = "待办清单"
tableView.register(UITableViewCell.self, forCellReuseIdentifier: "Cell")
navigationItem.rightBarButtonItem = UIBarButtonItem(
barButtonSystemItem: .add,
target: self,
action: #selector(addTapped)
)
let header = UIView(frame: CGRect(x: 0, y: 0, width: view.bounds.width, height: 56))
newTodoField.placeholder = "输入新待办"
newTodoField.borderStyle = .roundedRect
newTodoField.frame = header.bounds.insetBy(dx: 16, dy: 8)
newTodoField.autoresizingMask = [.flexibleWidth, .flexibleHeight]
header.addSubview(newTodoField)
tableView.tableHeaderView = header
}
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
todos.count
}
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath)
let todo = todos[indexPath.row]
cell.textLabel?.text = todo.title
cell.textLabel?.textColor = todo.isDone ? .secondaryLabel : .label
cell.accessoryType = todo.isDone ? .checkmark : .none
return cell
}
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
todos[indexPath.row].isDone.toggle()
tableView.reloadRows(at: [indexPath], with: .automatic)
}
override func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCell.EditingStyle, forRowAt indexPath: IndexPath) {
if editingStyle == .delete {
todos.remove(at: indexPath.row)
tableView.deleteRows(at: [indexPath], with: .automatic)
}
}
@objc private func addTapped() {
guard let text = newTodoField.text, !text.isEmpty else { return }
todos.append(Todo(title: text))
newTodoField.text = ""
let indexPath = IndexPath(row: todos.count – 1, section: 0)
tableView.insertRows(at: [indexPath], with: .automatic)
}
}
你看,UIKit 版本要手动管理:
- 表格注册;
- 数据源方法;
- 行点击;
- 删除;
- 添加按钮;
- 输入框;
- 刷新行;
- 插入行。
SwiftUI 版本则更多是在描述“界面长什么样”和“数据怎么变”。这就是声明式的威力。
你可以继续扩展 SwiftUI 待办清单:添加优先级、截止日期、分类、搜索、持久化等。你会发现,大多数扩展只是增加状态和 View,而不是增加一堆手动刷新逻辑。
十四、常见坑与调试建议
14.1 预览不刷新
检查:
- 是否选择了正确的 Preview;
- 是否有编译错误;
- 是否在 #Preview 里传入了必要参数。
新版预览:
#Preview {
ContentView()
}
14.2 修改了状态但界面没变
可能原因:
- 修改的不是 @State、@Published 等可观察状态;
- 在结构体里直接改了普通属性;
- 状态在子视图里是值拷贝,没有用 @Binding;
- 对象没有遵循 ObservableObject,或属性没有 @Published。
14.3 List 报错“ID 重复”
确保 ForEach 的 id 唯一。用 Identifiable 或指定唯一 id。
14.4 视图太复杂
把大 body 拆成小 View。SwiftUI 里,拆 View 几乎无成本,反而更清晰。
14.5 不要过度设计
第1课先求“能跑、能看懂”。架构可以慢慢学。先让界面动起来,再谈优化。
十五、第1课总结与练习
这一课我们讲了很多。我们回顾一下核心知识:
同时,我们也反复对比了 UIKit:
- UIKit 是命令式,SwiftUI 是声明式;
- UIKit 要创建视图、加约束、绑事件、手动刷新;
- SwiftUI 描述界面和状态,系统负责更新;
- UIKit 适合精细控制,SwiftUI 适合快速组合;
- 两者可以共存,不是非此即彼。
练习一:个人名片
分别用 SwiftUI 和 UIKit 做一个 ProfileCard,包含头像、姓名、职业、简介。SwiftUI 用 VStack、HStack、Image、Text;UIKit 用 UIStackView、UILabel、UIImageView。做完对比代码量。
练习二:计数器
SwiftUI 用 @State 做一个计数器;UIKit 用 UILabel、UIButton 和 target-action 做一个计数器。感受“自动刷新”和“手动刷新”的区别。
练习三:水果列表
创建一个水果数组,分别用 SwiftUI 的 List 和 UIKit 的 UITableView 显示。点击某个水果,跳转到详情页。
练习四:设置页面
用 SwiftUI 的 Form 做一个设置页,包含用户名输入框、通知开关、音量滑块、主题选择器。然后想一想,如果用 UIKit 的 UITableView,需要写多少数据源方法。
练习五:待办清单扩展
给综合项目的待办清单添加显示未完成数量、清空已完成、搜索框、优先级标签。SwiftUI 版本优先,学有余力再尝试 UIKit 版本。
下一课预告
第1课我们建立了 SwiftUI 的整体感觉,也看到了它和 UIKit 的差异。下一课,我们会更深入地学习:
- 布局系统到底怎么工作;
- frame、padding、Spacer 的优先级;
- 对齐指南 AlignmentGuide;
- 自定义布局 Layout 协议;
- 响应式设计与不同屏幕适配;
- 更复杂的列表和网格 LazyVGrid、LazyHGrid。
但在此之前,请一定把这一课的代码亲手敲一遍。SwiftUI 不是“看懂”就会的,它是“写会”的。你每改一个数字、每换一个修饰符、每加一个状态,都会对它有新的理解。
记住一句话:先让界面跑起来,再让它变漂亮,最后让它变聪明。
我们下一课见。


