Work with windows in SwiftUI
SwiftUI & UI Frameworks 进阶 20m

在 SwiftUI 中操作窗口

Work with windows in SwiftUI

2024年6月10日

在 Apple 官方观看视频

一句话判断

如果你的 visionOS 或 macOS App 需要多窗口交互——尤其是控制面板、详情面板这类辅助窗口——这场 Session 介绍的 WindowPlacement、pushWindow 和 defaultWindowPlacement 就是你要找的东西。

这场 Session 讲了什么

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

内容围绕四个主题展开:窗口的定义和打开(WindowGroup + openWindow/pushWindow)、初始位置控制(defaultWindowPlacement)、尺寸策略(defaultSize、minimum/maximumContentSize、idealSize)和平台特定定制(visionOS 的 .persistentSystemOverlays、HoverEffectComponent 等)。

整场 Session 以 visionOS 为主要平台,但大部分 API 同时适用于 macOS。核心思路是多窗口设计应该以使用场景为导向——某些内容适合同时展示(编辑器+控制面板),某些内容适合替换展示(编辑器->电影播放)。

值得深挖的点

openWindow vs pushWindow 的设计决策

SwiftUI 提供了两个打开窗口的 Environment Action:openWindowpushWindowopenWindow 会在保留原始窗口的同时打开新窗口,适合需要同时展示两个界面的场景,比如游戏画面和操控面板。pushWindow 则会隐藏当前窗口并展示新窗口,关闭新窗口时原始窗口会自动恢复显示。

这个设计解决了一个很实际的 UX 问题。在 visionOS 中,窗口太多会让空间变得杂乱。如果某些内容不需要和当前窗口同时查看(比如编辑器里打开一个电影预览),用 pushWindow 可以避免用户手动关闭旧窗口。而且 pushWindow 的恢复逻辑是系统内置的,不需要开发者写任何额外的 show/hide 代码。

defaultWindowPlacement 的跨平台策略

defaultWindowPlacement 修饰符允许你根据平台动态决定窗口的初始位置。visionOS 上有 .utilityPanel 这种语义化的位置选项,表示”放在用户手边、触手可及的位置”。macOS 上则需要手动计算位置,通过 context 获取默认显示屏的 visibleRect,再结合内容期望尺寸算出坐标。

Session 中展示了一个跨平台的控制面板定位实现:visionOS 上用 .utilityPanel 自动放在身边,macOS 上计算为”屏幕底部居中”。这种”同一套声明式代码,不同平台走不同分支”的模式很值得在项目中推广。

代码片段

场景一:定义 WindowGroup 并使用 pushWindow 打开

// 在 App body 中定义窗口组
@main
struct BotanistApp: App {
    var body: some Scene {
        WindowGroup {
            RobotEditorView()
        }

        // 定义电影播放窗口,指定 ID 用于后续引用
        WindowGroup(id: "movie") {
            MoviePortalView()
        }

        WindowGroup(id: "game") {
            GameView()
        }
        .windowStyle(.volumetric)  // 在 visionOS 上使用 volumetric 样式
    }
}

// 在视图中使用 pushWindow 打开电影窗口
struct RobotEditorView: View {
    // 使用 pushWindow 替代 openWindow
    @Environment(\.pushWindow) private var pushWindow

    var body: some View {
        Button("观看机器人电影") {
            // pushWindow 会隐藏当前窗口,打开新窗口
            // 关闭新窗口时,当前窗口会自动恢复显示
            pushWindow(id: "movie")
        }
    }
}
// 坑点:pushWindow 在 iOS 上不适用,只对多窗口平台有效
// 在 iOS 上调用会被静默忽略,不会崩溃也不会打开新窗口

场景二:跨平台的 defaultWindowPlacement

// 为控制面板窗口设置初始位置
WindowGroup(id: "controller") {
    GameControllerView()
}
.defaultWindowPlacement { content, context in
    // visionOS:使用语义化位置,放在用户手边
    #if os(visionOS)
    return WindowPlacement(.utilityPanel)
    #else
    // macOS:手动计算屏幕底部居中位置
    let displayBounds = context.defaultDisplay.visibleRect
    let contentSize = content.sizeThatFits(.unspecified)
    let position = CGPoint(
        x: displayBounds.midX - contentSize.width / 2,
        y: displayBounds.minY + 40  // 距离底部留 40pt 间距
    )
    return WindowPlacement(position, size: contentSize)
    #endif
}
// 坑点:defaultWindowPlacement 只影响窗口首次打开的位置
// 用户手动移动窗口后,位置以用户设置为准

场景三:窗口尺寸控制

// 方式一:设置默认尺寸
WindowGroup(id: "controller") {
    GameControllerView()
}
.defaultSize(width: 320, height: 480)

// 方式二:设置尺寸范围约束
WindowGroup(id: "editor") {
    EditorView()
}
.windowResizable()  // 允许用户调整大小
// 也可以通过 SwiftUI 的 frame 修饰符控制内容尺寸
// .frame(minWidth: 300, idealWidth: 600, maxWidth: 800,
//        minHeight: 400, idealHeight: 700, maxHeight: 1000)

// 方式三:在 visionOS 上隐藏系统控件
WindowGroup(id: "movie") {
    MoviePortalView()
}
.persistentSystemOverlays(.hidden)  // 隐藏窗口栏和关闭按钮
// 坑点:隐藏系统控件后,用户只能通过系统手势关闭窗口
// 确保你的 App 有明确的退出路径,否则用户可能不知道如何关闭

最佳实践

  • 迁移建议:如果你的 App 目前只用了单个 WindowGroup,考虑将辅助功能拆分为独立窗口。控制面板、设置面板、详情预览等都是很好的多窗口候选场景。在 visionOS 上,独立窗口意味着用户可以自由摆放,体验远优于全屏叠加。
  • 窗口替换 vs 窗口叠加:根据内容相关性选择 pushWindowopenWindow。如果两个窗口不需要同时查看,用 pushWindow;如果需要并排使用,用 openWindow。这个决策直接影响 visionOS 上的空间管理体验。
  • 尺寸策略:优先用 defaultSize 设置合理的初始尺寸,再通过 min/max 约束防止极端比例。不要假设所有用户都用同一个屏幕尺寸。

还有什么值得关注

  • macOS 上的窗口定制可以参考 “Tailor macOS windows with SwiftUI” 这个相关 Session
  • visionOS 上 .volumetric 窗口样式支持嵌入 3D 内容,适合游戏和可视化场景
  • dismissWindow 环境动作可以编程式关闭指定窗口,适合自动化测试或引导流程
WWDC 2024