要构建交互式 SwiftUI 预览,请为每个预览提供自己的本地状态,将可观察的视图模型传入编辑器,并通过绑定公开可编辑属性。本指南使用合成示例文本构建一个小型 macOS 标签编辑器。

选择预览状态的存放位置

任务是预览一个桌面编辑器:用户可以更改标签的标题和详细文本,然后切换是否显示详细信息。编辑器应立即响应,每个预览都应以明确的示例配置启动。

使用能够支持交互的最少输入。Apple 建议只向视图传入其所需的数据,因为复杂的依赖关系会让构建预览更困难。静态标签可以接收字符串;编辑器则需要与其状态建立可写连接。为界面文件添加预览。

视图所需内容 合适的输入 示例状态的存放位置
显示固定文本 不可变字符串 直接放在预览中
编辑由父级拥有的单个值 @Binding 属性 预览宿主中的本地 @State
编辑多个相关的可观察属性 通过 @Bindable 暴露的可观察模型 存放在预览宿主 @State 中的模型实例
读取通过环境提供的模型 环境依赖项 在预览根视图中显式注入

在此示例中,视图模型包含两个字符串、一个可见性设置,以及一个计算所得的显示标题。每个预览都有独立的实例。这样,初始条件会清楚地显示在预览声明旁边,示例也不会依赖共享的可变单例。

准备 macOS SwiftUI 目标

此处展示的内联 @Previewable 语法要求使用 Xcode 16 或更高版本。Apple 在 Xcode 16 中引入了该宏。将此示例的最低部署目标设为 macOS 14 或更高版本,因为 SwiftUI 从该版本开始支持 Observation;Apple 当前的 Previewable() 参考资料也列出了 macOS 14 的可用性。Xcode 16 发行说明、在 App 中管理模型数据、Previewable()。

在现有 macOS SwiftUI App 目标中添加名为 LabelEditor.swift 的 Swift 文件。接下来的两个代码块共同构成完整的预览示例。保留 App 现有的入口点;此文件定义模型、编辑器和预览。

代码在受支持的工具链中采用 @State 写法。Apple 当前文档对其实现方式做了区分:Xcode 26 及更早版本使用 State 属性包装器,而 Xcode 27 及更高版本使用 State() 宏。检查生成的代码或分析初始化开销时,请留意这一区别。State、State()。

构建可观察模型和可编辑视图

为模型应用 @Observable,让 SwiftUI 跟踪界面读取的属性。在编辑器中使用 @Bindable,为文本字段和切换控件创建所需的属性绑定。Apple 在编辑可观察模型的说明中记录了这种组合。在 App 中管理模型数据、Bindable。

import Foundation
import Observation
import SwiftUI

@MainActor
@Observable
final class LabelViewModel {
    var title: String
    var detail: String
    var showsDetail: Bool

    init(
        title: String,
        detail: String,
        showsDetail: Bool = true
    ) {
        self.title = title
        self.detail = detail
        self.showsDetail = showsDetail
    }

    var displayedTitle: String {
        let trimmed = title.trimmingCharacters(
            in: .whitespacesAndNewlines
        )
        return trimmed.isEmpty ? "Untitled label" : trimmed
    }
}

@MainActor
struct LabelEditor: View {
    @Bindable var model: LabelViewModel

    var body: some View {
        VStack(alignment: .leading, spacing: 16) {
            VStack(alignment: .leading, spacing: 6) {
                Text("Title")
                TextField("Label title", text: $model.title)
            }

            VStack(alignment: .leading, spacing: 6) {
                Text("Detail")
                TextField("Label detail", text: $model.detail)
            }

            Toggle("Show detail", isOn: $model.showsDetail)

            Divider()

            Text("Rendered label")
                .font(.caption)
                .foregroundStyle(.secondary)

            VStack(alignment: .leading, spacing: 8) {
                Text(model.displayedTitle)
                    .font(.title2)
                    .fixedSize(horizontal: false, vertical: true)

                if model.showsDetail {
                    Text(model.detail)
                        .foregroundStyle(.secondary)
                        .fixedSize(horizontal: false, vertical: true)
                }
            }
            .frame(maxWidth: .infinity, alignment: .leading)
            .padding()
            .background(
                .quaternary,
                in: RoundedRectangle(cornerRadius: 10)
            )
        }
        .textFieldStyle(.roundedBorder)
        .padding(20)
    }
}

模型有意通过初始化器接收所有示例值。初始化器只会赋值给本地属性。回退计算会改变渲染出的标签内容,但不会覆盖用户输入的标题:只有空白字符的标题仍可编辑,而渲染结果会显示 Untitled label。

编辑器从调用方接收模型。它的 @Bindable 声明提供 $model.title 之类的绑定;状态由预览宿主持有。如果此视图只显示模型的值,那么普通的模型引用就足以支持观察。此处使用 Bindable 存储,是因为控件需要可写的属性绑定。Bindable。

为每个预览提供独立的示例状态

将以下声明追加到同一文件中。每个声明都会使用明确的合成值创建自己的模型。这里的宽度仅用于示意性地检查布局,并非推荐的最小窗口尺寸。

#Preview("Standard label") {
    @Previewable @State var model = LabelViewModel(
        title: "Sample tile",
        detail: "Blue square, medium size"
    )

    LabelEditor(model: model)
        .frame(width: 420)
        .preferredColorScheme(.light)
}

#Preview("Long label in a narrow editor") {
    @Previewable @State var model = LabelViewModel(
        title: "Sample collection with a longer descriptive label",
        detail: "Round tiles arranged in three columns"
    )

    LabelEditor(model: model)
        .frame(width: 300)
        .preferredColorScheme(.dark)
}

#Preview("Blank title and hidden detail") {
    @Previewable @State var model = LabelViewModel(
        title: "   ",
        detail: "An optional detail line",
        showsDetail: false
    )

    LabelEditor(model: model)
        .frame(width: 420)
}

@Previewable 会将本地状态声明纳入 #Preview 生成的内嵌 SwiftUI 视图。Apple 文档说明,带有该标记的声明会成为生成视图的属性。请将声明放在预览闭包的根部;在预览主体之外使用 @Previewable 会导致错误。Previewable()。

由此形成了明确的所有权链:生成的预览宿主在 @State 中存储模型,LabelEditor 接收模型引用,而 @Bindable 则公开其可编辑属性。SwiftUI 支持在 state 中存储 Observation 模型,并将该引用传递给子视图。State。

将初始化器中的值视为起始测试数据。交互式预览运行时可以更改这些值。重现特定布局时,请切换到全新的预览实例,再比较其初始外观。

用小型场景矩阵检查行为

打开 LabelEditor.swift,选择 Show Canvas,然后在画布标签页中选择一个已命名的预览。如果画布已暂停,请使用 Restart。启用 Live 模式,以测试文本输入和控件逻辑。Apple 当前的画布指南介绍了这些控件。在画布中与预览交互。

使用以下检查来区分状态问题和布局问题。预期结果来自示例代码,并非测试结果报告。

场景 操作 预期结果
标准标签 将标题替换为 Amber circle 渲染的标题立即更改
详细信息可见性 先关闭 Show detail,再打开 详细信息消失后重新显示,且文本得到保留
空白标题 删除标题中的所有字符 渲染的标题变为 Untitled label
空格标题 只输入空格 字段保留这些空格;渲染的标题使用回退值
窄编辑器 查看长标题预览 渲染的标题在卡片内换行
独立测试数据 编辑一个预览,然后查看另一个全新的预览 另一个预览以其声明的模型值启动

如果文本输入有效,但渲染结果没有变化,请检查字段绑定与输出所读取的属性之间的连接。此处两条路径都指向 model.title,直接或通过 displayedTitle 连接。如果切换可见性时详细信息被清空,请查找将值赋为空字符串的代码;此示例只会更改 showsDetail。

研究环境中没有 Swift 编译器或 macOS 预览运行时,因此这些示例是根据文档中的 API 检查的,并未编译或实际运行。改造编辑器之前,请先在 Xcode 中构建目标并完成交互检查。

将共享设置与可编辑测试数据分开

对于这样的小型模型,在本地构建可让每个预览都易于理解。如果之后多个预览需要复用开销较大的设置,Apple 提供了 PreviewModifier:其共享上下文会缓存,并在参与的预览之间复用。该 API 要求 macOS 15 或更高版本。PreviewModifier。

这种有文档依据的共享行为带来了一个实际的设计选择:独立可编辑的测试模型应保留在本地;只有当你确实希望参与的预览复用某项设置时,才使用共享上下文。共享的可变模型也会共享其中的编辑内容,这会让不同场景更难比较。

如果现有视图按类型从环境中获取模型,请在预览根视图中注入所需实例。Apple 文档指出,如果请求类型对应的非可选模型缺失,就会引发异常。因此,预览必须提供其选定根视图实际读取的依赖项。在 App 中管理模型数据。

保留 AppKit 和 TextKit 的边界

上面的编辑器使用 SwiftUI 控件。如果现有 macOS 编辑器包含 AppKit 文本视图,请使用 NSViewRepresentable 将其集成到 SwiftUI 层级中。通过 makeNSView 创建并配置该视图,然后通过 updateNSView 应用传入的 SwiftUI 状态。如果编辑必须回传给 SwiftUI,请通过协调器转发 AppKit 委托的更改。让 SwiftUI 控制所表示视图的框架和边界。NSViewRepresentable。

Apple 还支持直接预览 AppKit 视图和视图控制器。不过,@Previewable 仅适用于 SwiftUI,不能用于直接的 AppKit 预览。若要将本文介绍的本地状态模式用于 AppKit 内容,请预览一个包含 representable 的 SwiftUI 宿主。为界面文件添加预览。

构造 NSTextView 时,请保留所选的文本引擎。Apple 文档说明,可通过 NSTextView(usingTextLayoutManager: true) 在初始化时选择 TextKit 2。TextKit 指南还解释说,访问 TextKit 1 的 layoutManager 可能会触发单向回退,并影响该文本视图实例。如果预期使用 TextKit 2,请采用相应的 TextKit 2 API;预览不会消除这些边界。TextKit 和文本视图的新功能。

在所有权边界处诊断预览故障

从第一个构建诊断开始检查。Swift 宏会在编译时生成代码,因此在宏调用处标出的错误可能涉及生成的声明。Xcode 的 Editor > Expand Macro 命令可用于检查这些代码。应用宏。

然后检查状态机制。现有的基于 Combine 的 ObservableObject 需要相应的所有权模式:Apple 警告,将此类对象存储在 State 中不会跟踪其已发布属性的更改,并建议对象所有者使用 StateObject。此处的示例使用 @Observable,因此其状态所有权遵循 Observation 模式。State。

完成后的预览文件应围绕明确的初始值和可见交互展开。标准、长标题和空白标题场景都按预期运行后,就可以在相应父视图拥有的模型上复用同一个编辑器。预览测试数据仍是重现这些界面状态的简洁方式。

来源