为 SwiftUI 应用添加多窗口支持
Bring multiple windows to your SwiftUI app
2022年6月6日
一句话判断
SwiftUI 在 iOS 16 和 macOS Ventura 终于补上了多窗口管理这块拼图,但 API 设计仍然偏保守——想精细控制窗口生命周期,还是得回退到 AppKit/UIKit。
这场 Session 讲了什么
SwiftUI 自诞生起在窗口管理上就存在明显短板:你没法方便地打开新窗口、管理多个窗口实例、或者为不同场景定义不同类型的窗口。iOS 16 和 macOS Ventura 通过增强 WindowGroup 和引入新的 Window scene 类型,大幅改善了这一局面。
WindowGroup 现在支持通过 openWindow environment action 打开新窗口。你可以在按钮的 action 中调用 openWindow(id:) 或 openWindow(value:) 来创建新窗口实例。配合 @Binding 和 onOpenURL,多窗口间的数据传递也变得更加清晰。WindowGroup 的 for 参数可以接受 Codable 类型,系统会根据传入的值自动创建对应的窗口实例。
全新的 Window scene 类型用于创建单例窗口——比如设置界面、关于页面。和 WindowGroup 不同,Window 最多只有一个实例存在,再次调用 openWindow 不会创建新窗口,而是把已有的带到前台。这个行为和 macOS 上的 PreferencesWindow 需求完全吻合。iPadOS 上,多窗口通过 Stage Manager 管理,SwiftUI 的多窗口 API 和 Stage Manager 的整合是自动的。
值得深挖的点
WindowGroup 的数据驱动窗口创建
WindowGroup(for: Item.self) 配合 openWindow(value:) 是这次最有意思的 API。它的工作方式是:你定义一个 Codable 类型作为窗口的数据源,系统根据这个值创建窗口。当用户通过 openWindow(value: item) 触发时,SwiftUI 会查找匹配的 WindowGroup,用传入的 item 作为上下文渲染内容。这就意味着你可以用不同的数据打开同一类型的窗口——比如用不同的文档 ID 打开编辑器窗口。系统还负责处理窗口的去重:如果已经有窗口持有相同的值,它会激活那个窗口而不是创建新的。
Window vs WindowGroup 的选择逻辑
选 Window 还是 WindowGroup 取决于你想要的语义。WindowGroup 用于可以存在多个实例的场景(文档编辑器、浏览器标签),Window 用于全局唯一的场景(设置、关于)。但有一个容易踩的坑:Window 不支持 commands 修饰符来自定义菜单栏。如果你需要在设置窗口里有特定的菜单项,还是得用 WindowGroup 然后手动控制只显示一个实例。另外,Window 目前在 iPadOS 上不会出现在 Stage Manager 的窗口列表里,它的行为更像一个 sheet。
代码片段
使用 WindowGroup 和 openWindow 创建多窗口
@main
struct MyApp: App {
var body: some Scene {
// 主窗口组:可以打开多个实例
WindowGroup {
ContentView()
}
// 数据驱动的编辑器窗口
WindowGroup(for: Document.ID.self) { $documentID in
DocumentEditorView(documentID: $documentID)
}
// 单例设置窗口
Window("设置", id: "settings") {
SettingsView()
}
}
}
// 在视图中打开新窗口
struct ContentView: View {
@Environment(\.openWindow) private var openWindow
var body: some View {
List(documents) { doc in
Button(doc.title) {
// 根据文档 ID 打开对应的编辑窗口
openWindow(value: doc.id)
}
}
Button("打开设置") {
openWindow(id: "settings") // 打开单例窗口
}
}
}
处理窗口间的数据同步
// 使用 FetchedResults 或 @ObservableObject 在多窗口间共享数据
class DocumentStore: ObservableObject {
@Published var documents: [Document] = []
}
struct DocumentEditorView: View {
@Binding var documentID: Document.ID?
@EnvironmentObject var store: DocumentStore
var body: some View {
if let id = documentID,
let doc = store.documents.first(where: { $0.id == id }) {
// 编辑文档内容
TextEditor(text: .constant(doc.content))
}
}
}
自定义 Window 的键盘快捷键
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
.commands {
CommandGroup(replacing: .newItem) {
Button("新建文档") {
// 通过快捷键触发打开新窗口
}
.keyboardShortcut("n", modifiers: .command)
}
CommandMenu("窗口") {
Button("设置") {
openWindow(id: "settings")
}
.keyboardShortcut(",", modifiers: .command)
}
}
Window("设置", id: "settings") {
SettingsView()
}
}
}
最佳实践
为 WindowGroup 的 for 参数设计好数据模型。这个 Codable 类型应该包含足够的信息让窗口独立渲染——不要只传一个 ID 然后在窗口内部再查询数据库。理想的做法是传入所有必要的展示数据,或者确保 @EnvironmentObject 在新窗口中可用。
macOS 上测试多窗口时,注意 UserDefaults 和文件系统操作在多窗口间的竞争条件。如果两个窗口同时修改同一个文件,SwiftUI 不会帮你做合并——你需要自己用 NSFileCoordinator 或其他同步机制处理。
iPadOS 上多窗口需要用户在 Settings 中开启 Stage Manager(iPadOS 16.0 仅支持 M1 芯片的 iPad,16.1 扩展到了更多机型)。调用 openWindow 时如果 Stage Manager 不可用,系统会降级为全屏展示。你的代码不需要做特殊处理,但设计上要考虑这个降级行为。
还有什么值得关注
dismissWindowenvironment action 可以关闭指定的窗口,配合openWindow形成完整的窗口管理闭环。handlesExternalEvents修饰符可以控制WindowGroup是否响应外部 URL 请求。- macOS 上
WindowGroup支持通过defaultSize设置窗口的初始尺寸,避免首次打开时尺寸不对的问题。