WWDC Quick Look 💓 By SwiftGGTeam
Work with windows in SwiftUI

Work with windows in SwiftUI

观看原视频

Highlight

这场 Session 聚焦 SwiftUI 中窗口的定义、打开、定位和尺寸控制,以一个名为 BOT-anist 的跨平台游戏 App 为演示对象。BOT-anist 在 visionOS 上有编辑器窗口和游戏 Volume 两个主要场景,Session 在此基础上新增了电影播放窗口和控制面板窗口,展示了完整的窗口管理能力。


核心内容

在 visionOS 和 macOS 上,如果你的 App 只用一个 WindowGroup 包打天下,那控制面板、详情页、媒体播放这类辅助界面只能塞进 TabView 或 NavigationStack。问题是:用户没办法自由拖动、缩放或并排放置这些界面。一个窗口里塞太多东西,空间利用效率很低。

SwiftUI 提供了多窗口能力来解决这个痛点。你可以为不同功能定义独立的 WindowGroup,用 openWindow 打开新窗口,用 pushWindow 替换当前窗口,用 defaultWindowPlacement 控制初始位置,用 defaultSizewindowResizability 管理尺寸。这些 API 让每个功能模块拥有自己的窗口,用户可以独立移动、缩放、关闭,体验远好于单窗口内叠层。

这场 Session 以 BOT-anist 为例,演示了完整的窗口管理流程。BOT-anist 原本只有编辑器窗口和游戏 Volume 两个场景,Session 依次新增了电影播放窗口和控制面板窗口,展示了从定义、打开、定位到尺寸约束的全过程。

详细内容

定义和打开窗口

BOT-anist 原始的 Scene 定义包含两个 WindowGroup:编辑器和游戏。游戏使用 .windowStyle(.volumetric) 在 visionOS 上呈现为 3D Volume(02:36)。

@main
struct BOTanistApp: App {
    var body: some Scene {
        WindowGroup(id: "editor") {
            EditorContentView()
        }

        WindowGroup(id: "game") {
            GameContentView()
        }
        .windowStyle(.volumetric)
    }
}
  • 每个 WindowGroupid 标识,后续通过 id 引用
  • .windowStyle(.volumetric) 让窗口在 visionOS 上以 3D Volume 形式呈现

新增电影窗口只需添加一个新的 WindowGroup(03:09):

WindowGroup(id: "movie") {
    MovieContentView()
}

在编辑器视图中通过环境变量获取 openWindow 动作来打开电影窗口(03:55):

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

    var body: some View {
        Button("Open Movie", systemImage: "tv") {
            openWindow(id: "movie")
        }
    }
}
  • @Environment(\.openWindow) 从环境获取打开窗口的动作
  • openWindow(id: "movie") 传入 WindowGroup 的 id 来指定打开哪个窗口

pushWindow:替换而非叠加

电影窗口和编辑器不需要同时可见,用 pushWindow 更合适(04:45):

struct EditorContentView: View {
    @Environment(\.pushWindow) private var pushWindow

    var body: some View {
        Button("Open Movie", systemImage: "tv") {
            pushWindow(id: "movie")
        }
    }
}
  • pushWindow 打开新窗口的同时隐藏来源窗口
  • 关闭新窗口后,来源窗口自动恢复显示,无需额外逻辑
  • 适合内容不需要与来源窗口同时查看的场景

窗口初始位置控制

新窗口默认出现在来源窗口前方(visionOS)或屏幕中央(macOS),可以用 defaultWindowPlacement 自定义(07:46):

WindowGroup(id: "controller") {
    ControllerContentView()
}
.defaultWindowPlacement { content, context in
    #if os(visionOS)
    return WindowPlacement(.utilityPanel)
    #elseif os(macOS)
    let displayBounds = context.defaultDisplay.visibleRect
    let size = content.sizeThatFits(.unspecified)
    let position = CGPoint(
        x: displayBounds.midX - (size.width / 2),
        y: displayBounds.maxY - size.height - 20
    )
    return WindowPlacement(position, size: size)
    #endif
}
  • WindowPlacement(.utilityPanel) 在 visionOS 上将窗口放在用户附近、触手可及的位置
  • macOS 上通过 context.defaultDisplay.visibleRect 获取安全区域,手动计算位置
  • content.sizeThatFits(.unspecified) 查询内容期望的尺寸
  • 闭合参数 content 是窗口内容的代理,context 包含平台相关的显示信息

窗口尺寸策略

defaultSize 设置窗口初始大小(10:12):

WindowGroup(id: "movie") {
    MovieContentView()
}
.defaultSize(width: 1166, height: 680)
  • 如果同时有 defaultWindowPlacement 返回的尺寸,或场景从恢复状态还原,defaultSize 会被忽略
  • pushed 窗口的默认尺寸等于来源窗口的尺寸

windowResizability(.contentSize) 限制窗口的缩放范围由内容的 min/max 决定(10:49):

WindowGroup(id: "movie") {
    MovieContentView()
        .frame(
            minWidth: 680, maxWidth: 2720,
            minHeight: 680, maxHeight: 1020
        )
}
.windowResizability(.contentSize)
  • .frame 的 minWidth/maxWidth/minHeight/maxHeight 定义内容的尺寸范围
  • .windowResizability(.contentSize) 让窗口尺寸跟随内容的 min/max 约束
  • 电影窗口最小可缩到 680x680 的正方形,最大不超过 2720x1020

控制面板窗口同样使用 .contentSize,但内容视图是固定尺寸(没有 min/max),所以窗口不可手动缩放,只随模式切换自动调整大小(11:37)。

平台特定增强

visionOS 上可以用 .persistentSystemOverlays(.hidden) 隐藏窗口栏和关闭按钮,让用户专注于内容(05:48):

WindowGroup(id: "movie") {
    ...
}
.persistentSystemOverlays(.hidden)

Freeform 风格的 toolbar ornament 和 ToolbarTitleMenu 也是 visionOS 上常用的窗口增强手段。

核心启发

  • 做什么:把辅助功能拆成独立窗口。控制面板、设置面板、详情预览、媒体播放器都是多窗口的候选。为什么值得做:在 visionOS 和 macOS 上,独立窗口意味着用户可以自由摆放和缩放,空间利用效率远高于单窗口内叠层。怎么开始:为辅助功能新建一个带 idWindowGroup,在主视图中用 openWindow(id:) 打开。
  • 做什么:根据内容是否需要同时可见,选择 pushWindowopenWindow。为什么值得做:pushWindow 自动管理来源窗口的隐藏和恢复,省去手动关闭/重开的逻辑,同时避免窗口堆叠造成视觉干扰。怎么开始:把 @Environment(\.openWindow) 换成 @Environment(\.pushWindow),调用方式相同。
  • 做什么:用 defaultWindowPlacement 为新窗口设定合理的初始位置。为什么值得做:默认位置可能遮挡已有内容或离用户太远,好的初始位置减少用户手动调整的负担。怎么开始:在 WindowGroup 上加 .defaultWindowPlacement 修饰符,visionOS 用 .utilityPanel,macOS 用 context.defaultDisplay.visibleRect 计算位置。
  • 做什么:用 windowResizability(.contentSize) + 内容的 min/max frame 控制窗口缩放范围。为什么值得做:防止窗口被缩到内容不可用,或放大到占用过多空间。怎么开始:在内容视图的 .frame 中设置 minWidth/maxWidth/minHeight/maxHeight,再在 WindowGroup 上加 .windowResizability(.contentSize)

关联 Session

评论

GitHub Issues · utterances