Highlight
SwiftUI 让 Mac 应用通过 Settings、AppStorage、Table 拖放、fileExporter 和 Continuity Camera 接入系统级工作流,开发者可以用声明式代码支持用户偏好、数据移动、文件导出和设备导入。
核心内容
Mac 用户会按自己的方式使用应用。有人开深色模式,有人把侧边栏图标调大,有人习惯键盘快捷键,有人直接把内容拖到另一个窗口。
如果应用没有接住这些习惯,功能本身仍然可用,体验会断开。用户要在菜单、窗口和文件系统之间来回找路。开发者也要维护一批平台细节:偏好窗口、拖放、保存面板、导入设备。
这场 session 接着第一部分的园艺应用继续做收尾。前一部分已经搭好了侧边栏、表格、多窗口、菜单栏和搜索。第二部分处理 Mac 应用最后一批高频细节。
SwiftUI 先接入系统偏好。深色模式、侧边栏图标大小会自动影响界面。开发者还可以在资源目录里设置 AccentColor,让按钮、选中状态和侧边栏图标使用应用自己的强调色。
然后,应用加入自己的设置窗口。Settings 场景负责菜单项和 Command-, 快捷键,TabView 负责分组,@AppStorage 把用户选择写进 UserDefaults。
数据操作也换成 Mac 用户熟悉的方式。表格行通过 itemProvider 成为拖拽源,表格通过 onInsert 接收拖放。用户可以把一个窗口里的植物拖到另一个窗口里的花园。
最后,应用接入文件系统和设备。fileExporter 打开保存面板,把数据库导出成 CSV。ImportFromDevicesCommands 和 importsItemProviders 接入 Continuity Camera(连续互通相机),用户用 iPhone 拍照后,图片直接进入 Mac 应用里的植物记录。
详细内容
设置窗口:Settings、TabView 和 AppStorage
(03:28)
SwiftUI 的 Mac 应用可以在 App 里添加 Settings 场景。系统会把偏好设置菜单项放到主菜单里,并配置标准快捷键 Command-,。
演示里的设置窗口有两个 pane:General 和 Viewing。实现方式是用 TabView 放两个子视图,再给每个子视图加 tabItem。
import SwiftUI
struct Garden: Identifiable {
let id: UUID
var name: String
var displayYear: String
}
final class GardenStore: ObservableObject {
@Published var gardens: [Garden] = [
Garden(id: UUID(), name: "Indoor Plants", displayYear: "2021"),
Garden(id: UUID(), name: "Backyard Flower Bed", displayYear: "2021")
]
}
@main
struct GardenApp: App {
@StateObject private var store = GardenStore()
var body: some Scene {
WindowGroup {
ContentView(store: store)
}
Settings {
SettingsView(store: store)
}
}
}
struct SettingsView: View {
@ObservedObject var store: GardenStore
var body: some View {
TabView {
GeneralSettings(gardens: store.gardens)
.tabItem {
Label("General", systemImage: "gear")
}
ViewingSettings()
.tabItem {
Label("Viewing", systemImage: "eyeglasses")
}
}
.padding()
}
}
struct GeneralSettings: View {
let gardens: [Garden]
@AppStorage("defaultGarden") private var selection: String?
var body: some View {
Form {
Picker("Default Garden", selection: $selection) {
Text("None").tag(String?.none)
ForEach(gardens) { garden in
Text("\(garden.name) \(garden.displayYear)")
.tag(Optional(garden.id.uuidString))
}
}
.fixedSize()
}
}
}
struct ViewingSettings: View {
var body: some View {
Text("Viewing")
}
}
struct ContentView: View {
@ObservedObject var store: GardenStore
var body: some View {
Text("Gardens: \(store.gardens.count)")
}
}
关键点:
Settings和WindowGroup同级,表示这是应用级设置窗口。SettingsView(store: store)把同一个模型传给设置界面,设置可以读取当前花园列表。TabView生成带工具栏图标的 Mac 设置窗口样式。Label("General", systemImage: "gear")提供文字和 SF Symbol 图标。@AppStorage("defaultGarden")用同一个 key 把默认花园持久化到UserDefaults。Text("None").tag(String?.none)给 Picker 一个空选择,对应演示里的 None。ForEach(gardens)为每个花园生成一项,tag 保存花园 ID 字符串。
(07:47)
设置保存以后,主界面要读取同一个 key。演示里的逻辑是:如果当前窗口已经有选中花园,就用窗口自己的选择;如果没有,就回退到默认花园。
import SwiftUI
struct GardenSelectionView: View {
@State private var selectedGardenID: String?
@AppStorage("defaultGarden") private var defaultGardenID: String?
var selection: Binding<String?> {
Binding(
get: {
selectedGardenID ?? defaultGardenID
},
set: {
selectedGardenID = $0
}
)
}
var body: some View {
Picker("Garden", selection: selection) {
Text("Indoor Plants").tag(Optional("indoor-plants"))
Text("Backyard Flower Bed").tag(Optional("backyard-flower-bed"))
}
}
}
关键点:
@State保存当前窗口里的临时选择。@AppStorage("defaultGarden")读取设置窗口写入的默认值。Binding(get:set:)自定义选择逻辑。get先返回selectedGardenID,没有当前选择时返回defaultGardenID。set只更新selectedGardenID,避免用户在窗口里切换花园时改掉全局默认值。
表格拖放:TableRow、itemProvider 和 onInsert
(09:30)
第一部分已经给应用加了“添加植物”的菜单项。这里新增另一条工作流:直接拖放。
SwiftUI 的 Table 可以把每一行变成拖拽源。演示里做法是从 Table 初始化器里移除数据数组,改用 row builder,再给每个 TableRow 加 itemProvider。
import SwiftUI
import UniformTypeIdentifiers
struct Plant: Identifiable, Hashable {
let id = UUID()
var name: String
static let draggableType = UTType.plainText
var itemProvider: NSItemProvider {
NSItemProvider(object: name as NSString)
}
static func fromItemProviders(_ providers: [NSItemProvider], completion: @escaping ([Plant]) -> Void) {
var plants: [Plant] = []
let group = DispatchGroup()
for provider in providers {
group.enter()
provider.loadObject(ofClass: NSString.self) { object, _ in
if let name = object as? String {
plants.append(Plant(name: name))
}
group.leave()
}
}
group.notify(queue: .main) {
completion(plants)
}
}
}
struct GardenDetail: View {
@State private var plants = [
Plant(name: "Daisy"),
Plant(name: "Fern")
]
var body: some View {
Table {
TableColumn("Name", value: \.name)
} rows: {
ForEach(plants) { plant in
TableRow(plant)
.itemProvider {
plant.itemProvider
}
}
}
.onInsert(of: [Plant.draggableType]) { index, itemProviders in
Plant.fromItemProviders(itemProviders) { newPlants in
plants.insert(contentsOf: newPlants, at: index)
}
}
}
}
关键点:
TableColumn("Name", value: \.name)声明表格的名称列。rows闭包让开发者逐行构建TableRow,方便给每行加修饰符。TableRow(plant)把当前植物对象绑定到这一行。itemProvider返回plant.itemProvider,这一行就能被拖出。onInsert(of:)声明表格接受的内容类型,演示里使用Plant.draggableType。- 闭包里的
index是用户放下的位置。 itemProviders是拖放系统传入的数据载体。insert(contentsOf:at:)把解析出的植物插入到目标位置。
文件导出:CommandGroup 和 fileExporter
(12:08)
用户常常需要把应用数据拿出去。备份、分享、导入到别的应用,都会用到文件系统。
这场演示用 Commands 增加 File 菜单里的 Export 项。菜单按钮只负责切换一个状态,真正的保存面板由 fileExporter 打开。
import SwiftUI
import UniformTypeIdentifiers
struct Store: FileDocument {
static var readableContentTypes: [UTType] { [.commaSeparatedText] }
var plants: [String] = ["Daisy", "Fern"]
init() {}
init(configuration: ReadConfiguration) throws {
plants = []
}
func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper {
let csv = plants.joined(separator: "\n")
let data = Data(csv.utf8)
return FileWrapper(regularFileWithContents: data)
}
}
@main
struct ExportingGardenApp: App {
@State private var store = Store()
var body: some Scene {
WindowGroup {
Text("Garden")
}
.commands {
ImportExportCommands(store: store)
}
}
}
struct ImportExportCommands: Commands {
let store: Store
@State private var isShowingExport = false
var body: some Commands {
CommandGroup(replacing: .importExport) {
Section {
Button("Export…") {
isShowingExport = true
}
.fileExporter(
isPresented: $isShowingExport,
document: store,
contentType: Store.readableContentTypes.first ?? .commaSeparatedText
) { result in
switch result {
case .success(let url):
print("Exported to \(url)")
case .failure(let error):
print("Export failed: \(error.localizedDescription)")
}
}
}
}
}
}
关键点:
Store遵守FileDocument,这样可以作为fileExporter的document。readableContentTypes暴露 CSV 类型,对应演示里的Store.readableContentTypes.first。fileWrapper(configuration:)把内存数据转换成文件内容。.commands给应用场景添加菜单命令。CommandGroup(replacing: .importExport)把菜单项放到系统预期的导入导出位置。Button("Export…")使用省略号,提示用户接下来会出现保存面板。isShowingExport = true触发导出流程。fileExporter接收显示状态、文档对象、内容类型和完成回调。
Continuity Camera:从 iPhone 直接导入图片
(15:08)
园艺应用里的植物记录已经有文字数据。图片适合记录植物的生长状态。
Continuity Camera 可以让用户在 Mac 菜单里选择“Import from iPhone > Take Photo”。iPhone 拍完以后,图片通过 item provider 交给 SwiftUI 视图。
import SwiftUI
import UniformTypeIdentifiers
struct PlantImage: Identifiable {
let id: UUID
var name: String
var imageURL: URL?
static let importImageTypes: [UTType] = [.image]
static func importImageFromProviders(_ providers: [NSItemProvider], completion: @escaping (URL?) -> Void) {
guard let provider = providers.first else {
completion(nil)
return
}
provider.loadFileRepresentation(forTypeIdentifier: UTType.image.identifier) { url, _ in
completion(url)
}
}
}
struct GalleryView: View {
@State private var plants = [
PlantImage(id: UUID(), name: "Daisy"),
PlantImage(id: UUID(), name: "Fern")
]
@State private var selection = Set<PlantImage.ID>()
var body: some View {
List(plants, selection: $selection) { plant in
Text(plant.name)
}
.importsItemProviders(selection.isEmpty ? [] : PlantImage.importImageTypes) { providers in
PlantImage.importImageFromProviders(providers) { imageURL in
guard let imageURL else { return }
for id in selection {
if let index = plants.firstIndex(where: { $0.id == id }) {
plants[index].imageURL = imageURL
}
}
}
}
}
}
关键点:
ImportFromDevicesCommands负责在主菜单里提供从设备导入的入口,演示把它加在ImportExportCommands后面。importsItemProviders让当前视图接收设备导入产生的 item providers。selection.isEmpty ? [] : PlantImage.importImageTypes控制菜单可用性;没有选中植物时,不接受图片导入。PlantImage.importImageTypes对应演示里的所有系统图片类型,这里用.image表达图片导入能力。providers是 Continuity Camera 传回的图片载体。importImageFromProviders返回保存到磁盘的图片 URL。for id in selection把同一张导入图片应用到当前选中的植物记录。plants[index].imageURL = imageURL更新模型,界面随状态刷新。
核心启发
-
做什么:给资料管理类 Mac 应用增加偏好设置窗口。 为什么值得做:
Settings自动接入主菜单和Command-,,@AppStorage负责跨启动保存用户选择。 怎么开始:在App里添加Settings { SettingsView(...) },用TabView划分 General、Viewing、Export 等 pane,把小型偏好写入@AppStorage。 -
做什么:给列表或表格增加窗口间拖放复制。 为什么值得做:Mac 用户经常并排打开多个窗口整理数据,
TableRow.itemProvider和onInsert可以把同一个操作变成拖放工作流。 怎么开始:为模型提供NSItemProvider,给TableRow添加itemProvider,在目标Table上用onInsert(of:)解析 providers 并插入数据。 -
做什么:给本地数据库增加 CSV 导出。 为什么值得做:
fileExporter直接使用系统保存面板,用户得到熟悉的文件保存流程,应用不用自己管理面板细节。 怎么开始:让数据容器遵守FileDocument,在Commands里添加 Export 菜单项,再用fileExporter(isPresented:document:contentType:)写出文件。 -
做什么:给资产记录类应用加入“从 iPhone 拍照导入”。 为什么值得做:Continuity Camera 把拍摄设备和 Mac 应用连起来,适合库存、园艺、维修记录、票据归档。 怎么开始:在应用命令里加入
ImportFromDevicesCommands,在详情或图库视图上加importsItemProviders,把返回的图片 URL 写入选中模型。 -
做什么:给应用设置专属强调色。 为什么值得做:用户选择系统多色强调色时,应用的按钮、选中高亮和侧边栏图标可以使用自己的主题色。 怎么开始:打开 asset catalog,选择
AccentColor,把内容设置成符合应用主题的系统颜色;演示使用 system green 匹配园艺主题。
关联 Session
- SwiftUI on the Mac: Build the fundamentals — 本 session 的第一部分,先搭起 Mac 应用的侧边栏、表格、工具栏、搜索、多窗口和菜单栏。
- What’s new in SwiftUI — 查看 SwiftUI 2021 的整体新增能力,包含 macOS Table、searchable、Canvas、FocusState 等 API。
- Direct and reflect focus in SwiftUI — 补充 Mac 和多平台应用里的焦点移动、键盘提交和输入体验。
- Demystify SwiftUI — 理解 SwiftUI 的身份、生命周期和依赖关系,帮助判断状态应该放在哪里。
评论
GitHub Issues · utterances