问题背景
在开发 macOS 菜单栏应用时,我遇到了一个令人困惑的问题:点击菜单栏的设置按钮后,设置窗口并没有显示出来,而是被其他应用的窗口覆盖在后面,造成了「点击没有反应」的假象。
这个问题的根源在于,当应用的 Info.plist 中设置了 Application is agent (UIElement) 为 YES 时,应用不会在 Dock 栏显示图标。而 macOS 系统对于没有 Dock 图标的应用,默认不允许其窗口置顶显示——这是系统对菜单栏工具类应用的特殊处理逻辑。
问题分析
为什么会被隐藏?
macOS 将菜单栏应用视为后台工具(background utilities),而非前台应用(foreground applications)。这种设计影响了窗口的层级顺序和事件接收机制。当应用的激活策略(Activation Policy)设置为 .accessory 时,系统不会主动将其窗口置于最前方。
SwiftUI 的 SettingsLink 限制
从 macOS 14 Sonoma 开始,Apple 完全移除了使用 NSApp.sendAction() 方法通过 showSettingsWindow: 或 showPreferencesWindow: 选择器打开设置窗口的能力。官方推荐使用 SwiftUI 的 SettingsLink 组件。
然而,SettingsLink 在菜单栏应用中存在明显的局限性:
- 无法在打开设置窗口前后执行自定义代码
- 无法程序化地控制窗口的激活状态
- 对于没有 Dock 图标的应用,窗口容易被其他应用覆盖
解决方案
经过研究多个开源项目和社区讨论,我找到了一个简洁有效的解决方案,来自 Hacking with Swift 论坛。
核心思路
- 打开设置时:临时将应用的激活策略改为
.regular(显示 Dock 图标),并激活应用 - 关闭设置时:恢复激活策略为
.accessory(隐藏 Dock 图标),并取消激活
代码实现
1. 在菜单栏视图中打开设置
Button(action: {
// 使用 EnvironmentValues 直接调用 openSettings
let environment = EnvironmentValues()
environment.openSettings()
NSApp.setActivationPolicy(.regular)
NSApp.activate(ignoringOtherApps: true)
}) {
Image(systemName: "gearshape")
.font(.system(size: 14))
.frame(width: 24, height: 24)
}
.buttonStyle(.plain)
.foregroundColor(.secondary)
.help("Settings")
这里的关键是:
- 通过
EnvironmentValues().openSettings()直接调用系统的设置打开方法 - 立即将激活策略设置为
.regular,让应用临时显示 Dock 图标 - 调用
NSApp.activate(ignoringOtherApps: true)强制激活应用,确保窗口置顶
2. 在应用主结构中监听设置窗口关闭
@main
struct LinkSetApp: App {
var body: some Scene {
// ... 其他 Scene
Settings {
SettingsView()
.onDisappear {
NSApp.setActivationPolicy(.accessory)
NSApp.deactivate()
}
}
}
}
当设置窗口关闭时:
- 将激活策略恢复为
.accessory,隐藏 Dock 图标 - 调用
deactivate()取消应用的激活状态
注意事项
开发环境的特殊情况
在从 Xcode 直接运行应用时,如果 Xcode 处于活动状态,这个方案可能不会完全正常工作。这是因为系统可能将 Xcode 视为应用的一部分,导致窗口激活逻辑出现偏差。
解决办法:使用 Archive 构建正式版本进行测试,或者在运行时切换到其他应用再测试设置按钮。
用户体验考虑
这个方案会让 Dock 图标在打开设置时短暂出现,然后在关闭设置后消失。这实际上是一个合理的用户体验设计:
- Dock 图标的出现让用户知道「有一个窗口正在显示」
- 图标消失后,应用回归纯菜单栏工具的定位
- 符合 macOS 用户对菜单栏应用的使用习惯
其他方案对比
在解决这个问题的过程中,我也尝试了其他几种方案:
方案一:使用 simultaneousGesture
SettingsLink { ... }
.simultaneousGesture(TapGesture().onEnded {
NSApp.activate(ignoringOtherApps: true)
})
问题:手势执行时机不确定,窗口仍然可能被隐藏。
方案二:延迟激活 + 窗口遍历
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
NSApp.activate(ignoringOtherApps: true)
for window in NSApp.windows where window.title.contains("Settings") {
window.orderFrontRegardless()
}
}
问题:依赖延迟时间不可靠,且 SettingsLink 的点击事件被覆盖后无法正常打开窗口。
方案三:使用第三方库 SettingsAccess
SettingsAccess 是一个专门解决这个问题的开源库,提供了更完善的 API。
优点:功能完整,支持 pre/post action 缺点:需要引入额外依赖
对于简单的使用场景,本文介绍的原生方案已经足够。
总结
macOS 菜单栏应用的窗口管理一直是 SwiftUI 开发中的痛点。Apple 在推进 SwiftUI 现代化的同时,也移除了一些传统的 AppKit 解决方案,导致开发者需要寻找新的变通方法。
本文介绍的方案通过动态切换应用的激活策略,巧妙地解决了设置窗口被隐藏的问题。虽然需要一些额外的代码,但实现简洁,不依赖第三方库,是目前较为理想的解决方案。
希望这篇文章能帮助到正在开发 macOS 菜单栏应用的你。如果你有更好的解决方案,欢迎交流讨论。
参考资料: