WWDC Quick Look 💓 By SwiftGGTeam
SwiftUI on the Mac: The finishing touches

SwiftUI on the Mac: The finishing touches

观看原视频

Highlight

SwiftUI 让 Mac 应用通过 Settings、AppStorage、Table 拖放、fileExporter 和 Continuity Camera 接入系统级工作流,开发者可以用声明式代码支持用户偏好、数据移动、文件导出和设备导入。

核心内容

Mac 用户会按自己的方式使用应用。有人开深色模式,有人把侧边栏图标调大,有人习惯键盘快捷键,有人直接把内容拖到另一个窗口。

如果应用没有接住这些习惯,功能本身仍然可用,体验会断开。用户要在菜单、窗口和文件系统之间来回找路。开发者也要维护一批平台细节:偏好窗口、拖放、保存面板、导入设备。

这场 session 接着第一部分的园艺应用继续做收尾。前一部分已经搭好了侧边栏、表格、多窗口、菜单栏和搜索。第二部分处理 Mac 应用最后一批高频细节。

SwiftUI 先接入系统偏好。深色模式、侧边栏图标大小会自动影响界面。开发者还可以在资源目录里设置 AccentColor,让按钮、选中状态和侧边栏图标使用应用自己的强调色。

然后,应用加入自己的设置窗口。Settings 场景负责菜单项和 Command-, 快捷键,TabView 负责分组,@AppStorage 把用户选择写进 UserDefaults

数据操作也换成 Mac 用户熟悉的方式。表格行通过 itemProvider 成为拖拽源,表格通过 onInsert 接收拖放。用户可以把一个窗口里的植物拖到另一个窗口里的花园。

最后,应用接入文件系统和设备。fileExporter 打开保存面板,把数据库导出成 CSV。ImportFromDevicesCommandsimportsItemProviders 接入 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)")
    }
}

关键点:

  • SettingsWindowGroup 同级,表示这是应用级设置窗口。
  • 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,再给每个 TableRowitemProvider

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,这样可以作为 fileExporterdocument
  • 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 更新模型,界面随状态刷新。

核心启发

  1. 做什么:给资料管理类 Mac 应用增加偏好设置窗口。 为什么值得做Settings 自动接入主菜单和 Command-,@AppStorage 负责跨启动保存用户选择。 怎么开始:在 App 里添加 Settings { SettingsView(...) },用 TabView 划分 General、Viewing、Export 等 pane,把小型偏好写入 @AppStorage

  2. 做什么:给列表或表格增加窗口间拖放复制。 为什么值得做:Mac 用户经常并排打开多个窗口整理数据,TableRow.itemProvideronInsert 可以把同一个操作变成拖放工作流。 怎么开始:为模型提供 NSItemProvider,给 TableRow 添加 itemProvider,在目标 Table 上用 onInsert(of:) 解析 providers 并插入数据。

  3. 做什么:给本地数据库增加 CSV 导出。 为什么值得做fileExporter 直接使用系统保存面板,用户得到熟悉的文件保存流程,应用不用自己管理面板细节。 怎么开始:让数据容器遵守 FileDocument,在 Commands 里添加 Export 菜单项,再用 fileExporter(isPresented:document:contentType:) 写出文件。

  4. 做什么:给资产记录类应用加入“从 iPhone 拍照导入”。 为什么值得做:Continuity Camera 把拍摄设备和 Mac 应用连起来,适合库存、园艺、维修记录、票据归档。 怎么开始:在应用命令里加入 ImportFromDevicesCommands,在详情或图库视图上加 importsItemProviders,把返回的图片 URL 写入选中模型。

  5. 做什么:给应用设置专属强调色。 为什么值得做:用户选择系统多色强调色时,应用的按钮、选中高亮和侧边栏图标可以使用自己的主题色。 怎么开始:打开 asset catalog,选择 AccentColor,把内容设置成符合应用主题的系统颜色;演示使用 system green 匹配园艺主题。

关联 Session

评论

GitHub Issues · utterances