WWDC Quick Look 💓 By SwiftGGTeam
Bring multiple windows to your SwiftUI app

Bring multiple windows to your SwiftUI app

观看原视频

Highlight

SwiftUI 自诞生起在窗口管理上就存在明显短板:你没法方便地打开新窗口、管理多个窗口实例、或者为不同场景定义不同类型的窗口。iOS 16 和 macOS Ventura 通过增强 WindowGroup 和引入新的 Window scene 类型,大幅改善了这一局面。


核心内容

SwiftUI app 由 AppSceneView 组成。View 负责界面,Scene 负责把界面放到系统窗口、文档窗口、设置窗口这类外壳里。

过去,很多 SwiftUI app 只有一个 WindowGroup。这对主窗口够用,但一旦要做桌面级体验,就会遇到具体问题:阅读列表需要一个单独的活动统计窗口;书籍详情适合从上下文菜单打开新窗口;文档 app 需要从按钮创建新文档或打开已有文件;菜单栏工具希望常驻系统菜单栏。

这场 Session 把这些问题拆成三类。

第一类是新的 scene 类型。Window 表示单一窗口,适合全局状态;MenuBarExtra 是 macOS 专用 scene,会在系统菜单栏放一个常驻入口。

第二类是从界面代码主动打开窗口。SwiftUI 通过环境提供 openWindownewDocumentopenDocument。按钮不需要知道 AppKit 或 UIKit 的窗口对象,只要调用对应 action。

第三类是窗口行为定制。scene 现在可以移除默认命令、指定默认位置和大小、绑定打开窗口的键盘快捷键。这样,窗口不再只是系统默认行为,而是 app 结构的一部分。


详细内容

Scene 可以组合:一个 App 定义多个入口

02:01)SwiftUI 的 App body 可以返回多个 scene。主界面用 WindowGroup,文档界面用 DocumentGroup,macOS 设置用 Settings。每个 scene 都描述一种系统级入口。

import SwiftUI
import UniformTypeIdentifiers

@main
struct MultiSceneApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }

        #if os(iOS) || os(macOS)
        DocumentGroup(viewing: CustomImageDocument.self) { file in
            ImageViewer(file.document)
        }
        #endif

        #if os(macOS)
        Settings {
            SettingsView()
        }
        #endif
    }
}

struct ContentView: View {
    var body: some View {
        Text("Content")
    }
}

struct ImageViewer: View {
    var document: CustomImageDocument

    init(_ document: CustomImageDocument) {
        self.document = document
    }

    var body: some View {
        Text("Image")
    }
}

struct SettingsView: View {
    var body: some View {
        Text("Settings")
    }
}

struct CustomImageDocument: FileDocument {
    var data: Data

    static var readableContentTypes: [UTType] { [UTType.image] }

    init(configuration: ReadConfiguration) throws {
        guard let data = configuration.file.regularFileContents
        else {
            throw CocoaError(.fileReadCorruptFile)
        }
        self.data = data
    }

    func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper {
        FileWrapper(regularFileWithContents: data)
    }
}

关键点:

  • WindowGroup 定义普通 app 窗口,在支持多窗口的平台上可以有多个实例。
  • DocumentGroup 把文件类型和文档界面绑定起来,系统负责文档窗口的生命周期。
  • Settings 只在 macOS 编译,表示 app 的设置界面。
  • CustomImageDocument 遵守 FileDocument,通过 readableContentTypes 声明可读取的文件类型。

Window:给全局状态一个唯一窗口

04:38Window 是 2022 新增的 scene 类型。它和 WindowGroup 的差异很明确:同一个 Window scene 只会有一个窗口实例。BookClub 的 Activity 窗口展示全局阅读活动,重复打开多个没有意义,所以适合用 Window

import SwiftUI

@main
struct BookClub: App {
    @StateObject private var store = ReadingListStore()

    var body: some Scene {
        WindowGroup {
            ReadingListViewer(store: store)
        }
        Window("Activity", id: "activity") {
            ReadingActivity(store: store)
        }
    }
}

struct ReadingListViewer: View {
    @ObservedObject var store: ReadingListStore

    var body: some View {
        Text("Reading List")
    }
}

struct ReadingActivity: View {
    @ObservedObject var store: ReadingListStore

    var body: some View {
        Text("Reading Activity")
    }
}

class ReadingListStore: ObservableObject {
}

关键点:

  • @StateObject private var store 放在 App 里,主窗口和 Activity 窗口共享同一份 app 状态。
  • WindowGroup 继续负责阅读列表主界面。
  • Window("Activity", id: "activity") 定义一个带标识符的单例窗口。
  • id: "activity" 会在后面的 openWindow(id:) 中使用,字符串必须和 scene 定义匹配。

05:34)打开这个窗口时,从环境取出 openWindow,在按钮 action 中调用即可。如果窗口已经存在,系统会把已有窗口带到前台。

struct OpenWindowButton: View {
    @Environment(\.openWindow) private var openWindow

    var body: some View {
        Button("Open Activity Window") {
            openWindow(id: "activity")
        }
    }
}

关键点:

  • @Environment(\.openWindow) 读取 SwiftUI 提供的窗口打开动作。
  • Button 的 action 直接调用 openWindow,不用接触平台窗口对象。
  • openWindow(id: "activity") 对应上面的 Window scene 标识符。

WindowGroup 可以按值打开详情窗口

05:57)详情页不适合用 Window。每本书都可能有自己的详情窗口,所以要用带 for: 参数的新 WindowGroup 初始化器。按钮传入书籍 ID,scene 根据 ID 创建或复用窗口。

import SwiftUI

@main
struct BookClub: App {
    @StateObject private var store = ReadingListStore()

    var body: some Scene {
        WindowGroup {
            ReadingListViewer(store: store)
        }
        Window("Activity", id: "activity") {
            ReadingActivity(store: store)
        }
        WindowGroup("Book Details", for: Book.ID.self) { $bookId in
            BookDetail(id: $bookId, store: store)
        }
    }
}

struct OpenWindowButton: View {
    var book: Book
    @Environment(\.openWindow) private var openWindow

    var body: some View {
        Button("Open In New Window") {
            openWindow(value: book.id)
        }
    }
}

struct BookDetail: View {
    @Binding var id: Book.ID?
    @ObservedObject var store: ReadingListStore

    var body: some View {
        Text("Book Details")
    }
}

struct Book: Identifiable {
    var id: UUID
}

class ReadingListStore: ObservableObject {
}

关键点:

  • WindowGroup("Book Details", for: Book.ID.self) 声明这个 scene 接收 Book.ID 类型的展示值。
  • openWindow(value: book.id) 传入同一种类型,SwiftUI 才能找到对应 scene。
  • view builder 收到的是 Binding<Book.ID?>,详情视图可以在打开后更新这个值。
  • 演讲建议传模型标识符,而非整个值类型模型。这样多个窗口仍然通过 store 指向同一份数据。
  • 传入值必须遵守 HashableCodable:前者用于匹配已打开窗口,后者用于状态恢复。

09:36)这个匹配规则解决了重复窗口问题。同一本书已经打开时,再次从上下文菜单打开,系统会复用已有窗口并把它排到前面。

文档窗口也有环境 action

06:16)文档 app 可以用 newDocument 创建新文档窗口。前提是 App 定义了带编辑角色的 DocumentGroup,并且传入的文档类型匹配。

import SwiftUI
import UniformTypeIdentifiers

@main
struct TextFileApp: App {
    var body: some Scene {
        DocumentGroup(viewing: TextFile.self) { file in
            TextEditor(text: file.$document.text)
        }
    }
}

struct NewDocumentButton: View {
    @Environment(\.newDocument) private var newDocument

    var body: some View {
        Button("Open New Document") {
            newDocument(TextFile())
        }
    }
}

struct TextFile: FileDocument {
    var text: String

    static var readableContentTypes: [UTType] { [UTType.plainText] }

    init() {
        text = ""
    }

    init(configuration: ReadConfiguration) throws {
        guard let data = configuration.file.regularFileContents,
              let string = String(data: data, encoding: .utf8)
        else {
            throw CocoaError(.fileReadCorruptFile)
        }
        text = string
    }

    func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper {
        let data = text.data(using: .utf8)!
        return FileWrapper(regularFileWithContents: data)
    }
}

关键点:

  • DocumentGroup(viewing: TextFile.self)TextFile 文档和 TextEditor 界面连接起来。
  • @Environment(\.newDocument) 取得新建文档的 action。
  • newDocument(TextFile()) 每次调用都会用新的 TextFile 值打开文档窗口。
  • TextFile 通过 FileDocument 处理文件读取和写回。

06:41)已有文件用 openDocument。它接收文件 URL,并且是异步可抛错调用。

struct OpenDocumentButton: View {
    var documentURL: URL
    @Environment(\.openDocument) private var openDocument

    var body: some View {
        Button("Open Document") {
            Task {
                do {
                    try await openDocument(at: documentURL)
                } catch {
                    // Handle error
                }
            }
        }
    }
}

关键点:

  • @Environment(\.openDocument) 取得打开已有文档的 action。
  • Task 用来调用异步的 openDocument(at:)
  • documentURL 指向磁盘上的文件。
  • catch 分支处理文件无法打开、类型不匹配等错误。

03:01MenuBarExtra 是 macOS 专用 scene。它不先显示主窗口,而是在系统菜单栏显示一个 label,点击后展示菜单或无边框窗口。

import SwiftUI

@main
struct UtilityApp: App {
    var body: some Scene {
        MenuBarExtra("Utility App", systemImage: "hammer") {
            AppMenu()
        }
    }
}

struct AppMenu: View {
    var body: some View {
        Text("App Menu Item")
    }
}

关键点:

  • MenuBarExtra 直接出现在 App 的 scene 列表里。
  • 第一个参数是菜单栏项目标题。
  • systemImage 使用 SF Symbol 作为菜单栏图标。
  • 内容闭包返回点击菜单栏项目后显示的 SwiftUI view。

03:49)默认样式会拉下一个菜单。如果内容更像仪表盘,可以改成窗口样式。

import SwiftUI

@main
struct UtilityApp: App {
    var body: some Scene {
        MenuBarExtra("Time Tracker", systemImage: "rectangle.stack.fill") {
            TimeTrackerChart()
        }
        .menuBarExtraStyle(.window)
    }
}

struct TimeTrackerChart: View {
    var body: some View {
        Text("Time Tracker Chart")
    }
}

关键点:

  • .menuBarExtraStyle(.window) 把展示方式改成锚定在菜单栏项目上的无边框窗口。
  • 这个样式适合图表、控制面板这类需要更多空间的内容。
  • MenuBarExtra 可以单独构成工具 app,也可以和主窗口 scene 组合使用。

Scene 修饰符控制窗口命令、位置、大小和快捷键

11:16)多个 WindowGroup 会带来默认菜单命令。Book details 只能从上下文菜单打开时,可以移除这个 scene 的默认命令。

WindowGroup("Book Details", for: Book.ID.self) { $bookId in
    BookDetail(id: $bookId, store: store)
}
.commandsRemoved()

关键点:

  • commandsRemoved() 作用在 scene 上。
  • 它会移除这个 scene 自动提供的默认命令。
  • 在演讲示例里,它让 File 菜单只保留打开主 WindowGroup 的项目。

11:46)当一个 scene 要挂多个修饰符时,可以抽成自定义 Scene。这样 App 定义更短,也方便复用。

struct ReadingActivityScene: Scene {
    @ObservedObject var store: ReadingListStore

    var body: some Scene {
        Window("Activity", id: "activity") {
            ReadingActivity(store: store)
        }
        #if os(macOS)
        .defaultPosition(.topTrailing)
        .defaultSize(width: 400, height: 800)
        #endif
        #if os(macOS) || os(iOS)
        .keyboardShortcut("0", modifiers: [.option, .command])
        #endif
    }
}

关键点:

  • ReadingActivityScene 遵守 Scene,和 View 抽组件的思路相同。
  • .defaultPosition(.topTrailing) 在没有历史状态时指定窗口默认位置。
  • .defaultSize(width: 400, height: 800) 给窗口一个初始尺寸,窗口仍然可以调整大小。
  • .keyboardShortcut("0", modifiers: [.option, .command]) 把打开这个 scene 的命令绑定到 Option-Command-0。
  • #if os(macOS)#if os(macOS) || os(iOS) 保留了平台边界,避免在不支持的平台编译相关修饰符。

核心启发

  • 做一个阅读或学习 app 的详情窗口:列表保持在主窗口,条目详情用 WindowGroup("Details", for: Item.ID.self) 打开。开始时先让模型遵守 Identifiable,按钮里调用 openWindow(value: item.id)

  • 做一个全局状态面板:统计、活动记录、同步状态这类内容适合 Window("Activity", id: "activity")。把共享状态放在 App@StateObject,主窗口和辅助窗口都读取同一个 store。

  • 做一个菜单栏工具:计时器、剪贴板、构建状态都可以用 MenuBarExtra 常驻 macOS 菜单栏。内容简单时使用默认菜单样式;需要图表或复杂控件时加 .menuBarExtraStyle(.window)

  • 给窗口加桌面级入口:常用窗口可以加 .keyboardShortcut。如果某个窗口只应该从业务入口打开,用 .commandsRemoved() 去掉默认菜单命令,避免用户从 File 菜单打开一个没有上下文的空窗口。

  • 给文档 app 加快捷打开按钮:新建文件用 newDocument(DocumentType()),打开已有文件用 try await openDocument(at: url)。先确认 DocumentGroup 支持对应文档类型,再在按钮中接入环境 action。


关联 Session

评论

GitHub Issues · utterances