SwiftUI 可复用视图设计解剖:从语义到 API 的完整实践

原文:The Anatomy of a Reusable SwiftUI View --- Alexander Weiss (2026-07-12)

引言

在构建可复用的 SwiftUI 视图(例如设计系统中的组件库)时,我们面临的核心挑战是:如何让自定义视图的 API 尽可能接近 Apple 原生视图的 API? 这缩小了自定义视图与原生视图之间的"距离",开发者无需学习新的交互模式,就能快速上手使用。

本文从组件分类、语义建模、API 设计、自定义机制到预览策略,全面剖析一个可复用 SwiftUI 视图的诞生过程。


一、组件类型:语义型 vs 规定型

在动手实现之前,先将组件分为两类,这决定了后续的自定义策略:

1. 语义组件(Semantic Components)

语义组件描述的是一个概念,而非固定的视觉形态。例如:

  • Button 描述了"可以被按下来执行动作"的概念,但没有预定义的 UI
  • Toggle 描述了"启用/禁用某物"的概念,视觉表现完全开放

策略:对于语义组件,几乎总是提供自定义样式的能力,因为它的视觉表现不应被限定。

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)

使用开发者熟悉的词汇

从系统组件中学习的命名约定:selectionvaluelabelcontentisPresented。这些词汇已经有明确的语义,开发者一看就懂:

swift 复制代码
// ✅ 使用系统熟悉的命名
Rating(value: $rating, in: 1...5)
Rating(label: { Text("评分:") }, value: $rating)

// ❌ 自创词汇,增加认知负担
Rating(ratingValue: $rating, range: 1...5, displayText: "评分:")

三、遵循 SwiftUI 的数据流

三条核心规则

  1. 不可变值作为输入:消费者传入的数据如果不被组件修改,用普通属性
  2. Binding 用于双向数据 :当组件修改消费者拥有的状态时,使用 Binding
  3. @State 仅用于组件内部 :组件自身拥有的临时状态才用 @State

分离语义状态与视觉状态

这是一个容易被忽略的设计要点:

swift 复制代码
// 语义状态(属于公共 API)
@Binding var value: Double          // 选中的评分值
let range: ClosedRange<Double>      // 评分范围

// 视觉状态(属于内部实现)
@State var isHighlighted: Bool      // 星星是否被悬停高亮
@State var isPressed: Bool          // 星星是否被按下

选中评分属于组件的公共 API,消费者关心它;而星星是否被按下/悬停/动画是临时视觉状态,消费者不应感知。将它们分开,API 保持简洁,内部实现保持灵活。

优先组合而非配置标志

避免不断增长的配置标志(showsIconisCompactusesBorder),它们产生的组合不清晰:

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")
}

ButtononTapGesture 更好,因为它已经参与了键盘交互、焦点处理、禁用状态行为和无障碍功能。

无障碍是 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 从环境读取 isEnabledStarRatingStyle 中的按钮自动采用 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 大小、布局方向、本地化和相关环境值。


八、保持组件可预测性

  1. 明确优先级:显式修饰符应覆盖环境默认值,样式负责自身的视觉细节
  2. 保留视图身份:通过避免不必要的类型擦除和大型结构变更来保留 View Identity,稳定身份有助于 SwiftUI 保留状态并产生预期的动画和过渡
  3. 类型擦除是特定问题的解决方案,而非默认选择------仅在样式架构需要的地方局部使用
  4. 公共组件的 API 一旦被其他模块依赖就更难更改,初始化器、泛型约束、默认参数和重载在设计之初就要慎重考虑

AI 时代的提醒:AI 代理可以帮助我们快速实现和采用组件,这使得先考虑 API 更加重要。不合适的设计可能同样快速地在代码库中传播。


我的见解与总结

核心收获

这篇文章提供了一套完整的 SwiftUI 可复用组件设计方法论,从"是什么"(语义建模)到"怎么用"(API 设计)再到"怎么变"(自定义机制),每一步都有清晰的决策依据。最打动我的几个点:

  1. 语义先行:先定义组件代表什么概念,再考虑外观。这避免了"什么参数都加"的膨胀问题。
  2. 两种自定义路径的选择依据:语义组件用样式协议(彻底换视觉),规定性组件用环境值(微调外观),这个分类非常实用。
  3. 无障碍是 API 的一部分,而非最后加上去的装饰------这个观念在很多人那里还是缺失的。
  4. 渐进式披露:简单场景用简单 API,复杂场景有完整 API,这跟 Apple 自身的设计哲学完全一致。

扩展场景

  1. 设计系统/组件库建设:这套方法论是构建企业级 SwiftUI 组件库的基石。如果你在做设计系统,建议严格按照语义型 vs 规定型分类所有组件,再分别选择自定义路径。

  2. Swift Package 模块化 :文中代码片段假设组件属于设计系统模块。使用 public 标记消费者需要的 API,实现助手可以保持 internal。正确的模块边界取决于组件所在位置。

  3. 跨平台适配 :样式协议天然支持跨平台------同一个 Rating 语义,iOS 用星星样式,macOS 用数字样式,watchOS 用简化样式,只需切换 .ratingStyle()

  4. AI 辅助组件开发:在 AI 代理可以帮助快速实现组件的时代,先定义好 API 再让 AI 填充实现变得更加关键------否则不合适的设计会快速扩散到整个代码库。

  5. 测试策略:文中提到闭包比 Task 更容易测试。对于样式协议,可以通过注入不同的环境值和样式来测试组件在不同条件下的行为,而不需要真实的 UI 渲染。

推荐原则清单

  • ✅ 确定组件是语义型还是规定型
  • ✅ 在外观之前建模有效状态和有意限制
  • ✅ 从灵活的基础初始化器开始,添加专注的便利初始化器
  • ✅ 匹配开发者已从系统视图中了解的名称和参数类型
  • ✅ 遵循 SwiftUI 的数据流和交互约定
  • ✅ 优先使用广泛的系统抽象(ViewShapeStyleLocalizedStringResource
  • ✅ 对语义组件的不同视觉表示使用自定义视图样式
  • ✅ 对组件外观的聚焦调整使用环境值
  • ✅ 将无障碍和相关环境值视为组件的一部分
  • ✅ 保持优先级和扩展点可预测
  • ✅ 在有意义的状态下预览和测试组件

参考资料

相关推荐
东坡肘子9 小时前
当灵感跑在了结果前面 -- 肘子的 Swift 周报 #145
人工智能·swiftui·swift
HarderCoder21 小时前
Swift 静态分派与动态分派:方法到底是如何被调用的
swift
云逸_4 天前
Cinder:基于 Swift Macro 与 Mach-O Section 的解耦注册机制
ios·swift
末代iOS程序员华仔4 天前
从 Figma 到上架:使用 Codex 与 MCP 全流程实现 AI 聊天 App
flutter·ios·swift
霍霍哈嗨5 天前
swiftUI框架基础
ios·swiftui·swift
末代iOS程序员华仔5 天前
iOS 开发到上架 App Store 全流程详解
ios·objective-c·swift
东坡肘子7 天前
当每一次写入都有了新价格 -- 肘子的 Swift 周报 #144
人工智能·swiftui·swift
zzb15807 天前
Zed 配置 Swift / iOS 开发
开发语言·ios·swift
Geek-Chow8 天前
Mobile App Certificate Pinning: Underlying Principle and a Swift Example
开发语言·ios·swift·安全架构