5.4 用 SwiftUI 构建基于文稿的应用

原文链接: https://developer.apple.com/documentation/swiftui/building-a-document-based-app-with-swiftui

5.4 用 SwiftUI 构建基于文稿的应用

在多平台应用中创建、存储和打开文稿。

概述

借助这个示例应用,人们可以在 iPhone、iPad、Mac 和 Vision Pro 上创建、存储和打开清单文稿。在应用中,人们还可以:

  • 添加、删除和重新排列清单条目。
  • 选中和取消选中条目,把它们标记为已完成。
  • 撤销和重做自己的改动。

该应用使用 SwiftUI 的 DocumentGroup 场景和 Document 协议来打开、存储和管理清单文件,并注册自己的自定义文稿类型,让系统知道应当用这个应用打开清单文件。

一张截图,展示 iPad 上的文稿启动体验,标题视图左右两侧分别有一个机器人和一盆植物作为配件。

注意:这个示例针对的是创建基于文稿的应用与更新你的基于文稿的应用中所述的 Document 协议。

配置示例代码项目

要在你的设备上构建并运行这个示例,请按以下步骤为该项目的目标选择你的开发团队:

  1. 用最新版本的 Xcode 打开示例。
  2. 选中顶层的项目。
  3. 对该项目的目标,在 Signing & Capabilities 面板的 Team 弹出菜单中选择你的团队,让 Xcode 自动管理你的描述文件。

创建数据模型

这个示例的数据模型把清单定义为条目的集合。每个条目都有一个标题,以及一个记录它是否被勾选的布尔值。ChecklistItem 和 Checklist 遵循 Codable 以便序列化,并遵循 Identifiable 以便在枚举时唯一标识。ChecklistItem 还遵循 Equatable,让 SwiftUI 能检测到条目内容何时变化,如下所示:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
struct ChecklistItem: Identifiable, Codable, Equatable {
    var id = UUID()
    var isChecked = false
    var title: String
}

struct Checklist: Identifiable, Codable {
    var id = UUID()
    var items: [ChecklistItem]
}

定义应用的场景

当 App 声明中的第一个场景是 DocumentGroup 或 DocumentGroupLaunchScene 时,应用就成为基于文稿的应用。在这个示例中,文稿类型遵循 Document 协议。构造器的 editor 闭包返回一个渲染文稿内容的视图,makeDocument 闭包创建一个新的文稿实例,如下所示:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
@main
struct DocumentBasedApp: App {
    var body: some Scene {
        DocumentGroup { document in
            ChecklistView(document: document)
        } makeDocument: { configuration, context in
            ChecklistDocument()
        }
    }
}

定制 iOS 和 iPadOS 的启动体验

你可以用自定义标题、操作按钮和屏幕背景来更新 iOS 和 iPadOS 上的默认启动体验。要添加带自定义标签的操作按钮,请使用 Button。要添加一个创建新文稿的按钮,请使用带自定义标题的 NewDocumentButton。你可以定制背景,例如用某个构造器添加视图或 backgroundStyle,例如 init(:backgroundStyle::backgroundAccessoryView:overlayAccessoryView:)。这个示例用 DocumentGroupLaunchScene 的 init(::background:overlayAccessoryView:) 构造器定制标题视图的背景,并把一个机器人和一盆植物作为叠加配件视图放在标题两侧,如下所示:

1
2
3
4
5
6
7
8
9
DocumentGroupLaunchScene("Checklist") {
    NewDocumentButton("Start a Checklist")
} background: {
    Image(.pinkJungle)
        .resizable()
        .scaledToFill()
} overlayAccessoryView: { _ in
    AccessoryView()
}

由于 macOS 上没有 DocumentGroupLaunchScene,请在 #if os(iOS) 条件编译块中把这个场景与示例的 DocumentGroup 场景并列放置。

采用文稿协议

ChecklistDocument 类采用 Document 协议,以便从文件读取清单并写入文件。由于 Document 要求引用类型,ChecklistDocument 是用 Observable() 标记的 final class,而不是结构体。readableContentTypes 属性定义示例可以读取的类型,具体来说就是 .checklistDocument 类型,如下所示:

1
static let readableContentTypes: [UTType] = [.checklistDocument]

示例用它的 reader(configuration:) 方法返回的 DocumentReader 从文件读取清单。这个示例使用 FileWrapperDocumentReader,并传入一个用 JSONDecoder 解码文件包装器内容的闭包,如下所示:

1
2
3
4
5
6
7
8
func reader(configuration: sending ReadConfiguration) -> sending FileWrapperDocumentReader<Checklist> {
    FileWrapperDocumentReader(configuration) { fileWrapper in
        guard let data = fileWrapper.regularFileContents else {
            throw CocoaError(.fileReadCorruptFile)
        }
        return try JSONDecoder().decode(Checklist.self, from: data)
    }
}

SwiftUI 在后台读取清单之后,会把结果在主 actor 上传给文稿的 apply(snapshot:previous:) 方法,由它更新文稿的可观察状态,如下所示:

1
2
3
4
@MainActor
func apply(snapshot: sending Checklist, previous: sending Checklist?) async throws {
    checklist = snapshot
}

当有人存储文稿时,示例从 snapshot(contentType:) 返回其数据的快照,该方法同样在主 actor 上运行,如下所示:

1
2
3
4
@MainActor
func snapshot(contentType: UTType) async throws -> sending Checklist {
    checklist // 复制一份。
}

反过来,writer(configuration:) 方法返回一个 DocumentWriter,由它编码快照并写入磁盘。这个示例使用 FileWrapperDocumentWriter,并传入一个用 JSONEncoder 实例把快照序列化为文件包装器的闭包,如下所示:

1
2
3
4
5
6
func writer(configuration: sending WriteConfiguration) -> sending FileWrapperDocumentWriter<Checklist> {
    FileWrapperDocumentWriter(configuration) { snapshot, _ in
        let data = try JSONEncoder().encode(snapshot)
        return FileWrapper(regularFileWithContents: data)
    }
}

注册撤销与重做操作

使用 Document 协议时,必须管理撤销才能启用自动存储。从环境中读取当前生效的 UndoManager,并通过会注册撤销操作的方法更新文稿。在撤销闭包中再次调用同一个方法,也会注册重做操作,因此大多数操作只需要一个方法,如下所示:

1
2
3
4
5
6
7
8
@MainActor
func toggleItem(_ item: Binding<ChecklistItem>, undoManager: UndoManager? = nil) {
    item.wrappedValue.isChecked.toggle()

    undoManager?.registerUndo(withTarget: self) { doc in
        doc.toggleItem(item, undoManager: undoManager)
    }
}

导出自定义文稿类型

应用为它创建的文稿定义并导出一个自定义内容类型。它在项目的信息属性列表文件中的 UTExportedTypeDeclarations 键下声明这个自定义类型。这个示例在信息属性列表文件中用 com.example.checklist 作为标识符,如下面的代码所示:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
<key>CFBundleDocumentTypes</key>
<array>
    <dict>
        <key>CFBundleTypeRole</key>
        <string>Editor</string>
        <key>LSHandlerRank</key>
        <string>Default</string>
        <key>LSItemContentTypes</key>
        <array>
            <string>com.example.checklist</string>
        </array>
        <key>NSUbiquitousDocumentUserActivityType</key>
        <string>$(PRODUCT_BUNDLE_IDENTIFIER).example-document</string>
    </dict>
</array>
<key>UTExportedTypeDeclarations</key>
<array>
    <dict>
        <key>UTTypeConformsTo</key>
        <array>
            <string>public.data</string>
            <string>public.content</string>
        </array>
        <key>UTTypeDescription</key>
        <string>Checklist Document</string>
        <key>UTTypeIconFiles</key>
        <array/>
        <key>UTTypeIdentifier</key>
        <string>com.example.checklist</string>
        <key>UTTypeTagSpecification</key>
        <dict>
            <key>public.filename-extension</key>
            <array>
                <string>checklist</string>
            </array>
        </dict>
    </dict>
</array>

为方便起见,你也可以在代码中定义该内容类型,如下例所示:

1
2
3
extension UTType {
    static let checklistDocument = UTType(exportedAs: "com.example.checklist")
}

为你声明的每一种自定义格式都指定文件扩展名,以确保操作系统会用你的应用打开具有该扩展名的文件。关于自定义文件与数据类型的更多信息,参见为应用定义文件与数据类型。

另见