作为一个常年拿 SwiftUI 写 macOS 工具的人,我其实挺怕做表单类界面的。倒不是因为 SwiftUI 做不出来,而是很多组件在 iOS 上看着很正常,一转到 Mac 上,那个交互逻辑就完全不是一回事儿。就比如这个第 4 节的标题里提到的“单选框与单选组的实现美化及流式布局”,单看每个词都认识,但真要把它们组合在 macOS 的一个设置界面里,让它长得像原生应用、用起来顺手,这里面的坑确实不少。这篇我就把这一节里我认为最值钱的几个点拆开揉碎讲清楚。
先说下这一节的定位:它接在前三节把窗口、导航和基础控件搞定之后。前三节你大概能搭出一个有列表、有工具栏的 Mac 应用壳子,到了这一节,我们解决的是“让用户从一堆选项里挑一个”这个高频场景。顺带,美化这部分会牵出一个非常实用的布局问题——流式布局,它解决的不是“怎么排版好看”,而是“怎么让一排按钮在窗口拖宽、字体变大、文案变长时依然不破相”。
这篇内容会从平台差异讲起,然后是原生单选框的坑和本质,再上手自己画一套更好看的单选组,最后把流式布局的完整实现代码和思路铺开。中间会穿插一些我在实际项目中踩过、或者被读者问过最多的问题。对于正在学 SwiftUI 做 Mac 应用、或者想把自己 iOS 那套表单经验迁移到 macOS 的人来说,这篇应该能帮你省掉不少试错的时间。
在正式写代码之前,先把结论放在这:macOS 里的单选框和 iOS 的单选框,其实是两种完全不同的心智模型。iOS 你大概率用的是Picker配合menu或segmented样式,或者干脆就是一行行带对勾的列表;但在 macOS 上,用户期待的是经典的“圆形单选按钮 + 右侧文字”,一行一个或者一行多个,选中的那个圆点实心,没选中的空心。SwiftUI 里对应原生效果最简单的方式是:
Picker("显示模式", selection: $displayMode) { Text("自动").tag(DisplayMode.auto) Text("浅色").tag(DisplayMode.light) Text("深色").tag(DisplayMode.dark) } .pickerStyle(.radioGroup)就这么三行,跑起来就是系统原生的单选框效果。但注意,这只是“能跑”。当你真的把这个东西放进一个复杂的设置面板里,绑定好数据模型、准备做好看一点、再想让它自动换行时,原生Picker的限制就全冒出来了。
1. 为什么 macOS 的单选框不能照搬 iOS 习惯
1.1 平台的原生交互差异
如果你是从 iOS 转过来学 SwiftUI 的,你可能会下意识地认为单选框就是Picker加个样式。在 iOS 上,Picker被包在Form里,点一下会弹出滚轮或者菜单,返回后把选中的值带回来。但在 macOS 上,系统级的设置界面里,单选交互是这样的:一组成组出现的小圆点,每个圆点旁边有文字描述,直接点击就可以切换,根本不需要先进入某个二级页面。
这就是我前面说的心智模型差异。以 macOS 用户的习惯来说,radioGroup样式的Picker是最保险的。它自带键盘焦点、VoiceOver 标签、以及和系统设置一致的外观。与其自己从头造轮子,不如先看看它在实际使用中的边界在哪。
举个例子,我在做一个应用的外观设置页时,需要用户从“跟随系统 / 浅色 / 深色”里选一个,同时还要从几个图标风格里选一个。代码长这样:
enum AppearanceMode: String, CaseIterable, Identifiable { case system = "跟随系统" case light = "浅色" case dark = "深色" var id: String { rawValue } } struct AppearanceSettingsView: View { @AppStorage("appearanceMode") private var appearanceMode = AppearanceMode.system var body: some View { Picker("外观模式", selection: $appearanceMode) { ForEach(AppearanceMode.allCases) { mode in Text(mode.rawValue).tag(mode) } } .pickerStyle(.radioGroup) } }这里必须强调一个细节:Picker的selection绑定必须和tag的类型严格一致。如果你枚举写了String的rawValue,但某个tag不小心写成了字符串字面量,或者反过来,编译器可能不报错,但运行时会发现选中状态怎么点都对不上。这个坑我后面会在常见问题里详细说。
1.2 单选组的本质:互斥与状态映射
单选组这个概念,说白了就是一组选项里只能有一个处于选中态。iOS 上你可能用@State存一个String,然后用List单选来实现。但在 macOS 的设置面板里,你需要的是“显式成组”的感觉。
我用一个例子说明“互斥”的底层逻辑:你有一个@State private var selectedPlan: PlanOption?,然后三个按钮,每个按钮的点击回调都是selectedPlan = .basic/.pro/.enterprise。这其实就是互斥逻辑的手动实现。而Picker帮你把这件事封装好了:你只需要提供所有选项和当前值,Picker负责把高亮圆点移动到正确的那一项。
不过要注意一组代码里的一个常见问题:ForEach的id如果用\.self,而且选项里有重复的字符串,那么单选组的选中态会出现错乱。这是因为 SwiftUI 识别视图身份时,两个相同字符串被视为同一个元素。所以一个稳妥的做法是给选项模型加上一个稳定的标识字段:
enum PlanOption: String, CaseIterable, Identifiable { case basic = "基础版" case pro = "专业版" case enterprise = "企业版" var id: String { rawValue } }你可能觉得重复字符串不至于,但实际业务里“自定义方案”和“推荐方案”可能文案相同,或者文案来自网络数据,重复概率是真实存在的。这属于数据建模层面的问题,不是 UI 的问题,所以我会习惯性地给所有可选中项都建一个独立 ID,而不是依赖展示文案。
从状态映射的角度看,单选组的核心其实是“把用户的操作翻译成数据模型的变化,再把数据模型的变化翻译回 UI 状态”。Picker只解决了映射的一半:它能把模型值映射到选中圆点。但如果你要的是“选中项旁边还出现一段升级提示”“选中某项后禁用另一项”,那就得考虑自定义方案了。
2. 原生 Picker 的 radioGroup 样式与细节打磨
2.1 基础实现:从数据绑定到 Tag 约束
原生Picker配合.radioGroup,最关键的一点是理解tag。tag是连接“选项视图”和“绑定值”的桥梁。每一个Text或Label都必须带一个唯一的tag,类型与绑定值对应的Binding泛型一致。
下面这段代码是我在模拟项目 X 里用到的实际写法,完整展示了一个带图标、带说明文字的单选组:
struct DisplaySettingsSection: View { enum ExportQuality: String, CaseIterable, Identifiable { case standard = "标准" case high = "高清" case lossless = "无损" var id: String { rawValue } } @State private var exportQuality: ExportQuality = .high var body: some View { Picker("导出质量", selection: $exportQuality) { ForEach(ExportQuality.allCases) { quality in HStack { Image(systemName: quality.iconName) Text(quality.rawValue) } .tag(quality) } } .pickerStyle(.radioGroup) .padding() } }这里有个很容易踩的坑:HStack在Picker里作为选项内容时,图标和文字可以正常显示,但如果你在HStack里加了Spacer(),某些系统版本里会出现选中热区变大的问题,看起来是点到了空白处也算选中了那一项。所以选项内容尽量保持紧凑,多余的空间交给外层布局去处理。
另一个细节是标签的显隐。当你把Picker放在设置面板里,前面往往已经有一个说明文字了,这时候Picker自带的标题文字就会显得冗余。你可以用.labelsHidden()隐藏它。但注意,隐藏标签不等于移除标签,辅助功能依然能读到这个标题,这反而是好事。
2.2 控制布局方向、尺寸与热区
macOS 的radioGroup默认是纵向排列的,一个选项占一行。如果你希望选项横着排成一排,比如“左对齐 / 居中 / 右对齐”这种模式,其实在 SwiftUI 的原生框架里并没有一个公开的“横向 radioGroup”开关。
有人会想到用.pickerStyle(.radioGroup)之后,再用controlSize或者环境变量搞定,实测并没有这个能力。我试过几种土办法:把Picker放进HStack,给每个选项设置frame(maxWidth: .infinity),效果一般,因为 macOS 的radioGroup有自己默认的行高和间距。
所以我的建议是,如果横排需求很明确,直接跳到自定义方案,用Button模拟选中态,反而能同时解决布局问题。后面第三节我会展开讲。
纵向布局也不是没有问题,主要卡在热区。原生radioGroup默认的可点击区域通常是圆点那一小块,文字部分有时候点了没反应。这就让一些不熟悉快捷操作的普通用户觉得“怎么点不动”。如果你需要让整行都能点击,原生Picker是做不到的,得自己在外面包一层onTapGesture再做高亮反馈。
2.3 原生方案什么时候该收手
当你的需求是下面这几种时,原生Picker的radioGroup就不合适了:
- 需要把选项做成大卡片,选中项有描边和背景色。
- 选项数量不定、文字宽度不定,需要自动换行。
- 一个选项除了文字还要展示说明、价格、标头之类的复合信息。
- 希望选中有流畅的动画反馈,而不仅仅是圆点实心变化。
我实际遇到最典型的是这样一个需求:选项是三张“主题色卡片”,每张卡片里有一个颜色圆、一个主题名、一句描述。用户点卡片整体选中,选中的卡片有一圈强调色描边。这已经和原生 radioGroup 长得完全不像了,硬凹的话成本比自定义还高。所以我后来的原则是:默认用原生,一旦出现“我需要自定义选中态外观”的需求,就直接换成自定义组件,不再纠结。
3. 自定义单选组的实现与美化
3.1 用 Button 自绘单选组:状态设计与选中反馈
自定义单选组的核心是用Button加 tag 加状态比对。基本思路是:视图接收一个泛型T(选项值)、一个T类型的当前选中值,以及一个回调闭包。每个按钮比较自己是否等于选中值,然后展示不同的背景、描边、图标。
这里有一段我在微软风格模拟器改造时用过的核心代码:
struct RadioButtonItem<Value: Hashable>: View { let value: Value let label: String let icon: String @Binding var selection: Value private var isSelected: Bool { selection == value } var body: some View { Button { selection = value } label: { HStack(spacing: 8) { Image(systemName: isSelected ? "largecircle.fill.circle" : "circle") .foregroundStyle(isSelected ? Color.accentColor : Color.secondary) Text(label) .font(.system(size: 13, weight: isSelected ? .semibold : .regular)) Spacer(minLength: 0) } .padding(.horizontal, 12) .padding(.vertical, 8) .contentShape(RoundedRectangle(cornerRadius: 8)) } .buttonStyle(.plain) .background( RoundedRectangle(cornerRadius: 8) .fill(isSelected ? Color.accentColor.opacity(0.12) : Color.clear) ) .overlay( RoundedRectangle(cornerRadius: 8) .stroke(isSelected ? Color.accentColor : Color.gray.opacity(0.3), lineWidth: 1) ) } }这里用到了几个关键点:
buttonStyle(.plain)是必加的。如果不加,macOS 会给 Button 套一层默认的蓝色高亮样式,你的自定义背景会显得很突兀。contentShape决定了整个区域都可以点击。- 选中态的图标用
largecircle.fill.circle,是 SwiftUI 的 SF Symbols 里最接近系统单选框的图标。 foregroundStyle在 macOS 13+ 可用,低版本要用foregroundColor。
我实测中,这套做法的优点是很直观,缺点也同样明显:它本质上是一个“看起来像单选组”的按钮集合,并没有原生 radioGroup 那种键盘方向键切换的能力。所以如果你对无障碍和键盘交互有严格要求,要么补充键盘逻辑,要么还是用原生。若只是普通设置页,这套自定义已经足够应付 90% 的视觉需求。
3.2 封装一个带泛型的单选组容器
多个RadioButtonItem排在一起,应该有一个容器视图负责遍历选项数据,统一管理布局。我习惯把它封装成RadioGroup:
struct RadioGroup<Value: Hashable, Content: View>: View { @Binding var selection: Value let data: [Value] let content: (Value, Bool) -> Content init(selection: Binding<Value>, data: [Value], @ViewBuilder content: @escaping (Value, Bool) -> Content) { self._selection = selection self.data = data self.content = content } var body: some View { VStack(alignment: .leading, spacing: 10) { ForEach(data, id: \.self) { item in content(item, selection == item) .onTapGesture { selection = item } } } } }这个容器做了几件好事:把选项遍历和选中判定收敛到一处,外部只用关心每个选项长什么样。配合前面的RadioButtonItem,用法是:
RadioGroup(selection: $exportQuality, data: ExportQuality.allCases) { quality, isSelected in RadioButtonItem(value: quality, label: quality.rawValue, icon: quality.iconName, selection: $exportQuality) }我一般不在RadioButtonItem里直接放onTapGesture,而是放在容器层,这样如果以后要加拖拽排序或复选逻辑,改起来更集中。留意一下,ForEach(data, id: \.self)依赖Value是Hashable,所以最好保证枚举确实没有重复 case。
3.3 美化:布局细节与动效
到这里,界面已经能看了,但还谈不上“美”。我踩过的坑是,只加背景色不加圆角,在浅色模式下还好,切到深色模式就是一条硬边。所以美化阶段,我会按顺序做四件事:
- 统一间距和对齐方式:
VStack内用spacing: 10,每个选项内用HStack撑满宽度。 - 加悬停反馈:这是 macOS 上非常容易被忽视的细节。用
@State private var hovered: Bool加.onHover,背景色从透明变gray.opacity(0.1),过渡动画.animation(.easeOut(duration: 0.15), value: hovered)。 - 选中态和悬停态叠加时,以选中态为主,避免两种颜色混合脏掉。
- 如果是大卡片式选项,可以加阴影和轻微缩放动画:点按瞬间
scaleEffect(0.98),抬起来恢复 1.0。
拿“主题色卡片”来说,完整的一个卡片式选项大概是这样:
struct ThemeCard: View { let theme: ThemeOption @Binding var selection: ThemeOption var isSelected: Bool { selection == theme } var body: some View { Button { withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { selection = theme } } label: { VStack(alignment: .leading, spacing: 6) { HStack { Circle() .fill(theme.color) .frame(width: 12, height: 12) Text(theme.name) .font(.system(size: 13, weight: .semibold)) } Text(theme.describe) .font(.system(size: 11)) .foregroundStyle(.secondary) .lineLimit(2) } .padding(12) .frame(maxWidth: .infinity, alignment: .leading) .background( RoundedRectangle(cornerRadius: 10) .fill(Color(nsColor: .controlBackgroundColor)) ) .overlay( RoundedRectangle(cornerRadius: 10) .stroke(isSelected ? Color.accentColor : Color.gray.opacity(0.25), lineWidth: isSelected ? 2 : 1) ) } .buttonStyle(.plain) } }用Color(nsColor: .controlBackgroundColor)而不是纯白或纯黑,是为了跟随 macOS 的深色和浅色模式。
动效方面,我给一个非常实在的建议:别滥用动画。macOS 的系统控件几乎不以“弹跳”为主,更多是瞬时响应。卡片选中描边淡入淡出这 0.2 秒就够了,不要给每个按钮都来一个大动画,看多了会腻,而且性能在旧机器上容易掉帧。
4. 流式布局:动态宽度与自动换行的正确姿势
4.1 固定布局的痛点和流式布局的使用场景
流式布局这个词在标题里排在“美化”之后,但它其实是从自定义单选组里自然长出来的需求。假设你做了六张卡片式单选组,放进一个宽度固定的HStack,每张卡片等宽。这个方案在小窗口、固定文案时没问题,但只要出现下面任一情况,它立刻就绷不住:
- 窗口被拉宽,卡片集体变胖,看起来反而很空。
- 窗口被拉窄,卡片文字换行,高度不一致,底部对齐乱七八糟。
- 选项文字是动态的,有的很短有的很长,等宽布局浪费空间。
流式布局的思路是:让每个选项根据自己的内容宽度决定自己的宽度,塞满一行后再自动换到下一行。这就像文字排版里的“词块流动”,和 CSS 里的 flex-wrap 很像。
在 SwiftUI 里,最简单的流式效果可以用LazyVGrid配合adaptive来模拟,但它做不到“完全按内容宽度换行”,因为它还是以网格列为单位,单个格子宽度会被约束。所以要达到精确的“内容宽度优先、排不下才换行”,你需要自定义Layout协议。
4.2 基于 Layout 协议的完整实现
macOS 13 开始,SwiftUI 的Layout协议让我可以彻底控制子视图的位置和尺寸。我实现的FlowLayout核心逻辑如下:
struct FlowLayout: Layout { var spacing: CGFloat = 8 func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { let maxWidth = proposal.width ?? .infinity let sizes = subviews.map { $0.sizeThatFits(.unspecified) } var width: CGFloat = 0 var height: CGFloat = 0 var lineHeight: CGFloat = 0 for size in sizes { if width + size.width + spacing > maxWidth, width > 0 { height += lineHeight + spacing width = 0 lineHeight = 0 } width += size.width + spacing lineHeight = max(lineHeight, size.height) } height += lineHeight return CGSize(width: proposal.width ?? width, height: height) } func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { let maxWidth = bounds.width let sizes = subviews.map { $0.sizeThatFits(.unspecified) } var x: CGFloat = bounds.minX var y: CGFloat = bounds.minY var lineHeight: CGFloat = 0 for (index, subview) in subviews.enumerated() { let size = sizes[index] if x + size.width > maxWidth, x > bounds.minX { x = bounds.minX y += lineHeight + spacing lineHeight = 0 } subview.place(at: CGPoint(x: x, y: y), proposal: ProposedViewSize(size)) x += size.width + spacing lineHeight = max(lineHeight, size.height) } } }这段代码有两个关键点需要解释清楚:
一是sizeThatFits里的换行判定。width > 0这个条件防止了极端情况下某个子视图比容器还宽,导致无限换行。第二个placeSubviews里,x > bounds.minX起同样作用,保证至少能放一个视图。
二是subviews.map { $0.sizeThatFits(.unspecified) }是测量每个子视图理想尺寸的标准方式。它会让每个选项“按自己的内容决定宽度”,这正是流式布局的核心。
用起来倒是非常简洁:
FlowLayout(spacing: 12) { ThemeCard(theme: .blue, selection: $selection) ThemeCard(theme: .green, selection: $selection) ThemeCard(theme: .orange, selection: $selection) ThemeCard(theme: .purple, selection: $selection) } .padding()这个组件可以直接放进任何视图里,窗口怎么拖,卡片都自动重排,比 Grid 灵活多了。
4.3 单选组与流式布局的整合实践
有了FlowLayout,单选组就可以从纵向一列解放出来。我把前面的RadioGroup容器稍微调整,把内部的VStack换成FlowLayout,就得到了一个“可变宽度的横排单选组”。
实际整合时有一个很关键的位置问题:FlowLayout目前只支持按自然顺序从左到右摆放,也就是说换行后的行首是从左边界开始的。如果你希望某些场景下居中对齐,需要在placeSubviews里做额外的行对齐计算,但单选组场景通常左对齐就好,这项暂时不用做。
还有一个容易忽略的问题:刷新性能。当selection变化导致某个卡片状态刷新时,FlowLayout会重新计算所有子视图尺寸。在选项数量不多(十个以内)时完全没问题,但如果你在选项里放了复杂视图(比如大图、评论区那种量级),建议把子视图撑满布局、减少重算频率。单选组这种界面,几十个选项已经是极限了,实测没压力。
我自己在做一个“导出配置”面板时,把六种导出方案用FlowLayout排成两层,窗口宽度从 800 拉到 400,卡片自动从一行变成两行,整个过程没有出现重叠或者异常间距。这个体验是普通HStack无法比的。
5. 常见问题与排查技巧实录
5.1 选中状态不同步、点击无效的排查方法
这里我集中回答几个这个系列里被问得最多的问题。我用表格归纳一下:
| 现象 | 常见原因 | 解决方案 |
|---|---|---|
| 点击单选组某选项,圆点不变 | tag类型与selection类型不一致 | 统一为同一枚举或String,不要混用 |
ForEach里选项重复时选中态错乱 | id: \.self碰撞 | 换成稳定唯一 ID,不要用展示文案做 ID |
| 点文字没反应,但点圆点可以 | 原生radioGroup热区较窄 | 改用自定义Button并加contentShape |
| 深色模式下自定义选项背景错乱 | 写死Color.white/Color.black | 使用Color(nsColor: .controlBackgroundColor) |
| 横排单选组在窗口缩小时溢出 | 用了HStack固定排列 | 改用FlowLayout或LazyVGrid |
| Picker 被放进滚动视图后选中卡顿 | 子视图数量过多且无id稳定标识 | 检查ForEach的 id,确保稳定 |
5.2 流式布局遇到性能问题的定位思路
如果你发现FlowLayout在切换选中项时明显掉帧,先别急着优化算法。先检查一下是不是每个卡片视图都用了复杂的@StateObject或大量Binding。在Layout协议里,每次布局都会读取所有子视图的尺寸和位置,如果子视图内又有大量动态计算的视图,代价会被放大。
一个高效的优化方案是把“选中与否”的样式差异收敛到最小范围,比如只把背景色、描边粗细作为差异,不要在按钮 label 里做太多条件判断。另一个优化是给FlowLayout的cache加上尺寸缓存,这里我先提供一个简单版本:
struct FlowLayoutCache { var sizes: [CGSize] = [] } struct FlowLayout: Layout { // 在 makeCache 里返回 FlowLayoutCache func makeCache(subviews: Subviews) -> FlowLayoutCache { FlowLayoutCache(sizes: subviews.map { $0.sizeThatFits(.unspecified) }) } }然后sizeThatFits和placeSubviews都优先从 cache 里取值。这样只要子视图尺寸没变化,就不会反复调用sizeThatFits(.unspecified),性能会有一个肉眼可见的提升。
5.3 无障碍支持
自定义单选组最大的劣势是没继承系统单选框的无障碍语义。我处理的方法是在根视图上加一个.accessibilityElement(children: .contain),然后为每个选项加.accessibilityAddTraits(isSelected ? [.isSelected] : []),同时调整.accessibilityLabel为完整的选项文案。这样至少 VoiceOver 用户能知道当前选中了哪一项。
这一步很多人会忽略,但如果你的应用面向 Mac 重度用户,键盘和 VoiceOver 支持属于基本要求。在交互上,我还给每个自定义按钮加了.keyboardShortcut,让用户可以按方向键切换,按空格确认。这是原生 radioGroup 天然具备的能力,自定义后需要手动补回来。
写在最后:几个可以继续扩展的方向
这一节的内容从原生Picker讲到自定义单选组,再到流式布局,算是一条完整的链路。接下来如果你想让界面更接近系统原生,可以尝试把自定义单选组包进Form,并配合Label的样式体系,让它在不同窗口尺寸下自动适配。如果你做的是工具栏里的筛选条件,还可以给单选组加一个“全部清除”按钮,或者在FlowLayout里嵌一个Divider,在视觉上分组。
另外一个小技巧:FlowLayout不仅仅能放单选卡片,像标签列表、筛选 chips、按钮组,全都能复用。你做一次封装,后面所有需要自动换行的地方都会受益。我现在的很多 macOS 项目里已经替换掉了以前的HStack + 手动换行逻辑,全部改用这一套布局了。
如果你在实际操作中发现自己的某个单选组样式始终调不顺手,或者FlowLayout在特定版本上有异常表现,欢迎在评论区里发出来,我可以根据大家的反馈再补一篇专门讲布局性能与动效优化的续篇。