声明式心智模型
UIKit 是"拿到一个 label,设置它的文字";SwiftUI 是"如果 count 是 5,就显示 5"。你描述界面与状态的对应关系,框架负责在状态变化时高效地刷新。
SwiftUI 的 View 是结构体(值类型),body 每次被调用都重建一份视图描述。它不是"屏幕上的控件",而是"当前应当如何展示"的说明书。框架用差异算法比较前后两份描述,只更新真正变化的部分。
View 协议与视图
所有界面元素都遵循 View 协议,要求实现一个 body 属性,返回 some View。
import SwiftUI
struct WelcomeView: View {
var body: some View {
VStack(spacing: 16) {
Image(systemName: "star.fill")
.font(.largeTitle)
.foregroundStyle(.yellow)
Text("欢迎")
.font(.title.bold())
Button("开始") { print("tapped") }
}
.padding()
}
}
状态管理
状态属性包装器决定"这块数据存在哪、谁拥有、变化了怎么通知视图"。选错会导致视图不刷新或数据串台。
| 包装器 | 归属 | 用途 |
|---|---|---|
@State | 视图私有 | 视图自己拥有、会变化的值(如输入框文字、开关) |
@Binding | 引用他人 | 子视图读写父视图的 @State,不拥有数据 |
@StateObject / @ObservedObject | 引用类型对象 | 监听 ObservableObject(旧范式) |
@Environment | 环境注入 | 主题、尺寸类别等跨层级共享值 |
@Observable(iOS 17+) | 可观察对象 | 新范式:直接标记 class,字段变化自动驱动视图 |
// 父视图拥有状态
struct Parent: View {
@State private var count = 0
var body: some View {
VStack {
Text("计数:\(count)")
CounterButton(count: $count) // $ 传绑定
}
}
}
// 子视图通过 Binding 读写
struct CounterButton: View {
@Binding var count: Int
var body: some View {
Button("加一") { count += 1 }
}
}
数据流向(@Observable 新范式)
iOS 17 起官方主推 @Observable 宏:给 class 加注解后,其被读取的字段变化会自动触发依赖它的视图刷新,不再需要 @Published。
import Observation
@Observable
final class CartModel {
var items: [Product] = []
var total: Int { items.reduce(0) { $0 + $1.price } }
func add(_ p: Product) { items.append(p) }
}
struct CartView: View {
@State private var model = CartModel() // 持有可观察对象
var body: some View {
List(model.items) { item in
Text(item.name)
}
.onTapGesture { model.add(sample) } // 变化自动刷新列表
}
}
旧写法用 ObservableObject + @Published,视图侧用 @StateObject/@ObservedObject。新 @Observable 更简洁,但仅 iOS 17+ 可用;维护老项目时仍需理解旧范式。
布局系统
SwiftUI 布局是"父提议尺寸 → 子回报尺寸 → 父定位"的三步协议。常用栈(VStack/HStack/ZStack)、frame、Spacer 与 GeometryReader 表达。
VStack(alignment: .leading, spacing: 12) {
Text("标题").font(.headline)
Text("副标题").foregroundStyle(.secondary)
Spacer() // 把下方内容推到底部
}
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
// GeometryReader 读取可用空间
GeometryReader { proxy in
Rectangle().fill(.blue)
.frame(width: proxy.size.width * 0.5)
}
列表与导航
现代导航用 NavigationStack + navigationDestination,列表用 List 配合 ForEach 与可识别元素。
NavigationStack {
List(products) { product in
NavigationLink(value: product) {
Text(product.name)
}
}
.navigationTitle("商品")
.navigationDestination(for: Product.self) { p in
ProductDetailView(product: p)
}
}
列表元素需可唯一标识:让模型遵循 Identifiable(提供 id),或传 id: \.id 键路径。错误使用会引起列表复用混乱与动画异常。
动画与转场
动画在 SwiftUI 里是"声明状态变化如何过渡",而不是"手动驱动每一帧"。withAnimation 让状态变化带上过渡。
@State private var expanded = false
Button("展开") { withAnimation(.spring) { expanded.toggle() } }
if expanded {
DetailView().transition(.opacity.combined(with: .move(edge: .top)))
}
与 UIKit 互操作
当 SwiftUI 缺少某个控件(如复杂文本编辑器、第三方 UIKit 组件)时,用 UIViewRepresentable / UIViewControllerRepresentable 包一层。
struct ActivityIndicator: UIViewRepresentable {
var animating: Bool
func makeUIView(context: Context) -> UIActivityIndicatorView {
UIActivityIndicatorView(style: .large)
}
func updateUIView(_ view: UIActivityIndicatorView, context: Context) {
animating ? view.startAnimating() : view.stopAnimating()
}
}
常见陷阱与 FAQ
多半是状态归属错了:把应放在 @Observable 对象或 @State 里的数据放成了普通 let/var,或子视图拿到了值的副本而非绑定。确保被观察的字段确实是可观察对象的属性,且视图读取了它。
谁创建对象谁用 @StateObject(拥有生命周期);从外部传入则视图用 @ObservedObject(不拥有)。用错可能导致对象被反复重建。新项目直接用 @Observable 可避开这套区分。
不应在 body 里做副作用,因为 body 可能被频繁重建。用 .task { } 修饰符(视图出现时启动 Task,消失时自动取消)来触发异步工作。