原文:The Anatomy of a Reusable SwiftUI View --- Alexander Weiss (2026-07-12)
引言
在构建可复用的 SwiftUI 视图(例如设计系统中的组件库)时,我们面临的核心挑战是:如何让自定义视图的 API 尽可能接近 Apple 原生视图的 API? 这缩小了自定义视图与原生视图之间的"距离",开发者无需学习新的交互模式,就能快速上手使用。
本文从组件分类、语义建模、API 设计、自定义机制到预览策略,全面剖析一个可复用 SwiftUI 视图的诞生过程。
一、组件类型:语义型 vs 规定型
在动手实现之前,先将组件分为两类,这决定了后续的自定义策略:
1. 语义组件(Semantic Components)
语义组件描述的是一个概念,而非固定的视觉形态。例如:
Button描述了"可以被按下来执行动作"的概念,但没有预定义的 UIToggle描述了"启用/禁用某物"的概念,视觉表现完全开放
策略:对于语义组件,几乎总是提供自定义样式的能力,因为它的视觉表现不应被限定。
2. 规定性组件(Prescriptive Components)
规定性组件已经创造了视觉预期。例如:
BarChart组件尽管可以自定义颜色、间距等,但渲染时必然会显示多个柱状条Tag组件始终会渲染一个胶囊形状的标签
策略:对于视觉较简单的规定性组件,通常不允许自定义样式,而是通过环境值提供外观微调。
关键洞察:组件分类直接影响自定义方案的选择------语义组件用视图样式协议,规定性组件用环境值。搞混了会导致 API 过度膨胀或定制能力不足。
二、语义建模:在画 UI 之前先定义含义
先建模语义,再考虑外观
以 Rating 评分组件为例,在考虑星星怎么画之前,先回答这些问题:
- 可以没有评分吗?
- 支持小数值吗?
- 范围总是从 1 开始吗?
- 组件可以是只读的吗?
让无效状态难以表示
这是 Swift API 设计的经典原则。如果多个值总是一起使用或需要满足某些不变量,引入专用模型类型比接受多个不相关参数更好:
swift
// ❌ 不好的设计:多个参数散落,容易产生无效组合
Rating(value: 3.5, maxValue: 5, minValue: 1, allowsHalfValues: true, isReadOnly: false)
// ✅ 好的设计:用 ClosedRange 约束范围,用 Binding 表明可编辑
Rating(value: $rating, in: 1...5)
使用开发者熟悉的词汇
从系统组件中学习的命名约定:selection、value、label、content、isPresented。这些词汇已经有明确的语义,开发者一看就懂:
swift
// ✅ 使用系统熟悉的命名
Rating(value: $rating, in: 1...5)
Rating(label: { Text("评分:") }, value: $rating)
// ❌ 自创词汇,增加认知负担
Rating(ratingValue: $rating, range: 1...5, displayText: "评分:")
三、遵循 SwiftUI 的数据流
三条核心规则
- 不可变值作为输入:消费者传入的数据如果不被组件修改,用普通属性
- Binding 用于双向数据 :当组件修改消费者拥有的状态时,使用
Binding - @State 仅用于组件内部 :组件自身拥有的临时状态才用
@State
分离语义状态与视觉状态
这是一个容易被忽略的设计要点:
swift
// 语义状态(属于公共 API)
@Binding var value: Double // 选中的评分值
let range: ClosedRange<Double> // 评分范围
// 视觉状态(属于内部实现)
@State var isHighlighted: Bool // 星星是否被悬停高亮
@State var isPressed: Bool // 星星是否被按下
选中评分属于组件的公共 API,消费者关心它;而星星是否被按下/悬停/动画是临时视觉状态,消费者不应感知。将它们分开,API 保持简洁,内部实现保持灵活。
优先组合而非配置标志
避免不断增长的配置标志(showsIcon、isCompact、usesBorder),它们产生的组合不清晰:
swift
// ❌ 配置标志膨胀:8 个布尔值 → 256 种组合,多数无效
Tag(title: "Swift", showsIcon: true, isCompact: false, usesBorder: true, ...)
// ✅ 组合方式:通过 ViewBuilder 让消费者自由组合
Tag("Swift") // 简洁默认
Tag("Featured", systemImage: "star.fill") // 带图标
渐进式披露
常见调用点保持简短,专门用例仍然可用:
swift
// 简单用法(大多数场景)
Rating(value: $rating)
// 带标题
Rating(title: "评分:", value: $rating)
// 完全自定义标签
Rating(label: { CustomLabel() }, value: $rating)
四、保留原生行为
优先使用原生控件而非原始手势
swift
// ❌ 使用原始手势:失去键盘交互、焦点处理、禁用状态、无障碍
Image(systemName: "star")
.onTapGesture { selectRating() }
// ✅ 使用 Button:自动获得键盘交互、焦点、禁用状态和无障碍
Button {
selectRating()
} label: {
Image(systemName: "star")
}
Button 比 onTapGesture 更好,因为它已经参与了键盘交互、焦点处理、禁用状态行为和无障碍功能。
无障碍是 API 的一部分
可复用控件应暴露适当的 label、value、role 和动作集合:
swift
// Rating 组件的无障碍设计
.accessibilityElement(children: .combine) // 合并子元素
.accessibilityValue(Text(value, format: .number)) // 暴露当前值
.accessibilityAdjustableAction { direction in // 支持递增/递减操作
switch direction {
case .increment:
value = min(value + 1, range.upperBound)
case .decrement:
value = max(value - 1, range.lowerBound)
@unknown default:
break
}
}
重要 :无障碍行为位于
Rating而非个别样式中。这保留了控件在视觉变化时的含义------无论显示为星星还是数字,"递增评分"这个语义始终不变。
尊重周围环境
自定义组件应响应以下环境值:
isEnabled--- 禁用状态controlSize--- 控制大小dynamicTypeSize--- 动态字体layoutDirection--- 布局方向tint--- 主题色accessibilityReduceMotion--- 减少动画accessibilityDifferentiateWithoutColor--- 不依赖颜色区分
五、API 设计实践
容器视图的初始化器设计
容器视图接受其他 SwiftUI 视图作为子视图。从最灵活的初始化器开始:
swift
// 最灵活的基础初始化器
public struct Card<Header: View, Content: View>: View {
let header: Header
let content: Content
public init(
@ViewBuilder header: () -> Header = { EmptyView() }, // 默认空视图
@ViewBuilder content: () -> Content
) {
self.header = header()
self.content = content()
}
public var body: some View {
VStack(alignment: .leading) {
header
content
}
}
}
// 调用方式
Card {
Text("标题")
} content: {
Text("内容")
}
渐进式披露:便利初始化器
swift
// 便利初始化器:图片 + 内容
extension Card where Header == Image {
public init(
image: Image,
@ViewBuilder content: () -> Content
) {
self.header = image
self.content = content()
}
}
// 更专业的初始化器:图片 + 标题 + 内容
extension Card where Header == ProminentCardHeader {
public init(
image: Image,
title: LocalizedStringResource,
@ViewBuilder content: () -> Content
) {
self.header = ProminentCardHeader(image: image, text: title)
self.content = content()
}
}
public struct ProminentCardHeader: View {
let image: Image
let text: LocalizedStringResource
public var body: some View {
HStack(alignment: .center) {
image
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 40, height: 40)
Text(text)
}
}
}
// 三种调用方式,从简到繁
Card(image: Image(.myAsset)) {
Text("内容")
}
Card(image: Image(.myAsset), title: "Hello, World") {
Text("内容")
}
Card {
CustomHeader()
} content: {
Text("完全自定义的内容")
}
参考 :
ContentUnavailableView是 SwiftUI 标准视图中使用此模式的范例。
非容器视图的初始化器设计
swift
public struct Tag: View {
private let title: LocalizedStringResource
private let image: Image?
// 纯文本标签
public init(_ title: LocalizedStringResource) {
self.title = title
self.image = nil
}
// 带 SF Symbol 图标的标签
public init(_ title: LocalizedStringResource, systemImage: String) {
self.title = title
self.image = Image(systemName: systemImage)
}
// 带自定义资源图标的标签(类型安全)
public init(_ title: LocalizedStringResource, image resource: ImageResource) {
self.title = title
self.image = Image(resource)
}
public var body: some View {
HStack(spacing: 4) {
if let image {
image
}
Text(title)
}
}
}
// 三种调用方式
Tag("SwiftUI")
Tag("Featured", systemImage: "star.fill")
Tag("Logo", image: .companyLogo) // 类型安全的资源引用
技术说明 :使用
LocalizedStringResource(而非普通String)与 Apple 组件保持一致,并受益于 Xcode 15 引入的 String Catalogs 的自动键和翻译处理。ImageResource提供类型安全的资源访问,避免字符串拼写错误。
六、自定义机制
方案一:视图样式协议(用于语义组件)
当语义组件需要根本不同的视觉表示时,使用自定义视图样式。以 Rating 评分组件为例:
第一步:定义配置结构
swift
public struct RatingStyleConfiguration {
public let value: Binding<Double> // 当前评分值(可修改)
public let range: ClosedRange<Double> // 评分范围
public let label: Label // 组件标签(类型擦除)
// 类型擦除的标签,确保每个样式收到相同的非泛型配置类型
public struct Label: View {
let underlyingLabel: AnyView
init(_ label: some View) {
self.underlyingLabel = AnyView(label)
}
public var body: some View {
underlyingLabel
}
}
}
第二步:定义样式协议
swift
public protocol RatingStyle: DynamicProperty {
associatedtype Body: View
@ViewBuilder func makeBody(configuration: Configuration) -> Body
typealias Configuration = RatingStyleConfiguration
}
关键技术点 :样式协议继承
DynamicProperty,这样消费者可以在样式定义中访问@Environment属性。SwiftUI 在调用makeBody之前会更新样式中的动态属性。
第三步:中间解析层
swift
// 关键部分:因为 Style 遵循 DynamicProperty,
// SwiftUI 在调用 body 之前会更新样式的动态属性,
// 从而填充样式内部声明的 @Environment、@State 等。
struct ResolvedRatingStyle<Style: RatingStyle>: View {
var style: Style
var configuration: RatingStyleConfiguration
var body: some View {
style.makeBody(configuration: configuration)
}
}
extension RatingStyle {
func resolve(configuration: Configuration) -> some View {
ResolvedRatingStyle(configuration: configuration, style: self)
}
}
类型擦除说明 :有两个刻意的类型擦除边界。
RatingStyleConfiguration.Label存储AnyView以便每个样式收到相同的非泛型配置类型。之后,Rating使用另一个AnyView,因为环境中存储的存在性样式不暴露具体的Body类型。外层Rating视图仍然有稳定身份,但擦除放弃了这些边界内的具体类型信息,因此仅在样式架构需要的地方局部使用。
第四步:注册环境值
swift
extension EnvironmentValues {
@Entry var ratingStyle: any RatingStyle = .star
}
extension View {
public func ratingStyle(_ style: some RatingStyle) -> some View {
environment(\.ratingStyle, style)
}
}
第五步:基础组件定义
swift
public struct Rating<RatingLabel: View>: View {
@Environment(\.ratingStyle) private var style // 从环境读取当前样式
@Binding private var value: Double // 评分值(双向绑定)
private var label: RatingLabel // 标签
private let range: ClosedRange<Double> // 评分范围
public init(
@ViewBuilder label: () -> RatingLabel,
value: Binding<Double>,
in range: ClosedRange<Double> = 1...5
) {
self.label = label()
_value = value
self.range = range
}
public var body: some View {
// 在 body 中创建配置,以便样式解析可以参与 SwiftUI 的常规视图解析流程
let configuration = RatingStyleConfiguration(
label: RatingStyleConfiguration.Label(label),
value: $value,
range: range
)
AnyView(style.resolve(configuration: configuration))
.accessibilityElement(children: .combine)
.accessibilityValue(Text(value, format: .number))
.accessibilityAdjustableAction { direction in
switch direction {
case .increment:
value = min(value + 1, range.upperBound)
case .decrement:
value = max(value - 1, range.lowerBound)
@unknown default:
break
}
}
}
}
// 便利初始化器
extension Rating where RatingLabel == Text {
public init(
title: LocalizedStringResource,
value: Binding<Double>,
in range: ClosedRange<Double> = 1...5
) {
self.init(
label: { Text(title) },
value: value,
in: range
)
}
}
默认星形样式
swift
public struct StarRatingStyle: RatingStyle {
@Environment(\.colorScheme) private var colorScheme // 从环境读取配色方案
public init() {}
public func makeBody(configuration: Configuration) -> some View {
HStack {
configuration.label
HStack(spacing: 4) {
ForEach(
Int(configuration.range.lowerBound)...Int(configuration.range.upperBound),
id: \.self
) { rating in
Button {
configuration.value.wrappedValue = Double(rating)
} label: {
// 已选中的星显示实心,未选中显示空心
Image(systemName: Double(rating) <= configuration.value.wrappedValue ? "star.fill" : "star")
.foregroundStyle(colorScheme == .dark ? Color.yellow : Color.orange)
}
.buttonStyle(.plain)
}
}
.accessibilityHidden(true) // 星星本身隐藏无障碍,由 Rating 层统一处理
}
}
}
extension RatingStyle where Self == StarRatingStyle {
public static var star: Self { Self() }
}
数字样式
swift
public struct NumericRatingStyle: RatingStyle {
@Environment(\.isEnabled) private var isEnabled // 从环境读取启用状态
public init() {}
public func makeBody(configuration: Configuration) -> some View {
HStack {
configuration.label
HStack(spacing: 2) {
Text(configuration.value.wrappedValue, format: .number.precision(.fractionLength(1)))
.fontWeight(.semibold)
Text("/ \(Int(configuration.range.upperBound))")
.foregroundStyle(.secondary)
}
.opacity(isEnabled ? 1 : 0.5) // 禁用时半透明
}
}
}
extension RatingStyle where Self == NumericRatingStyle {
public static var numeric: Self { Self() }
}
使用示例
swift
@State var rating: Double = 3
// 默认星形样式
Rating(title: "Stars:", value: $rating)
// 自定义标签 + 星形样式
Rating(label: { Text("Custom Stars:") }, value: $rating)
// 自定义标签 + 数字样式
Rating(label: { Text("Numbers:") }, value: $rating)
.ratingStyle(.numeric)
环境集成说明 :
NumericRatingStyle从环境读取isEnabled,StarRatingStyle中的按钮自动采用 SwiftUI 的禁用交互行为,星形样式还响应colorScheme。每个样式仅读取对其呈现和行为有影响的值。
方案二:环境值(用于规定性组件)
当组件保持身份和结构,但消费者应能调整其外观的个别方面时,使用环境值。以 Tag 组件为例:
定义环境值
swift
extension EnvironmentValues {
// 选择 ShapeStyle 协议而非具体类型,可以表示 Color、渐变、材质或任何遵循该协议的类型
@Entry var tagBackgroundStyle: (any ShapeStyle)? = nil
}
extension View {
public func tagBackgroundStyle(_ style: some ShapeStyle) -> some View {
environment(\.tagBackgroundStyle, style)
}
}
技术说明 :选择
ShapeStyle协议而非具体视觉类型(如Color),可以表示Color、渐变、材质或任何遵循该协议的类型,大大增加了灵活性。
在组件中使用
swift
public struct Tag: View {
@Environment(\.tagBackgroundStyle) private var backgroundStyle
private let title: LocalizedStringResource
private let image: Image?
public var body: some View {
HStack(spacing: 4) {
if let image {
image
}
Text(title)
}
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(background)
}
// 优先级:显式环境值 > 组件默认值
private var background: some View {
let style = if let backgroundStyle {
AnyShapeStyle(backgroundStyle)
} else {
AnyShapeStyle(Material.regular) // 默认使用 regular material
}
return Capsule()
.fill(style)
}
}
调用示例
swift
// 默认材质背景
Tag("SwiftUI")
// Thick material 背景
Tag("SwiftUI")
.tagBackgroundStyle(Material.thick)
// 橙色背景
Tag("Featured", systemImage: "star.fill")
.tagBackgroundStyle(Color.orange)
// 渐变背景
Tag("Gradient")
.tagBackgroundStyle(
LinearGradient(
colors: [.red, .blue],
startPoint: .bottom,
endPoint: .top
)
)
优先级规则 :视图层次结构中最近的
tagBackgroundStyle修饰符生效,遵循 SwiftUI 环境的正常优先级规则。如果环境中没有值,Tag回退到组件内部定义的 regular material。这使显式调用点选择优先于组件默认值。
七、预览与测试
swift
private struct ComponentPreview: View {
@State private var rating: Double = 3
var body: some View {
VStack(spacing: 24) {
Rating(title: "Rating", value: $rating)
Rating(title: "Disabled", value: $rating)
.disabled(true)
Tag("Featured", systemImage: "star.fill")
.tagBackgroundStyle(
LinearGradient(
colors: [.red, .blue],
startPoint: .bottom,
endPoint: .top
)
)
}
.padding()
}
}
#Preview("Light") {
ComponentPreview()
}
#Preview("Dark") {
ComponentPreview()
.preferredColorScheme(.dark)
}
可复用视图应易于预览和隔离测试:覆盖有意义的状态、不同配色方案、Dynamic Type 大小、布局方向、本地化和相关环境值。
八、保持组件可预测性
- 明确优先级:显式修饰符应覆盖环境默认值,样式负责自身的视觉细节
- 保留视图身份:通过避免不必要的类型擦除和大型结构变更来保留 View Identity,稳定身份有助于 SwiftUI 保留状态并产生预期的动画和过渡
- 类型擦除是特定问题的解决方案,而非默认选择------仅在样式架构需要的地方局部使用
- 公共组件的 API 一旦被其他模块依赖就更难更改,初始化器、泛型约束、默认参数和重载在设计之初就要慎重考虑
AI 时代的提醒:AI 代理可以帮助我们快速实现和采用组件,这使得先考虑 API 更加重要。不合适的设计可能同样快速地在代码库中传播。
我的见解与总结
核心收获
这篇文章提供了一套完整的 SwiftUI 可复用组件设计方法论,从"是什么"(语义建模)到"怎么用"(API 设计)再到"怎么变"(自定义机制),每一步都有清晰的决策依据。最打动我的几个点:
- 语义先行:先定义组件代表什么概念,再考虑外观。这避免了"什么参数都加"的膨胀问题。
- 两种自定义路径的选择依据:语义组件用样式协议(彻底换视觉),规定性组件用环境值(微调外观),这个分类非常实用。
- 无障碍是 API 的一部分,而非最后加上去的装饰------这个观念在很多人那里还是缺失的。
- 渐进式披露:简单场景用简单 API,复杂场景有完整 API,这跟 Apple 自身的设计哲学完全一致。
扩展场景
-
设计系统/组件库建设:这套方法论是构建企业级 SwiftUI 组件库的基石。如果你在做设计系统,建议严格按照语义型 vs 规定型分类所有组件,再分别选择自定义路径。
-
Swift Package 模块化 :文中代码片段假设组件属于设计系统模块。使用
public标记消费者需要的 API,实现助手可以保持internal。正确的模块边界取决于组件所在位置。 -
跨平台适配 :样式协议天然支持跨平台------同一个
Rating语义,iOS 用星星样式,macOS 用数字样式,watchOS 用简化样式,只需切换.ratingStyle()。 -
AI 辅助组件开发:在 AI 代理可以帮助快速实现组件的时代,先定义好 API 再让 AI 填充实现变得更加关键------否则不合适的设计会快速扩散到整个代码库。
-
测试策略:文中提到闭包比 Task 更容易测试。对于样式协议,可以通过注入不同的环境值和样式来测试组件在不同条件下的行为,而不需要真实的 UI 渲染。
推荐原则清单
- ✅ 确定组件是语义型还是规定型
- ✅ 在外观之前建模有效状态和有意限制
- ✅ 从灵活的基础初始化器开始,添加专注的便利初始化器
- ✅ 匹配开发者已从系统视图中了解的名称和参数类型
- ✅ 遵循 SwiftUI 的数据流和交互约定
- ✅ 优先使用广泛的系统抽象(
View、ShapeStyle、LocalizedStringResource) - ✅ 对语义组件的不同视觉表示使用自定义视图样式
- ✅ 对组件外观的聚焦调整使用环境值
- ✅ 将无障碍和相关环境值视为组件的一部分
- ✅ 保持优先级和扩展点可预测
- ✅ 在有意义的状态下预览和测试组件
参考资料
- The Anatomy of a Reusable SwiftUI View --- Alexander Weiss
- LocalizedStringResource --- Apple Developer Documentation
- ContentUnavailableView --- Apple Developer Documentation
- ImageResource --- Apple Developer Documentation
- DynamicProperty --- Apple Developer Documentation
- EnvironmentValues --- Apple Developer Documentation
- ShapeStyle --- Apple Developer Documentation