第 7 章 组件化:从一堆修饰符到一个零件

把重复的修饰符、重复的布局、重复的交互各抽成一种东西:ViewModifier、@ViewBuilder 容器、Style 协议,以及怎么选

第 7 章:组件化

前六章解决的是"一个界面怎么正确做出来"。这一章解决另一个问题:做第二个界面时,怎么不把第一个界面的代码抄一遍。

7.1 先从最烦的事开始

写到一个真实 App 的中期,你的代码里会长出这种东西:

1
2
3
4
5
// 第 1 处
Text("余额").padding(12).background(.white, in: RoundedRectangle(cornerRadius: 10)).shadow(radius: 2)
// 第 2 处
Text("本月支出").padding(12).background(.white, in: RoundedRectangle(cornerRadius: 10)).shadow(radius: 2)
// 第 3 处……第 30 处

那个 .padding(12).background(.white, in: RoundedRectangle(cornerRadius: 10)).shadow(radius: 2) 就是一个已经存在、但还没有名字的组件。

第 1 章讲过"通用修饰符是包壳"(ModifiedContent),当时那只是理解布局的钥匙。现在它变成了一把工程上的刀:既然是一层壳,就能给它起个名字。

⚠️ 但要小心一个常见误区:组件化不是"把重复的代码抽成函数"那么简单。 SwiftUI 里"重复的东西"分三类,抽法完全不同:

重复的是什么抽成什么本章位置
一串修饰符(外观)ViewModifier7.2
一段布局结构(容器)泛型容器视图7.4
一套交互行为(控件长什么样、怎么响应)Style 协议7.5

抽错了类型,代码会变得比不抽更难维护。这一章就是把这三种分清楚。

7.2 ViewModifier:给一串修饰符起名字

最小可用形态

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
import SwiftUI

struct CardStyle: ViewModifier {
    func body(content: Content) -> some View {
        content
            .padding(12)
            .frame(maxWidth: .infinity, alignment: .leading)
            .background(.white, in: RoundedRectangle(cornerRadius: 10))
            .shadow(color: .black.opacity(0.08), radius: 2, y: 1)
    }
}

用的时候:

1
Text("余额").modifier(CardStyle())

⚠️ 第 1 个反直觉点:body(content:) 的参数叫 content,类型是 Content——它是调用方的那个视图。所以 ViewModifier 的本质不是"提取样式",而是"声明"我要把这个视图包起来"。content 出现的位置决定了一切:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
struct A: ViewModifier {
    func body(content: Content) -> some View {
        content.padding()          // 内边距加在内容外面 ✅
    }
}
struct B: ViewModifier {
    func body(content: Content) -> some View {
        Color.red.overlay(content) // content 变成了"叠上去的那层"
    }
}

💭 一个方便的记忆法:ViewModifier 的 body 里,content 就是"你原来写的那个东西"。 你把它放在哪,它就出现在哪。

加一层语法糖:View 扩展

Text("余额").modifier(CardStyle()) 里的 .modifier(...) 有点啰嗦。惯例是再包一个扩展:

1
2
3
4
5
extension View {
    func cardStyle() -> some View {
        modifier(CardStyle())
    }
}

现在可以用得跟内置修饰符一样:

1
2
Text("余额").cardStyle()
Text("本月支出").cardStyle()

🔥 这一步是社区标准做法:ViewModifier 负责实现,View 扩展负责调用体验。库作者几乎都这么写。

⚠️ 命名建议:给修饰符起一个"形容词"名字(cardStyle()、primaryButton()),而不是"动作"名字(addPaddingAndBackground())。因为调用点上它读起来是在描述结果,这和 SwiftUI 的声明式风格一致。

带参数的修饰符

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
struct BadgeStyle: ViewModifier {
    let color: Color
    var compact: Bool = false

    func body(content: Content) -> some View {
        content
            .font(compact ? .caption2 : .caption)
            .padding(.horizontal, compact ? 4 : 8)
            .padding(.vertical, 2)
            .background(color.opacity(0.15), in: Capsule())
            .foregroundStyle(color)
    }
}

extension View {
    func badge(_ color: Color = .blue, compact: Bool = false) -> some View {
        modifier(BadgeStyle(color: color, compact: compact))
    }
}
1
2
Text("新增").badge(.green)
Text("3").badge(.red, compact: true)

💭 给参数默认值很重要——它让"最常用的那种"变成零参数调用,同时也保留了全部灵活性。

什么时候不该用 ViewModifier

情况更好的做法
只有一处用到别抽。抽了只会让你少看一层就要跳一次文件
抽完参数超过 4 个它在表达"好几种不同的东西",考虑拆成多个,或换成容器视图
需要读被包装视图的布局结果ViewModifier 拿不到 content 的尺寸。用 Layout(第 4 章)或 GeometryReader
需要往上层传信息用 PreferenceKey(7.6 节)

🔥 最后一条值得记:ViewModifier 是"向下包",不是"向上报"。 数据流方向和它的能力绑定死了。

7.3 @ViewBuilder:让一个参数收下多个视图

从第 1 章开始你就一直在用 @ViewBuilder(VStack { } 里那个花括号就是它),但一直没说它到底是什么。

它的作用是:允许这个参数接受"若干行视图",然后自动打包成一个值。

1
@ViewBuilder var content: Content      // 声明长这样,Content 是泛型参数

如果没有 @ViewBuilder,你就只能传一个视图;有了它,可以写多行、if、switch、ForEach。

手动用一次

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
struct Row<Content: View>: View {              // ← 泛型参数
    var title: String
    @ViewBuilder var accessory: Content        // ← 存储属性用泛型参数

    var body: some View {
        HStack {
            Text(title)
            Spacer()
            accessory
        }
    }
}

调用方可以传 0 行、1 行或多行:

1
2
3
4
5
6
7
8
VStack {
    Row(title: "静音") { Image(systemName: "speaker.slash") }
    Row(title: "纯文本") { }                    // 什么都不传也合法
    Row(title: "两个图标") {
        Image(systemName: "star")
        Image(systemName: "heart")
    }
}

⚠️ 注意上面 Row 的声明里那两个关键点,缺一个都编译不过:

写法结果
struct Row<Content: View> + @ViewBuilder var accessory: Content✅
struct Row + @ViewBuilder var accessory: some View🛑 property declares an opaque return type, but has no initializer expression from which to infer an underlying type
struct Row + @ViewBuilder var accessory: some View { ... }✅ 但这是计算属性,调用方传不了内容

@ViewBuilder 支持哪些写法

你写的打包成类型确定吗
一行就是那个视图✅
多行TupleView<(...)>✅
if(无 else)Optional<A>✅
if/else_ConditionalContent<A, B>✅
switch嵌套的 _ConditionalContent✅
空EmptyView✅

⚠️ 关键限制:分支数量有限制。_ConditionalContent 是"二选一"的嵌套结构,所以:

  • 2 个分支 → 1 层嵌套,没问题;
  • 10 个分支 → 9 层嵌套,类型检查器会变得很慢,甚至报"expression too complex"。

🔥 分支超过 4~5 个时,改用 switch 里返回同一种包装类型、或者干脆查表:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// 🛑 10 个 if/else 分支 —— 类型检查器要展开 9 层 ConditionalContent
@ViewBuilder var icon: some View {
    if kind == .a { Image(systemName: "a") }
    else if kind == .b { Image(systemName: "b") }
    // …… 再来 8 个
}

// ✅ 先算出名字,再走一个分支
var icon: some View {
    let name: String = switch kind {
        case .a: "a"
        case .b: "b"
        default: "questionmark"
    }
    Image(systemName: name)
}

💭 这是个真实的编译性能陷阱,不是风格洁癖。SwiftUI 项目里"某个文件编译特别慢",十有八九是某个 body 里的深嵌套 if/else。

@ViewBuilder 也能用在计算属性上

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
struct StatusView: View {
    var isOn: Bool

    @ViewBuilder
    private var indicator: some View {          // 注意:计算属性上也能标
        if isOn {
            Label("运行中", systemImage: "checkmark.circle.fill").foregroundStyle(.green)
        } else {
            Label("已停止", systemImage: "pause.circle").foregroundStyle(.secondary)
        }
    }

    var body: some View {
        HStack { indicator; Spacer() }
    }
}

🔥 这是拆大 body 的首选手法:一个 body 超过一屏时,把里面的段落抽成带 @ViewBuilder 的私有计算属性。比抽成子视图更轻——不需要传参数,直接读 self 的属性。

7.4 自定义容器:把布局结构也抽出来

ViewModifier 只能包装一个视图。如果要抽的是"怎么排列多个子视图",就需要一个泛型容器:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
struct SectionCard<Content: View>: View {
    var title: String
    @ViewBuilder var content: Content

    var body: some View {
        VStack(alignment: .leading, spacing: 8) {
            Text(title)
                .font(.headline)
            content                       // 调用方给的内容原样放进来
        }
        .padding(16)
        .frame(maxWidth: .infinity, alignment: .leading)
        .background(.background, in: RoundedRectangle(cornerRadius: 12))
        .overlay(RoundedRectangle(cornerRadius: 12).stroke(.separator, lineWidth: 1))
    }
}

用法:

1
2
3
4
5
6
SectionCard(title: "本月概览") {
    Text("支出 ¥3,240")
    Text("收入 ¥12,000")
    Divider()
    Text("结余 ¥8,760").bold()
}

三个必须注意的点

① 存储属性必须用泛型参数,不能用 some View。

1
2
3
4
5
struct SectionCard<Content: View>: View {     // ← 泛型参数
    var title: String
    @ViewBuilder var content: Content          // ← 用 @ViewBuilder 收多行
    var body: some View { /* ... */ }
}

⚠️ 原因是:some View 是不透明类型,它需要一个初始化表达式来让编译器推断"到底是哪个类型"。而"由调用方传进来的内容"恰恰没有初始化表达式。所以:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// ✅ 计算属性 + some View:合法(有 body 表达式可供推断)
var badge: some View { Text("新") }

// ✅ 存储属性 + 泛型参数:合法(Content 是类型参数,由调用方确定)
struct Box<Content: View>: View {
    @ViewBuilder var content: Content
    var body: some View { content }
}

// 🛑 存储属性 + some View:不合法
struct Bad: View {
    @ViewBuilder var content: some View
    var body: some View { content }
}
1
2
error: property declares an opaque return type, but has no initializer expression
       from which to infer an underlying type [#OpaqueTypeInference]

💭 一句话记:some View 只配"有实现的"计算属性;要接收调用方传来的内容,必须写 <Content: View>。

② 用泛型会带来一个代价:类型膨胀。

每个不同的 Content 都会生成不同的 SectionCard<...> 类型。如果 SectionCard 出现在一个 List 的 ForEach 里、每行的内容类型还不一样,编译器要为每种组合做类型检查。这就是"泛型容器用多了编译慢"的来源。

⚠️ 什么时候该换成 AnyView?基本不该。 AnyView 会擦除类型、增加运行期开销、还让视图身份不稳定(影响动画和状态保留)。只有在"实在无法用泛型表达"时才用它,且要在注释里写明原因。

③ 想让容器对子视图有更多控制,就上 Layout。

泛型容器只能"把 content 放进去"。如果容器需要逐个处理子视图(比如自己做流式换行、或者给每个子视图加不同的间距),那就超出泛型容器的能力了——第 4 章的 Layout 协议才是那个工具。

需求工具
把一段内容包起来,加点装饰ViewModifier
接收若干行内容,按固定结构排列泛型容器 + @ViewBuilder
要按自己的算法摆放子视图Layout 协议
要做出 SwiftUI 里没有的控件7.5 的 Style 协议

7.5 Style 协议:自定义控件的"皮肤"

Button、Toggle、Label、ProgressView 这些控件都支持换皮肤,而且换皮肤之后行为完全保留(点击区域、高亮、无障碍、键盘支持等等)。

这就是 Style 协议系列存在的理由——它是"组件化"里最容易被忽略、但收益最大的一类。

ButtonStyle:让按钮长得不一样但行为一样

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
struct PressableButtonStyle: ButtonStyle {
    var tint: Color = .accentColor

    func makeBody(configuration: Configuration) -> some View {
        configuration.label                    // ← 按钮的内容(你写的那个 Label)
            .font(.headline)
            .foregroundStyle(.white)
            .padding(.horizontal, 20)
            .padding(.vertical, 12)
            .background(tint, in: RoundedRectangle(cornerRadius: 10))
            .scaleEffect(configuration.isPressed ? 0.96 : 1)      // ← 按下状态
            .opacity(configuration.isPressed ? 0.9 : 1)
            .animation(.easeOut(duration: 0.12), value: configuration.isPressed)
    }
}
1
2
Button("确认支付") { pay() }
    .buttonStyle(PressableButtonStyle(tint: .green))

🔥 ButtonStyle 的核心价值:configuration 给你两样东西——

成员是什么
configuration.label调用方写的按钮内容(Text、Label、HStack 都行)
configuration.isPressed当前是否按下
configuration.role按钮的语义角色(.destructive 等,可用来换配色)

⚠️ 注意:ButtonStyle 里不能用 .buttonStyle() 改回默认样式(会递归),要"基于默认样式改"得用 .buttonStyle(.bordered) 之后再用 .tint() 之类的修饰符调整。

💭 ButtonStyle 比"自己写一个 HStack + .onTapGesture“好在哪?

自定义 ButtonStyle自己拼 HStack + onTapGesture
点击区域自动包含整个 label 及 padding要自己保证
按下高亮configuration.isPressed要自己用 DragGesture 模拟
无障碍语义自动是"按钮”要自己加 .accessibilityAddTraits(.isButton)
键盘/辅助设备自动支持通常缺失
与系统一致性好差

⚠️ 这张表是这一章最值钱的东西:很多人为了"按钮长得特别点"就自己拼一个,结果悄悄丢掉了无障碍和辅助设备支持。能用 Style 就用 Style。

其他 Style 协议

协议用于Configuration 提供
ButtonStyleButtonlabel、isPressed、role
PrimitiveButtonStyle需要自己触发动作的按钮(如长按)label、trigger()
ToggleStyleToggleisOn(Binding)、label
LabelStyleLabelicon、title
ProgressViewStyleProgressView进度相关
TextFieldStyleTextField有限,通常直接用修饰符
ListStyle / PickerStyleList / Picker一般是枚举而非自定义协议

PrimitiveButtonStyle:需要自己决定"什么时候算点击"

普通 ButtonStyle 只改外观,动作仍由系统触发。想控制触发时机(长按、双击、拖动一定距离才算),要用 PrimitiveButtonStyle:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
struct LongPressButtonStyle: PrimitiveButtonStyle {
    var minimumDuration: Double = 0.6

    func makeBody(configuration: Configuration) -> some View {
        configuration.label
            .padding(.horizontal, 20).padding(.vertical, 12)
            .background(.red.opacity(0.15), in: RoundedRectangle(cornerRadius: 10))
            .onLongPressGesture(minimumDuration: minimumDuration) {
                configuration.trigger()          // ← 由你决定何时真正触发
            }
    }
}

⚠️ 关键区别:PrimitiveButtonStyle 必须自己调用 configuration.trigger(),否则按钮永远不会响应。而普通 ButtonStyle 里不要试图调用它(它没有这个方法)。

7.6 往上层传信息:PreferenceKey

前面说过 ViewModifier 是"向下包"。那如果子视图想把信息报给祖先(比如"我有多宽",好让父视图决定布局)怎么办?

答案是 PreferenceKey——一条自下而上的数据通道。经典用例:测量某个视图的宽度。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import SwiftUI

struct WidthKey: PreferenceKey {
    static var defaultValue: CGFloat { 0 }          // ⚠️ 用计算属性,别用存储属性(见下)
    static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
        value = max(value, nextValue())             // 多个子视图上报时怎么合并
    }
}

struct MeasuredWidth: View {
    @State private var width: CGFloat = 0

    var body: some View {
        Text("量我的宽度")
            .background(
                GeometryReader { geo in
                    Color.clear.preference(key: WidthKey.self, value: geo.size.width)
                }
            )
            .onPreferenceChange(WidthKey.self) { width = $0 }
            .overlay(alignment: .bottom) {
                Text("宽 \(width, specifier: "%.0f")pt")
                    .font(.caption2).foregroundStyle(.secondary)
            }
    }
}

⚠️ Swift 6 严格并发下的一个真实陷阱:defaultValue 如果写成存储属性,会直接报错:

1
2
3
4
5
struct WidthKey: PreferenceKey {
    static var defaultValue: CGFloat = 0            // 🛑
    // error: static property 'defaultValue' is not concurrency-safe because it is
    //        nonisolated global shared mutable state [#MutableGlobalVariable]
}

🔬 正确写法是用计算属性,因为协议只要求 { get }:

1
static var defaultValue: CGFloat { 0 }              // ✅ 零警告(放进 WidthKey 里)

💭 这个坑很典型:协议要求只读的属性,就别写成可变存储属性。 既满足协议,又避开共享可变状态。

什么时候该用 PreferenceKey

场景说明
子视图尺寸/位置回报给祖先经典用法,比如"高亮当前选中的 Tab 下划线"
多个子视图汇总一个值reduce 就是干这个的(取最大、求和、收集数组)
自定义容器的布局参数收集比如 FlowLayout 想提前知道所有子视图宽度
滚动偏移老代码常用;现在优先用 onScrollGeometryChange 之类的新 API

⚠️ 但 PreferenceKey 是有成本的:它会让 SwiftUI 多做一轮"收集—归并—回传"。能不用就不用——很多时候 Layout 协议(第 4 章)或者把状态提到共同祖先更直接。

💭 判据:如果信息本来就能通过"把状态放在共同祖先"解决,就别用 PreferenceKey。 它适合的是"子视图的几何信息必须自下而上流动"这种天然逆向的场景。

7.7 三种抽法怎么选:一张决策表

你手上重复的东西长什么样抽成关键词
xxx.padding().background().clipShape()ViewModifier + View 扩展content 放哪
多行内容要塞进一个固定结构泛型容器 + @ViewBuilder<Content: View>
控件的外观(点击行为不变)ButtonStyle / ToggleStyle / LabelStyleConfiguration
控件的触发时机PrimitiveButtonStyleconfiguration.trigger()
要按自己的算法摆放子视图Layout(第 4 章)sizeThatFits / placeSubviews
子视图的信息要报给祖先PreferenceKeyreduce

再补三条工程经验:

经验理由
抽之前先问"有第三处了吗"两处重复抽出来,往往第三个用例一出现就得重写
名字用"形容词"cardStyle() 比 addCardBackground() 更像 SwiftUI
优先用 Style 协议而不是自己拼控件保住无障碍、键盘、辅助设备支持

7.8 完整可运行文件

把这一章的东西拼成一个能跑的例子:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
import SwiftUI

// ① ViewModifier:一串修饰符起个名字
struct CardStyle: ViewModifier {
    func body(content: Content) -> some View {
        content
            .padding(16)
            .frame(maxWidth: .infinity, alignment: .leading)
            .background(.background, in: RoundedRectangle(cornerRadius: 12))
            .overlay(RoundedRectangle(cornerRadius: 12).stroke(.separator, lineWidth: 1))
    }
}

extension View {
    func cardStyle() -> some View { modifier(CardStyle()) }
}

// ② 泛型容器:接收多行内容,按固定结构排列
struct SectionCard<Content: View>: View {
    var title: String
    @ViewBuilder var content: Content

    var body: some View {
        VStack(alignment: .leading, spacing: 10) {
            Text(title).font(.headline)
            content
        }
        .cardStyle()
    }
}

// ③ ButtonStyle:换外观,保行为
struct PressableButtonStyle: ButtonStyle {
    var tint: Color = .accentColor

    func makeBody(configuration: Configuration) -> some View {
        configuration.label
            .font(.headline)
            .foregroundStyle(.white)
            .frame(maxWidth: .infinity)
            .padding(.vertical, 12)
            .background(tint, in: RoundedRectangle(cornerRadius: 10))
            .scaleEffect(configuration.isPressed ? 0.97 : 1)
            .opacity(configuration.isPressed ? 0.9 : 1)
            .animation(.easeOut(duration: 0.12), value: configuration.isPressed)
    }
}

// ④ LabelStyle:图标和标题的排法
struct CompactLabelStyle: LabelStyle {
    func makeBody(configuration: Configuration) -> some View {
        HStack(spacing: 4) {
            configuration.icon
            configuration.title
        }
        .font(.subheadline)
    }
}

// ⑤ PreferenceKey:子视图把宽度报给祖先
struct WidthKey: PreferenceKey {
    static var defaultValue: CGFloat { 0 }
    static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
        value = max(value, nextValue())
    }
}

struct Chapter07Demo: View {
    @State private var measured: CGFloat = 0

    var body: some View {
        ScrollView {
            VStack(spacing: 16) {

                SectionCard(title: "本月概览") {
                    Label("支出 ¥3,240", systemImage: "arrow.up.right").labelStyle(CompactLabelStyle())
                    Label("收入 ¥12,000", systemImage: "arrow.down.left").labelStyle(CompactLabelStyle())
                    Divider()
                    Text("结余 ¥8,760").bold()
                }

                SectionCard(title: "测量的宽度") {
                    Text("这段文字的宽度会被量出来")
                        .background(
                            GeometryReader { geo in
                                Color.clear.preference(key: WidthKey.self, value: geo.size.width)
                            }
                        )
                    Text("实测宽 \(measured, specifier: "%.0f")pt")
                        .font(.caption).foregroundStyle(.secondary)
                }
                .onPreferenceChange(WidthKey.self) { measured = $0 }

                Button("确认支付") { }
                    .buttonStyle(PressableButtonStyle(tint: .green))

                Button(role: .destructive) { } label: {
                    Text("删除账户")
                }
                .buttonStyle(PressableButtonStyle(tint: .red))

                // 同一个 ButtonStyle,配上系统样式做对照
                Button("系统样式对照") { }
                    .buttonStyle(.borderedProminent)
            }
            .padding()
        }
    }
}

#Preview {
    Chapter07Demo()
}

📦 这一段是 View + #Preview,没有 @main:新建 Chapter07.swift 放进 Xcode 的 App 项目,再把 App 文件里的 WindowGroup { ContentView() } 改成 WindowGroup { Chapter07Demo() };只想看效果就直接看 #Preview——预览不需要入口。

💭 跑起来重点看最后两个按钮:它们用了同一个 PressableButtonStyle,只是 tint 不同,但点击、按下反馈、无障碍语义、键盘操作全都正常——这就是"用 ButtonStyle 而不是自己拼 HStack“的价值。

7.9 本章易错点速查

你会怎么写实际发生什么正确做法
存储属性写 @ViewBuilder var c: some Viewproperty cannot have an opaque type存储属性用 <Content: View> 泛型参数;some View 只配计算属性
一个 body 里堆 10 个 if/else 分支类型检查器展开多层 _ConditionalContent,编译变慢甚至 “too complex”改成查表 + 一个分支,或拆成子视图
为了按钮好看,自己拼 HStack + onTapGesture悄悄丢掉无障碍语义、按下反馈、辅助设备支持用 ButtonStyle
PrimitiveButtonStyle 里忘了 configuration.trigger()按钮永远不响应在你认为该触发的地方显式调用
泛型容器到处用每个组合都生成一个新类型,类型检查变慢只在真的需要多行内容时用;别为省事上 AnyView
PreferenceKey.defaultValue 写成存储属性Swift 6 下报 not concurrency-safe ... #MutableGlobalVariable写成计算属性 static var defaultValue: T { ... }
用 PreferenceKey 传普通状态多一轮收集归并,绕远路状态提到共同祖先就好
有一处重复就急着抽象第三个用例出现时通常要重写等第三处

7.10 下一章

到这里,“怎么把界面写对、写好、组装起来"这条线走完了。剩下两个大问题:

  • 数据从哪来、存到哪去——模型怎么持久化,网络请求的"加载中/成功/失败"三态怎么建得干净;
  • 多个页面怎么组织——List、NavigationStack、弹窗家族怎么配合,以及"什么才叫一个能用的列表”。

下一章先解决数据落地。

最后修改 September 21, 2026: 更新 (ac821931b)