SwiftUI on the Mac: The finishing touches
Swift & UI 进阶 14m

SwiftUI 在 Mac 上的收尾工作

SwiftUI on the Mac: The finishing touches

2021年6月9日

在 Apple 官方观看视频

一句话判断

如果你的 SwiftUI Mac App 看起来「有点不对劲」,这个 Session 就是你的修复手册——从窗口行为到菜单栏,把那些让原生 App 感觉「原生」的细节一次性讲清楚。

这场 Session 讲了什么

SwiftUI 在 macOS 上的基础功能在 WWDC 2020 已经有了,这个 Session 聚焦在「收尾」——让你的 Mac App 不只是能跑,而是真正像一个 Mac App。Session 分几个主题展开:窗口管理(多窗口、窗口尺寸约束)、菜单栏集成(菜单命令、右键菜单)、工具栏设计(toolbar 的正确用法)、以及键盘快捷键。

窗口管理方面,macOS 12 新增了 defaultSizewindowResizability 修饰符,让你可以精确控制窗口的初始大小和缩放范围。以前的 SwiftUI Mac App 窗口大小经常「不对」——要么太大要么太小,用户得手动调整。现在你可以设置理想的默认尺寸和最小/最大尺寸。

菜单栏方面,SwiftUI 的 CommandGroupCommandMenu API 终于可以覆盖大部分 Mac App 的菜单需求。Session 展示了如何把「文件 > 新建窗口」、编辑操作、以及自定义的 App 专属命令正确地集成到菜单栏中。关键是你需要理解 SwiftUI 的命令系统是声明式的——你定义的是「这个命令存在」,而不是「点这个菜单项时执行这段代码」。

值得深挖的点

windowResizability(.contentSize) 的陷阱。 当你使用 .contentSize 修饰符时,窗口的尺寸会根据内容自动调整。但如果你有一个可滚动的列表或动态内容,窗口大小可能会不可预测地跳动。Session 的建议是:对于固定布局的窗口(比如设置面板、关于对话框),使用 .contentSize;对于有可变内容的窗口(比如文档编辑器),使用 .contentMinSize 并手动设置最小尺寸。.windowResizability(.userAllowed) 是最灵活的选项,让用户自由调整但受你的 min/max 约束。

focusedValueFocusSystem 的配合。 Mac App 的一个核心体验是「当前焦点」。当你选中一段文字时,编辑菜单的命令应该作用于这段文字;当你选中一个列表项时,命令应该作用于这个列表项。SwiftUI 的 focusedValue 修饰符让你可以把当前焦点关联的值「发布」出去,然后菜单命令通过 @FocusedValue 读取这些值。这解决了 SwiftUI Mac App 长期以来「菜单命令不知道该作用于什么」的问题。

代码片段

import SwiftUI

@main
struct MyApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        // 设置窗口默认大小和缩放约束
        .defaultSize(width: 800, height: 600)
        .windowResizability(.contentSize)

        // 设置面板——固定大小,不可缩放
        Window("设置", id: "settings") {
            SettingsView()
        }
        .windowResizability(.contentSize)
        .windowStyle(.hiddenTitleBar)
    }
}
// 集成菜单栏命令
@main
struct MyApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .commands {
            // 替换「新建」命令
            CommandGroup(replacing: .newItem) {
                Button("新建窗口") {
                    openNewWindow()
                }
                .keyboardShortcut("n", modifiers: .command)
            }

            // 添加自定义菜单
            CommandMenu("工具") {
                Button("导出 PDF") {
                    exportPDF()
                }
                .keyboardShortcut("e", modifiers: [.command, .shift])

                Divider()

                Button("清除缓存") {
                    clearCache()
                }
            }
        }
    }
}
// 使用 focusedValue 让菜单命令感知当前焦点
struct DocumentView: View {
    @State private var selectedText = ""

    var body: some View {
        TextEditor(text: $selectedText)
            // 将选中的文字发布给焦点系统
            .focusedValue(\.selectedText, selectedText)
    }
}

// 在菜单命令中读取焦点值
struct DocumentCommands: Commands {
    @FocusedValue(\.selectedText) var selectedText

    var body: some Commands {
        CommandGroup(after: .editMenu) {
            Button("复制选中文字") {
                copyToClipboard(selectedText ?? "")
            }
            .disabled(selectedText == nil || selectedText!.isEmpty)
        }
    }
}

最佳实践

  • Mac App 的窗口默认尺寸应该基于内容而非屏幕尺寸——在 13 寸 MacBook 和 27 寸 iMac 上,同样的 800x600 窗口体感完全不同
  • 菜单命令中必须正确处理 .disabled 状态——在 Mac 上,用户会通过菜单灰掉的程度来判断当前能做什么
  • 为你的 App 添加 Settings scene(Settings 而不是 Window),这样系统会自动把它放在「App 名称 > 设置」菜单下
  • 测试时在 System Preferences > Appearance 中切换 Light/Dark 模式和 Accent Color,确保你的 SwiftUI 视图正确响应

还有什么值得关注

  • macOS 12 的 WindowGroup 支持通过 id 参数创建多种类型的窗口
  • SwiftUI 的 ToolbarItem 在 macOS 上会自动适配为原生工具栏控件
  • openWindowopenDocument 环境方法让你可以在代码中编程式地打开新窗口
WWDC 2021