将调用方提供的内容声明为 (Input) -> Content,并将 Content 限定为 View。这样,macOS 组件就能提供一个类型化的值,同时由调用方决定如何呈现它。此契约要求接收该输入,但无法要求使用它。

本教程将构建一个小型桌面色板预览。可复用组件负责管理尺寸控件,并向调用方的渲染闭包提供一个合成配置。调用方绘制色板,无需管理组件的内部状态。

什么接口能表达这一要求?

为返回的视图使用一个具名泛型参数,并为组件的输入使用一个具体类型。在下面的示例中,核心签名是 @ViewBuilder content: @escaping (SwatchInput) -> Content,且它位于 Content: View 约束的声明中。

输入和输出各有不同用途。SwatchInput 定义组件提供的值。Content 表示每个调用方的闭包生成的具体视图类型。具名泛型参数让声明能够表达并复用这些类型关系,详见 Swift 的泛型章节。

Apple 在 GeometryReader 中采用了这种接口模式:它的初始化器接受一个类型为 (GeometryProxy) -> Content 的闭包。容器提供代理值,调用方据此构建内容。对于提供自有配置的自定义组件,这是一个很好的先例。请参阅 Apple 关于 GeometryReader 和 init(content:) 的文档。

根据实际任务选择契约:

要求 适用接口
调用方提供内容,组件不提供输入 () -> Content
组件提供特定配置 (SwatchInput) -> Content
容器接收的值在不同用法中类型各异 具名的 Input 泛型参数,以及匹配的 (Input) -> Content 闭包
编译器必须证明调用方使用了配置 闭包签名无法表达这一要求

这些选择源于 Swift 的函数类型:函数类型描述可传入和可返回的参数类型与返回类型,但不描述实现必须如何使用其参数。请参阅 Swift 语言参考中的函数类型章节。

构建完整的 macOS 色板预览

使用带有 SwiftUI 的 macOS 应用目标。示例采用既有的 @ViewBuilder 写法,适用于 Swift 5.7 或更高版本,部署目标为 macOS 12 或更高版本。它不使用预览宏或较新的内容构建器名称。研究环境中没有 Swift 和 Apple SDK,因此这里只检查了这段程序,未进行编译或运行。

在 Xcode SwiftUI 应用项目中,用以下完整程序替换生成的应用声明。如果生成的 ContentView 声明与此重复,请将其删除。只保留一个标记为 @main 的应用入口点。Apple 的 Xcode 帮助介绍了如何从 SwiftUI 项目开始,或向现有项目添加 SwiftUI 文件。

import SwiftUI

struct SwatchInput {
    let title: String
    let side: CGFloat
    let color: Color
}

@MainActor
struct SwatchPanel<Content: View>: View {
    @State private var isLarge = false
    private let content: (SwatchInput) -> Content

    init(
        @ViewBuilder content: @escaping (SwatchInput) -> Content
    ) {
        self.content = content
    }

    private var input: SwatchInput {
        SwatchInput(
            title: "Sample A",
            side: isLarge ? 120 : 64,
            color: .blue
        )
    }

    var body: some View {
        VStack(alignment: .leading, spacing: 16) {
            Toggle("Large sample", isOn: $isLarge)
            content(input)
        }
        .padding(24)
    }
}

@MainActor
struct ContentView: View {
    var body: some View {
        SwatchPanel { input in
            Text(input.title)
                .font(.headline)

            RoundedRectangle(cornerRadius: 12)
                .fill(input.color)
                .frame(width: input.side, height: input.side)

            if input.side == 120 {
                Text("Large preview")
                    .font(.caption)
            }
        }
        .frame(width: 300, height: 280, alignment: .topLeading)
    }
}

@main
@MainActor
struct TypedContentDemoApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

预期的初始界面包含 Large sample 开关、标题 Sample A,以及边长为 64 点的蓝色圆角方块。打开开关后,提供的边长会变为 120 点,并显示 Large preview。这些尺寸和标签只是示例中的本地值,并非测量结果。

组件负责管理 isLarge。它的计算属性 input 会打包当前选择,而 content(input) 会将此数据包传给调用方。SwiftUI 管理状态存储,并在值发生变化时更新视图层级中依赖该值的部分,详见 State 文档。

调用方负责呈现:标题、形状,以及可选说明文字。它可以在保持类型化输入不变的情况下替换成另一种布局。面板及其调用方都实现 body 来组合界面,这遵循 Apple 文档所述的 View 模型。

为什么需要构建器和转义注解?

@ViewBuilder 让调用方可以在一个闭包中编写多个视图表达式和受支持的条件分支。在此示例中,调用方提供两个无条件视图和一段条件说明文字。构建器会将这些内容组合成满足组件返回约束的结果。Apple 在 ViewBuilder 文档中介绍了多个子视图和条件构造。

构建器不会提供 SwatchInput。普通的闭包参数负责声明输入,而 content(input) 负责提供实际参数。如果初始化器接受 () -> Content,添加构建器注解也不会使它变成能够接收配置的闭包。

需要使用 @escaping,因为初始化器会将闭包存储在属性中,以便稍后从 body 调用。Swift 的闭包章节说明,如果闭包可能在接收它的函数返回后才被调用,那么这个闭包就是逃逸闭包。

对于配置在初始化时已最终确定的组件,另一种设计是立即调用非逃逸构建器,并存储生成的 Content 值。这会改变应用配置的时机。此处保留闭包能明确表达预期流程:从组件状态推导当前输入,然后使用该输入构建内容。

遵循 Xcode 的构建器边界

Apple 当前的文档区分了 Xcode 26 及更早版本与 Xcode 27 及更高版本。对于较早版本的工具,文档将 ViewBuilder 描述为用于构建视图。对于 Xcode 27 及更高版本,文档将其描述为用于类型无关的构造,并建议使用 ContentBuilder 别名来表示这种更广泛的行为。请参阅 ViewBuilder。

维护 Xcode 27 或更高版本的代码库时,初始化器可以改用 @ContentBuilder。Apple 将它记载为 ViewBuilder 的类型别名,并通过其生成的内容类型来强制符合性。此组件应保留 Content: View:它的输出仍用于视图层级。不要仅因当前文档列出了较早的部署目标,就假设较旧 SDK 中也有这种较新的写法。请参阅 ContentBuilder。

为什么 some View 无法满足契约的所有部分?

示例中的 body 属性适合使用 some View。它会隐藏具体结果类型,同时保留结果为视图的承诺。而存储的闭包使用具名的 Content 参数,让组件能在其属性和初始化器中引用同一个结果类型。

Swift 5.7 通过 SE-0341 引入了不透明参数声明。像 _ value: some View 这样的直接参数会引入一个未命名的泛型参数,其具体类型由调用方确定。这并不意味着函数实现可以任意选择一个视图,并将其传给任何需要该未命名类型的位置。请参阅不透明参数声明。

具体来说,SE-0341 文档未支持在回调的函数类型中将参数写成 (some View) -> Content。该提案明确禁止在回调函数类型的输入位置使用不透明类型。有关可能启用该语法的讨论属于未来方向,并非已实现的行为。当前的 Types 参考文档仍保留此限制。

对于这个桌面任务,受支持的做法很直接:将组件提供的值设为具体配置类型。如果另一个可复用容器必须保留调用方提供的输入类型,就用泛型参数为该类型命名,并在存储值和回调中始终使用该类型。这两种设计都不需要不透明的回调输入。

编译器实际保证了什么?

该签名保证了可调用形式:接受一个 SwatchInput 参数,并返回满足 Content: View 的结果。它还会推断闭包参数的类型,因此调用方无需重复标注类型,就能访问 input.title、input.side 和 input.color。Swift 在闭包文档中介绍了这种根据闭包上下文进行的类型推断。

它无法保证这些属性会影响显示。例如,以下表达式可以有效地替换 ContentView.body 中的内容;它会有意接收并忽略该配置:

SwatchPanel { _ in
    Text("Fixed preview")
}

在编写组件需求时,这个界限很重要。“调用方内容接受 SwatchInput”是一项类型契约。“调用方内容显示提供的尺寸”是一项行为要求。如果显示该尺寸是组件的关键要求,就应将渲染逻辑放在组件内部,并提供更小的自定义区域。

使用以下检查来诊断集成问题。这些是根据接口推导出的检查步骤,并非报告的编译器测试结果:

现象 检查项
调用 content 时提示缺少参数 传入配置:content(input)。
初始化器出现逃逸闭包诊断 存储回调时保留 @escaping。
调用方的多个同级视图无法通过检查 在初始化器参数上保留适当的构建器注解。
显式标注类型的回调被拒绝 检查其输入是否为 SwatchInput,而不是 String 等无关类型。
预览未响应尺寸开关 检查渲染是否读取 input.side。
组件始终显示初始配置 检查内容是否只构建一次并被存储,而不是用当前输入调用回调。

让契约尽可能精简。具体配置和泛型视图结果为这个色板面板划定了清晰的边界:组件提供当前值,调用方收到完整类型信息,并且可以轻松替换所选的呈现方式。

来源