Highlight
这场 Session 聚焦 SwiftUI 中窗口的定义、打开、定位和尺寸控制,以一个名为 BOT-anist 的跨平台游戏 App 为演示对象。BOT-anist 在 visionOS 上有编辑器窗口和游戏 Volume 两个主要场景,Session 在此基础上新增了电影播放窗口和控制面板窗口,展示了完整的窗口管理能力。
核心内容
在 visionOS 和 macOS 上,如果你的 App 只用一个 WindowGroup 包打天下,那控制面板、详情页、媒体播放这类辅助界面只能塞进 TabView 或 NavigationStack。问题是:用户没办法自由拖动、缩放或并排放置这些界面。一个窗口里塞太多东西,空间利用效率很低。
SwiftUI 提供了多窗口能力来解决这个痛点。你可以为不同功能定义独立的 WindowGroup,用 openWindow 打开新窗口,用 pushWindow 替换当前窗口,用 defaultWindowPlacement 控制初始位置,用 defaultSize 和 windowResizability 管理尺寸。这些 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)
}
}
- 每个
WindowGroup用id标识,后续通过 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 上,独立窗口意味着用户可以自由摆放和缩放,空间利用效率远高于单窗口内叠层。怎么开始:为辅助功能新建一个带
id的WindowGroup,在主视图中用openWindow(id:)打开。 - 做什么:根据内容是否需要同时可见,选择
pushWindow或openWindow。为什么值得做: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
- Bring your iOS or iPadOS game to visionOS — 将 iOS/iPadOS 游戏迁移到 visionOS 的完整指南
- Create custom environments for your immersive apps in visionOS — 为沉浸式 App 创建自定义环境
- Create enhanced spatial computing experiences with ARKit — 用 ARKit 构建沉浸式空间计算体验
- Design interactive experiences for visionOS — 设计 visionOS 上的交互式叙事体验
评论
GitHub Issues · utterances