Tailor macOS windows with SwiftUI
SwiftUI & UI Frameworks 进阶 20m

用 SwiftUI 定制 macOS 窗口

Tailor macOS windows with SwiftUI

2024年6月10日

在 Apple 官方观看视频

一句话判断

SwiftUI 在 macOS Sequoia 上终于补齐了窗口定制的三块拼图——toolbar 样式、窗口行为、窗口位置——让 SwiftUI macOS App 不再只能用千篇一律的系统默认窗口。

这场 Session 讲了什么

Session 通过一个叫 Destination Video 的示例 App,展示了三个方向的窗口定制 API。第一是 toolbar 定制:隐藏标题栏、移除 toolbar 背景、让内容延伸到窗口顶部边缘。第二是窗口行为控制:禁用最小化按钮、禁用状态恢复(窗口位置记忆)。第三是窗口位置控制:根据内容尺寸和屏幕尺寸动态计算窗口的理想位置和大小,包括 Zoom 行为的自定义。

这些 API 填补了 SwiftUI 在 macOS 上长期以来的空白。之前做 macOS App,只要窗口的默认行为不满足需求,你就得写 NSWindow 的 bridging 代码。现在这些常见需求都有了一行的 SwiftUI modifier。

值得深挖的点

defaultWindowPlacement 和 windowIdealPlacement:让窗口”知道”自己的内容

macOS App 有一个被忽视但很影响体验的问题:窗口初始大小和内容不匹配。一个 16:9 的视频播放器窗口被系统默认开成 4:3,用户看到两边的黑条。一个 About 窗口被开得太大,一小块内容浮在巨大的空白里。

.defaultWindowPlacement 让你根据内容的理想尺寸和屏幕的可用区域来计算窗口初始大小。它提供两个参数:一个 content proxy(可以调 .sizeThatFits() 获取内容的理想尺寸)和一个 context(包含屏幕信息,自动扣除菜单栏和 Dock 的空间)。

.windowIdealPlacement 控制”窗口被 Zoom 时应该多大”。在 macOS 上双击标题栏或从 Window 菜单选 Zoom,窗口会变大。对视频播放器来说,Zoom 后的窗口应该是”尽可能大但保持视频的宽高比”,而不是简单地铺满整个屏幕。这两个 placement modifier 组合使用,让窗口的”出生”和”成长”都有了合理的规则。

状态恢复的精确控制

macOS 的状态恢复机制会在 App 退出时记住所有窗口的位置和大小,下次启动时自动还原。这对主窗口很好,但对 About 窗口就不合适了——About 窗口应该每次都从菜单打开,不应该在启动时自动弹出。

.restorationBehavior(.disabled) 一行代码解决问题。这个 modifier 是 scene 级别的,可以精确控制每个窗口类型的状态恢复行为。配合 .windowMinimizeBehavior(.disabled) 禁用 About 窗口的最小化按钮,一个”辅助性质的固定尺寸窗口”的行为就完全正确了。

这种”每个窗口类型独立配置”的设计比 App 级别的全局开关好得多。你的主窗口照常恢复状态,About 窗口每次从菜单触发——各管各的,不冲突。

代码片段

隐藏 toolbar 背景让内容延伸到顶部

场景:视频播放器的主窗口,大图延伸到标题栏区域。

WindowGroup {
    ContentView()
        .toolbar(removing: .title)  // 隐藏标题
        .toolbarBackgroundVisibility(.hidden, for: .windowToolbar)  // 隐藏 toolbar 背景
}

坑:标题虽然从 UI 上隐藏了,但仍然关联到窗口——辅助功能和菜单栏仍然使用这个标题。

禁用 About 窗口的最小化和状态恢复

场景:一个固定尺寸的 About 窗口,不应该被最小化或自动恢复。

Window("关于 Destination Video", id: "about") {
    AboutView()
        .containerBackground(.thickMaterial, for: .window)
}
.windowMinimizeBehavior(.disabled)      // 禁用最小化
.restorationBehavior(.disabled)          // 禁用状态恢复

坑:.containerBackground 用 material 会产生半透明毛玻璃效果,颜色会和桌面背景混合——设计上考虑这是不是你想要的效果。

根据视频尺寸和屏幕大小计算窗口位置

场景:视频播放器窗口的初始大小和 Zoom 大小都跟随视频内容。

// 初始位置:视频原始尺寸,超出屏幕则缩小
.defaultWindowPlacement { content, context in
    var size = content.sizeThatFits(.unspecified)
    let displayBounds = context.defaultDisplay.visibleRect

    // 如果视频比屏幕大,缩放到适合
    if size.width > displayBounds.width || size.height > displayBounds.height {
        size = fitToDisplay(size, displayBounds)
    }

    return WindowPlacement(size: size)
}

// Zoom 行为:尽可能大但保持宽高比
.windowIdealPlacement { content, context in
    var size = content.sizeThatFits(.unspecified)
    let displayBounds = context.defaultDisplay.visibleRect
    return WindowPlacement(size: zoomToFit(size, displayBounds))
}

坑:不指定位置时窗口默认居中。如果你需要非居中的初始位置,在 WindowPlacement 里同时指定 position

最佳实践

新项目:为每种窗口类型独立配置行为。主窗口、About 窗口、设置窗口、播放器窗口——它们的 toolbar 需求、最小化行为、状态恢复策略都应该不同。不要全部用默认值,花 10 分钟给每种窗口加上合适的 modifier,用户体验会明显提升。

已有项目:如果你的 SwiftUI macOS App 已经上线,优先迁移两个东西:toolbar 背景隐藏(让内容布局更自由)和 defaultWindowPlacement(修正初始窗口大小)。这两个改动用户最容易感知到,而且迁移成本很低——加几个 modifier 就行。状态恢复和最小化行为的修改要更谨慎,因为它们改变了用户已有的操作习惯。

还有什么值得关注

  • .windowStyle(.plain) 可以创建无边框窗口,适合做欢迎屏幕或自定义启动画面。
  • 可以控制窗口在首次启动时是否自动显示,用来实现”条件性欢迎窗口”。
  • Session 提到的 Destination Video 示例项目可以作为窗口定制的完整参考代码。
WWDC 2024