网站首页 > 技术文章 正文
SwiftUI 本质论:声明式、可组合、状态驱动
引言:为什么是 SwiftUI,为什么是现在?
SwiftUI 的目标非常直接:用最短路径把你带到“优秀 App”。这个最短路径并不是偷工减料,而是让系统“替你干对的事”:
- o 原生控件 + 自适配(暗黑模式、动态字体、不同输入方式)。
- o 一套声明式语法表达你想要的结果,由框架完成如何实现。
- o 把你从“增删 cell / 手写状态同步 / 兼容多平台差异”的琐事里解放出来,把更多精力投入到独特功能与体验打磨。
要点回顾
o SwiftUI 提供控件(Button/Toggle/Picker)、容器(Stack/List/Form)、绘制、动画、手势。o 把平台特性语义化(macOS 菜单、watchOS 数字表冠、tvOS Siri Remote)。o 不是“Write Once, Run Anywhere”,而是“Learn Once, Apply Anywhere”。
目录
- 1. SwiftUI 的三根柱子:声明式 / 可组合 / 状态驱动
- 2. 从容器开始:VStack/HStack/ZStack/List/Form 与 ViewBuilder
- 3. 修饰符(Modifier)链:顺序即层级,语义即结果
- 4. 数据流入门:@State 与 @Binding 的双人舞
- 5. 控件的“语义化适配”:Button / Toggle / Picker 深入
- 6. 无障碍与环境(Accessibility & Environment):把“上下文”变成一等公民
- 7. 导航与结构:NavigationStack / NavigationSplitView / TabView(以及旧 API 兼容)
- 8. 跨平台要点:iOS / macOS / watchOS 的差异与统一
- 9. 完整实战:牛油果吐司订购 App(表单 + 历史 + 放蛋位拖拽 + 导航)
- 10. 最佳实践十条 & 常见坑排查
- 11. 思考题
- 12. 知识小结(TL;DR)
1. SwiftUI 的三根柱子:声明式 / 可组合 / 状态驱动
1.1 声明式(Declarative)
你描述 UI 的“是什么”,由 SwiftUI 负责**“怎么做”**。不再增/删 subview、不再手动 diff 列表,也不用为默认动画操心;你只需要保证状态正确。
类比:做牛油果吐司
- o 命令式:电话指导朋友每一步(取面包→烤→切牛油果…稍有失误全盘皆输)。
- o 声明式:找加州的牛油果工匠,说出期望与偏好,专家搞定细节。
1.2 可组合(Compositional)
用小视图搭大界面:容器(Stack/List/Form)+ 修饰符(Modifier)叠加。视图是值类型 struct,仅是“描述”。SwiftUI 内部有高效结构负责渲染与手势,拆分更多小组件几乎没有性能负担。
1.3 状态驱动(State-Driven)
UI = f(state)。谁被读取,谁是依赖;依赖改变,SwiftUI 重算 body 并最小化更新输出(屏幕/手势/可访问性)。
2. 从容器开始:ViewBuilder 与层级结构
容器视图(如 VStack/HStack/ZStack、List、Form)通过 @ViewBuilder 闭包声明孩子视图;你写出来的层级直观映射为 UI 结构。
# Swift
import SwiftUI
struct SimpleForm: View {
@State private var includeSalt = true
@State private var quantity = 1
var body: some View {
Form { // 与 VStack 同为容器,但对“表单”语义更友好
Section("基本配置") {
Toggle("加入盐", isOn: $includeSalt)
Stepper("数量:\(quantity)", value: $quantity, in: 1...6)
}
Section {
Button("下单") { /* 提交 */ }
}
}
.navigationTitle("牛油果吐司")
}
}
注意:容器切换(VStack→Form)无需改变内部控件定义;系统会按上下文自动适配背景、分隔线、按压态等细节。
3. 修饰符(Modifier)链:顺序就是层级
修饰符是“返回新视图的函数”,围绕基础视图包裹一层层“外衣”。
顺序决定层级与效果:
- o Text("Hi").padding().background(.green) → 背景包住文字+内边距
- o Text("Hi").background(.green).padding() → 绿色仅包住文字,外面再加空白
# Swift
struct ModifierOrderDemo: View {
var body: some View {
VStack(spacing: 16) {
Text("背景包住 padding")
.padding()
.background(.green.opacity(0.2))
Text("背景只包住文字")
.background(.green.opacity(0.2))
.padding()
}
.padding()
}
}
实践建议
- o 把“可复用的修饰符串”封装为自定义 ViewModifier,统一风格、减少重复。
- o 需要连贯动画时,尽量“把条件放进修饰符的参数”,而不是 if 切换不同视图类型(避免不必要的淡入淡出)。
4. 数据流入门:@State与@Binding的双人舞
4.1@State:视图的内部状态
由 SwiftUI 持有存储;本视图读写;改变即重算 body。
# Swift
struct Counter: View {
@State private var count = 0
var body: some View {
VStack {
Text("\(count)").font(.largeTitle).contentTransition(.numericText())
HStack {
Button("-") { withAnimation { count -= 1 } }
Button("+") { withAnimation { count += 1 } }
}
.buttonStyle(.borderedProminent)
}
}
}
4.2@Binding:双向引用外部状态
父视图拥有“事实来源”,子视图通过 Binding 读写。
# Swift
struct StepperEditor: View {
@Binding var value: Int
var body: some View {
Stepper("数量:\(value)", value: $value, in: 1...6)
}
}
struct Host: View {
@State private var qty = 1
var body: some View {
StepperEditor(value: $qty) // 传入 Binding
}
}
记忆:本地数据用 @State;父子共享用 @Binding;跨层共享再上升到 ObservableObject 家族(详见延伸阅读)。
5. 控件的“语义化适配”:Button / Toggle / Picker
5.1 Button:动作 + 标签
语义是“带标签的动作”,因此在不同上下文(滑动、菜单、表单)自动“换装”,但含义不变。
# Swift
Button {
// 动作:提交订单
} label: {
Label("下单", systemImage: "cart")
}
.buttonStyle(.borderedProminent) // 可换 .borderless / .bordered / .plain / 自定义 style
.tint(.green)
5.2 Toggle:开/关 + 标签(带Binding)
不仅外观可适配(开关/复选/切换按钮),而且自动接入辅助功能(VoiceOver 会读出标签和状态)。
# Swift
@State private var includeSalt = true
Toggle("加入盐", isOn: $includeSalt)
5.3 Picker:选项集合 + 选择值 + 标签
selection 是 Binding;选项用 ForEach 数据驱动;样式可在不同平台/上下文自适配或手动指定。
# Swift
enum Bread: String, CaseIterable, Identifiable { case sourdough, bagel, brioche
var id: Self { self }
}
@State private var bread: Bread = .sourdough
Picker("面包", selection: $bread) {
ForEach(Bread.allCases) { kind in
Text(kind.rawValue.capitalized).tag(kind)
}
}
// iOS 表单中默认“导航式”选择;也可强制 wheel 或 segmented
.pickerStyle(.menu) // .segmented / .inline / .wheel / .navigationLink 等
为什么 SwiftUI 控件“少而精”?
控件围绕“目的/角色”定义,而不是“外观”。在语义不变的前提下,自适配不同平台与场景,API 面更小更稳定,你也更容易迁移复用。
6. 无障碍(Accessibility)与环境(Environment)
6.1 无障碍基础
- o 为非文本标签提供 .accessibilityLabel 或使用 Label。
- o VoiceOver / Voice Control 会自动读出控件的目的与状态(因其语义化定义)。
# Swift
Image("egg")
.accessibilityLabel("水波蛋")
6.2 环境(Environment):把上下文注入为“一等公民”
环境值描述“视图所处环境”(布局方向、色彩方案、是否启用、地区语言等)。
- o 视图继承父环境;
- o 你可 .environment(\.xxx, value) 覆写子树;
- o 自定义视图也可读环境,自动响应变化。
# Swift
struct EggPlacement: View {
@Environment(\.isEnabled) private var isEnabled // 读取是否可交互
@State private var offset: CGSize = .zero
var body: some View {
ZStack {
Image("toast")
Image("egg")
.offset(offset)
.gesture(
DragGesture().onChanged { offset = $0.translation }
)
.saturation(isEnabled ? 1.0 : 0.0) // 禁用时去饱和
}
.frame(height: 220)
}
}
// 一键禁用子树(含手势)
Form { /* ... */ }
.disabled(true)
7. 导航与结构:NavigationStack/NavigationSplitView/TabView
API 更新提示
o 早年示例里出现的 NavigationView、NavigationLink(字幕里甚至有旧称“Navigationbutton”)在 iOS 16+ 逐步过渡到新的**NavigationStack/NavigationSplitView** 架构与值类型导航。o 若你的 minOS 较新,优先学习并使用 NavigationStack。
7.1 单栈导航:NavigationStack + NavigationLink
# Swift
struct Root: View {
var body: some View {
NavigationStack {
List(0..<10) { i in
NavigationLink("订单 #\(i)") {
OrderDetail(id: i)
}
}
.navigationTitle("订单历史")
}
}
}
7.2 分栏导航:NavigationSplitView(iPad/macOS 最佳)
# Swift
struct SplitRoot: View {
@State private var selection: Int?
var body: some View {
NavigationSplitView {
List(0..<50, selection: $selection) { i in
Text("订单 #\(i)")
}
} detail: {
if let id = selection {
OrderDetail(id: id)
} else {
Text("请选择订单")
}
}
}
}
7.3 Tab 结构
# Swift
TabView {
OrderFormView()
.tabItem { Label("下单", systemImage: "cart") }
OrderHistoryView()
.tabItem { Label("历史", systemImage: "clock") }
}
8. 跨平台要点:iOS / macOS / watchOS 的差异与统一
- o iOS:表单风格(Form)+ 导航行式 Picker。
- o macOS:信息密度更高;Picker 常见 menu/radioGroup;窗口与菜单特性丰富。
- o watchOS:旋转表冠 digitalCrownRotation 交互、密度极简。
- o 共同点:控件“语义化”定义,保证“学一次、到处用”。
# Swift (watchOS 示例)
struct CrownStepper: View {
@State private var value: Double = 3
var body: some View {
Text("分数:\(Int(value))")
.digitalCrownRotation($value, from: 0, through: 10, by: 1)
.focusable() // Watch 交互聚焦
}
}
9. 完整实战:牛油果吐司订购 App
目标:
o 表单下单(面包/抹酱/加盐/数量);o 选择“是否加蛋”与拖拽放置位置;o 订单历史列表 & 详情;o Tab 结构 + 栈式导航;o 可访问性/禁用态/环境值;o 现代 API(NavigationStack)。
9.1 模型与枚举
# Swift
import SwiftUI
enum Bread: String, CaseIterable, Identifiable {
case sourdough, bagel, brioche
var id: Self { self }
var label: String { switch self {
case .sourdough: return "酸面包"
case .bagel: return "贝果"
case .brioche: return "布里欧修"
}}
}
enum Spread: String, CaseIterable, Identifiable {
case butter, hummus, creamCheese, peanut
var id: Self { self }
var label: String { ["黄油","鹰嘴豆泥","奶油奶酪","花生酱"][Self.allCases.firstIndex(of: self)!] }
}
struct Order: Identifiable, Hashable {
let id: UUID = .init()
var bread: Bread
var spread: Spread
var includeSalt: Bool
var includeEgg: Bool
var eggOffset: CGSize? // 蛋位置(可选)
var quantity: Int
var time: Date = .init()
var summary: String {
"\(bread.label) + \(spread.label) \(includeSalt ? "加盐" : "无盐") ×\(quantity)" + (includeEgg ? " + 鸡蛋" : "")
}
}
9.2 订单状态 Store(演示用@State,可扩展ObservableObject)
# Swift
@MainActor
final class OrderStore: ObservableObject {
@Published var current = Order(
bread: .sourdough,
spread: .butter,
includeSalt: true,
includeEgg: false,
eggOffset: nil,
quantity: 1
)
@Published var history: [Order] = []
func submit() {
history.insert(current, at: 0)
// 重置当前订单(保留部分偏好)
current.quantity = 1
current.includeEgg = false
current.eggOffset = nil
}
}
9.3 放蛋位拖拽视图
# Swift
struct EggPlacementView: View {
@Environment(\.isEnabled) private var isEnabled
@Binding var offset: CGSize?
var body: some View {
ZStack {
Image("toast").resizable().scaledToFit()
Image("egg")
.resizable().scaledToFit()
.frame(width: 60, height: 60)
.offset(offset ?? .zero)
.gesture(
DragGesture().onChanged { gesture in
offset = gesture.translation
}
)
.saturation(isEnabled ? 1 : 0) // 被禁用时视觉降饱和
.accessibilityLabel("水波蛋位置")
}
.frame(height: 220)
.padding(.vertical)
}
}
提示:
o .disabled(true) 能“一键”禁用手势与控件;o 自定义视图可通过环境读取 isEnabled 生成视觉反馈。
9.4 下单表单
# Swift
struct OrderFormView: View {
@EnvironmentObject var store: OrderStore
@State private var networkOK = true // 演示禁用态:假设网络状态
var body: some View {
Form {
Section("基本配置") {
Picker("面包", selection: $store.current.bread) {
ForEach(Bread.allCases) { b in Text(b.label).tag(b) }
}
Picker("抹酱", selection: $store.current.spread) {
ForEach(Spread.allCases) { s in Text(s.label).tag(s) }
}
Toggle("加入盐", isOn: $store.current.includeSalt)
Stepper("数量:\(store.current.quantity)", value: $store.current.quantity, in: 1...6)
}
Section("加鸡蛋") {
Toggle("需要鸡蛋", isOn: $store.current.includeEgg.animation()) // 动画插入行
if store.current.includeEgg {
NavigationLink("放置位置") {
EggPlacementView(offset: $store.current.eggOffset)
.navigationTitle("放置鸡蛋")
.toolbar {
Button("归位") { store.current.eggOffset = .zero }
}
}
}
}
Section {
Button {
store.submit()
} label: {
Label("下单", systemImage: "cart.fill")
}
.buttonStyle(.borderedProminent)
.tint(.green)
.disabled(!networkOK || store.current.quantity == 0)
} footer: {
Text(networkOK ? "下单将保存到历史" : "网络不可用,表单已禁用")
}
}
.navigationTitle("牛油果吐司")
.accentColor(.green) // 可作用于整棵子树
.disabled(!networkOK && true) // 演示:一键禁用整棵表单(含手势)
.toolbar {
Button {
networkOK.toggle()
} label: {
Label(networkOK ? "断网" : "联网", systemImage: networkOK ? "wifi.slash" : "wifi")
}
}
}
}
9.5 历史列表与详情
# Swift
struct OrderHistoryView: View {
@EnvironmentObject var store: OrderStore
var body: some View {
List {
ForEach(store.history) { order in
NavigationLink(value: order) {
HStack {
VStack(alignment: .leading) {
Text(order.summary)
Text(order.time, style: .time).font(.caption).foregroundStyle(.secondary)
}
Spacer()
if order.includeEgg { Image("egg").resizable().frame(width: 20, height: 20) }
}
.accessibilityElement(children: .combine)
.accessibilityLabel("订单 \(order.summary)")
}
}
}
.navigationTitle("订单历史")
}
}
struct OrderDetail: View {
let order: Order
var body: some View {
VStack(spacing: 16) {
Text(order.summary).font(.title3)
EggPlacementView(offset: .constant(order.eggOffset))
.disabled(true)
Spacer()
}
.padding()
.navigationTitle("订单详情")
}
}
9.6 应用入口:Tab + Stack 导航
# Swift
@main
struct AvocadoToastApp: App {
@StateObject private var store = OrderStore()
var body: some Scene {
WindowGroup {
NavigationStack {
TabView {
OrderFormView()
.tabItem { Label("下单", systemImage: "cart") }
OrderHistoryView()
.tabItem { Label("历史", systemImage: "clock") }
}
.navigationDestination(for: Order.self) { OrderDetail(order: $0) }
}
.environmentObject(store)
}
}
}
运行提示:将 Assets 添加 toast 与 egg 两张图片;无图也可将图片换成 SF Symbols(例如 Image(systemName: "frying.pan"))以快速运行。
10. 最佳实践十条 & 常见坑排查
10.1 最佳实践
- 1. 小组件化:OrderRow、EggPlacementView、StepperEditor 等拆小,复用、预览、测试都更顺手。
- 2. 把条件放进修饰符参数:需要连续动画/过渡时,尽量不要用 if 切视图类型。
- 3. 单一事实来源:局部 @State → 父子 @Binding → 跨层 ObservableObject。避免状态复制。
- 4. 预览先行:为关键视图写多种 #Preview(深色/大字/RTL),减少真机反复跑。
- 5. 列表 id 稳定:Identifiable 或 .id 保证稳定,避免闪烁与错误动画。
- 6. 动画粒度:withAnimation{} 包裹改变状态的地方,小步快跑,避免整页抖动。
- 7. 环境驱动:优先用 Environment 与 .disabled/.tint/.accentColor 等向下渗透策略。
- 8. 导航用新栈:能用 NavigationStack/NavigationSplitView 就不要新项目里继续 NavigationView。
- 9. 平台分支:必要时用 #if os(macOS) 等分支,逻辑与 UI 解耦。
- 10. 互操作边界清晰:需要 UIKit 控件时用 UIViewRepresentable 包装,生命周期集中在 Coordinator。
10.2 常见坑
- o “改了数据 UI 不刷”:确认该数据是否在 body 被读取;ObservableObject 记得 @Published;拥有者用 @StateObject。
- o “Binding 传错”:子视图参数是 @Binding,父级需传 $state,而非拷贝值。
- o “动画变成淡入淡出”:你很可能 if 切换了两种不同视图类型;把条件移入 modifier 的参数。
- o “列表刷新错位”:id 不稳定或重复;或对数据做了非等价替换。
- o “预览总编不过”:缺资源/条件编译;先用最小依赖跑通预览,再逐项打开。
11. 知识小结
- o 声明式:描述“结果”,由框架“实现过程”;UI = f(state)。
- o 可组合:小视图 + 容器 + 修饰符;视图是值类型,拆分不损性能。
- o 状态驱动:@State(本地)→ @Binding(父子)→ ObservableObject(跨层)。
- o 修饰符链:顺序即层级;善用自定义 ViewModifier 复用风格。
- o 控件语义化:Button/Toggle/Picker 在不同上下文自适配;你专注“目的”,系统负责“外观”。
- o 无障碍与环境:标签/状态天然可被 VoiceOver 等读取;Environment 让上下文传递与覆写变容易。
- o 导航现代化:NavigationStack / NavigationSplitView + TabView 组合,覆盖 iPhone/iPad/macOS。
- o 跨平台:Learn Once, Apply Anywhere;必要处做平台分支与样式覆写。
- o 迁移策略:从新功能或单页开始引入;UIKit 控件可互嵌;保持所有权与状态边界清晰。
猜你喜欢
- 2025-09-18 用户界面开发工具集Telerik UI for ASP.NET AJAX发布Q1 2016
- 2025-09-18 实战网页实现平滑滚动_网页制作滑动效果怎么弄
- 2024-08-04 团队建设知识分享之案例(团队建设 案例)
- 2024-08-04 SwiftUI入门三:处理用户输入(swift输入输出)
- 2024-08-04 收藏帖!零基础如何学习创建Salesforce屏幕流?
- 2024-08-04 神奇的交互设计工具 Principle 中文文档
- 2024-08-04 FreeBSD-常用软件推荐(freebsd常用命令)
- 2024-08-04 前端基石:DOM事件监听的艺术(前端tofixed)
- 2024-08-04 VS Code 1.27 发布:全新设置UI,菜单,导航及其他功能
- 2024-08-04 Bootstrap 导航元素(bootstrap导航条)
- 最近发表
- 标签列表
-
- cmd/c (90)
- c++中::是什么意思 (84)
- 标签用于 (71)
- 主键只能有一个吗 (77)
- c#console.writeline不显示 (95)
- pythoncase语句 (88)
- es6includes (74)
- sqlset (76)
- apt-getinstall-y (100)
- node_modules怎么生成 (87)
- chromepost (71)
- flexdirection (73)
- c++int转char (80)
- mysqlany_value (79)
- static函数和普通函数 (84)
- el-date-picker开始日期早于结束日期 (76)
- js判断是否是json字符串 (75)
- c语言min函数头文件 (77)
- asynccallback (87)
- localstorage.removeitem (74)
- vector线程安全吗 (70)
- java (73)
- js数组插入 (83)
- mac安装java (72)
- 无效的列索引 (74)