超谈 SwiftUI:一个向下兼容的纯 Menu Bar app 方案
前言
此文为「超谈 SwiftUI」系列的文章,主要输出一些使用 SwiftUI 开发几个 apps 后的一些心得、经验和踩坑记录。希望能帮助到你。
此文介绍 MenuBarExtra,以及它目前往下兼容的问题和处理方案。
此文的方案在我的 Photon AI Translator app 上实践,个人感觉效果还不错,欢迎使用并提出反馈意见。
MenuBarExtra 向下兼容问题
MenuBarExtra 是一个 SwiftUI 上的 Scene,用于在 Menu Bar 上显示你的 app 图标,以及处理点击后的行为。用法很简单:
@main
struct UtilityApp: App {
var body: some Scene {
MenuBarExtra("Utility App", systemImage: "hammer") {
AppMenu()
}
}
}
默认情况下,它的样式是一个 Menu,意味着在点击后会显示第一个你定义的 Menu。
你可以使用 menuBarExtraStyle(_:) 来设置使用 Window,然后 Block 里传入你要显示的 SwiftUI View,在这个环节里,SwiftUI 和 AppKit 帮你做了以下事情:
- 帮你创建
NSStatusItem
以及处理NSStatusBarButton
的点击操作
- 在点击操作里,帮你创建好
NSWindow
以及一系列把 SwiftUI 桥接到 AppKit 的操作
- 计算
NSStatusItem
位置,然后以正确的 Frame 来显示NSWindow
以上行为都是经过手动实现一个 Menu Bar Only 的 app 后加上 Xcode 的分析推断出来的。可以看到 MenuBarExtra 这么一个简单的 SwiftUI Scene 背后实际上做了这么多事情——实际上,所有 SwiftUI View 和 ViewModifier 背后都一定做了很多 Bridge 的操作,这些都是我们看不见的,但这也是 SwiftUI 能在 Apple 生态下跨平台的重要原因。
MenuBarExtra
好用是好用,但是有一个根本的致命问题:仅支持 macOS 13.0 或以上系统。macOS 的升级率可能没有大家想的那么快,如果你的 app 仅支持 macOS 13.0,那么意味着会抛弃很大一部分用户。不过,既然你选择 SwiftUI 开发,那么可能本来也面临着这一问题,不是吗?
AppKit + SwiftUI 实现一个 Menu Bar Only 的 macOS app
基于以上背景,我不再使用 MenuBarExtra
来实现 Menu Bar Only 的效果。尽管在 UI 绘制方面我依然选择使用 SwiftUI,但是我选择自行管理窗口以及 app 生命周期。
首先,这意味着我不能使用 SwiftUI 的 App Protocol,因为你不能实现一个完全自由的 Scene:你在 App 里必须提供一个有效的 Scene,比如 WindowGroup、Window、Settings 和 MenuBarExtra。那么,应该怎么定义 app 的入口点呢?
定义 App 入口
你需要新建一个叫 main.swift
的文件,然后里面去初始化你的 NSApplication
:
import Foundation
import AppKit
let appDelegate = NSAppDelegate()
let app = NSApplication.shared
app.delegate = appDelegate
app.run()
这里的 NSAppDelegate
为我的实现了 NSApplicationDelegate
的类,把它设置给 NSApplication 的 delegate 属性,那么之后就能收到 applicationDidFinishLaunching
等的回调了。
在 applicationDidFinishLaunching
里,我转给我的 MenuBarExtraCompat 类来进行后续的 NSWindow 的管理等操作:
class NSAppDelegate: NSObject, NSApplicationDelegate {
private let compact = MenuBarExtraCompact.shared
func applicationDidFinishLaunching(_ notification: Notification) {
compact.setup()
}
func applicationDidResignActive(_ notification: Notification) {
NSApplication.shared.hide(self)
compact.cleanupOnHide()
}
}
需要注意的是,main.swift
这个名字相当于一个入口点,等同于 @main
。而 App 入口点只能有一个,如果你的 app 未来需要能使用 SwiftUI App Protocol
来支持在 iOS 上运行,那么需要做一下处理:
- 在 Target 的 Build Phases 里,把此 main.swift 设置为仅在 macOS 上编译
- 用上述的方式新建一个仅针对 iOS 编译的 Swift 文件,里面实现 SwiftUI 的 App Protocol,并使用
@main
来做标记
import SwiftUI
@main
struct ProjectAIApp: App {
var body: some Scene {
WindowGroup {
Text("Title")
}
}
}
管理 App StatusItem 和 Window
到目前为止,我们的 App 仅有一个 NSApplication
实例在运行(Dock 里有 App 的图标,但是没有任何窗口界面),接下来我们要实现这样的效果:
- 隐藏 Dock 上的图标,让 App 只在 Menu Bar 右侧上显示一个
NSStatusItem
。
- 点击 Menu Bar 上的图标,显示一个
NSWindow
,让其出现在NSStatusBarButton
的附近。
- 同时,该
NSWindow
需要隐藏 macOS 上窗口左上角的三大金刚按钮,需要支持键鼠操作,支持复制粘贴等快捷键操作
NSWindow
里面使用 SwiftUI 来绘制 UI
- 再次点击
NSStatusBarButton
或者激活其他 App 的时候需要隐藏NSWindow
首先定义一个 NSWindow
:
private lazy var mainWindow: NSWindow = {
NSWindow(contentRect: NSRect(x: 200, y: 200, width: 360, height: 600),
styleMask: NSWindow.StyleMask.defaultWindow,
backing: NSWindow.BackingStoreType.buffered,
defer: false)
}()
它的 contentRect 的 x 和 y 可以先设置为随意的数值,后面我们会动态计算它的位置。
styleMask 设置为我们定义的 defaultWindow,如下:
extension NSWindow.StyleMask {
static var defaultWindow: NSWindow.StyleMask {
var styleMask: NSWindow.StyleMask = .init()
styleMask.formUnion(.titled)
styleMask.formUnion(.fullSizeContentView)
return styleMask
}
}
这里两个 Flags 都是必须的:
- 只有设置了 .titled,SwiftUI 上的 TextField 等才能接收到键盘输入,但这样会显示标题栏以及左上角的三大金刚键,这个我们后面处理
- fullSizeContentView 可以让窗口内容延伸到 TitleBar
接着设置我们的 NSStatusItem
:
statusBar = NSStatusBar.system
statusBarItem = statusBar.statusItem(withLength: NSStatusItem.squareLength)
if let button = statusBarItem.button {
button.image = NSImage(named: "MenuBarIcon")!
button.target = self
button.action = #selector(openMainWindow)
}
Bundle.main.loadNibNamed("MainMenu", owner: self, topLevelObjects: nil)
NSApplication.shared.setActivationPolicy(.accessory)
在这里:
NSStatusBar.system
获取系统默认的NStatusBar
实例,调用其statusItem(withLength:)
方法获取一个新的 statusItem 实例,此实例需要我们自己持有着,否者会被回收。里面使用了NSStatusItem.squareLength
来实现一个系统默认的宽度的NSStatusItem
,这样的话我们的NSStatusItem
能跟系统的保持一致。
- 获取 statusBarItem 的 button,并设置它的 image 以及点击后 action selector。
- 让你的 TextField 能够支持键盘复制粘贴等操作,最快的实现方法是加载一个 Menu(没错,不这样做的话,你的 TextField 无法通过键盘的操作来对文本进行复制粘贴)。你可以通过 Xcode 新建文件里新建一个 MainMenu.xib,然后使用
Bundle.main.loadNibNamed
加载即可。当然你也可以使用代码来创建,这里就不展示了。
- 最后,设置
ActivationPolicy
为 .accessory 来声明此 app 为辅助类型的 app,这样 Dock 栏上就不会有图标了。
接着我们来实现点击 NSStatusBarButton
打开窗口的操作。
为了能让 NSWindow 出现在 NSStatusBarButton
的附近,我们需要获取 NSStatusBarButton
的 NSWindow 的位置。
💡Menu Bar 右侧的按钮图标,本质也是由 NSWindow 管理。
guard let eventFrame = statusBarItem.button?.window?.frame else {
return
}
然后去计算我们的 NSWindow 出现的位置:
let eventOrigin = eventFrame.origin
let eventSize = eventFrame.size
let screenFrame = NSScreen.main?.frame ?? .zero
// Calculate the position of the window to
// place it centered below of the status item
let windowFrame = window.frame
let windowSize = windowFrame.size
let windowTopLeftPosition: CGPoint
if eventOrigin.x > 0 {
windowTopLeftPosition = CGPoint(x: eventOrigin.x + eventSize.width / 2.0 - windowSize.width / 2.0,
y: eventOrigin.y - 10)
} else {
// If the window frame of status icon is outside from the screen(that's managed by Menu bar Manager
// Then we place the location of the window to the right.
windowTopLeftPosition = CGPoint(x: screenFrame.width - windowSize.width - 20,
y: eventOrigin.y - 10)
}
window.setFrameTopLeftPoint(windowTopLeftPosition)
需要注意的是:
- NSWindow 的 Frame 的坐标,是以屏幕左下角为原点的。整个屏幕本身处于这个坐标系的右上角象限区域。
- 如果用户使用了 Bartender 等管理 Menu Bar icon 的 app,那么拿到的
NSStatusBarButton
的NSWindow
的 Frame,可能会出现负数的情况(所以那些 app 实现,本质就是把该NSWindow
移动到一个用户不可见的位置)。这情况下的处理方式因人而异,我这里固定显示在屏幕右上角,并多加了一些 padding。
然后设置 NSWindow
内显示的内容,这里展示如何桥接 SwiftUI:
let mainView = MainViewWrapper()
let hostingViewController = NSHostingController(rootView: AnyView(mainView))
self.hostingViewController = hostingViewController
self.hostingViewController?.view.frame = window.contentView!.bounds
window.contentViewController = hostingViewController
注意需要设置好 NSHostingController
里 View 的 Frame,让其跟 NSWindow
的一样大。
然后设置 NSWindow
的 contentViewController
属性为上述的 NSHostingController
实例。
最后配置以下 NSWindow
的其他属性并显示出来:
window.configure()
window.makeKeyAndOrderFront(nil)
NSApplication.shared.activate(ignoringOtherApps: true)
其中 configure 为:
fileprivate extension NSWindow {
func configure() {
self.backgroundColor = .clear
self.titlebarAppearsTransparent = true
self.titleVisibility = .hidden
self.collectionBehavior = [.moveToActiveSpace, .transient, .ignoresCycle]
self.isReleasedWhenClosed = false
}
}
需要注意的是:
- 设置 title 相关的属性,让其完全不绘制 TitleBar
- 设置 collectionBehavior,此行为会影响此 NSWindow 在多显示器或者多
NSWorkspace
上的行为。其中moveToActiveSpace
可以让NSWindow
能正常出现在当前激活的 Workspace;transient
则能让 NSWindow 在三指上划的窗口管理界面隐藏;ignoresCycle
则会让其不受 Menu Bar 的 Windows 菜单管理。
isReleasedWhenClosed
设置为 false。因为我们需要自行在某些情况下关闭NSWindow
(调用其 close() 方法),不设置为 false 的话,在 close 调用后再一次给储存在 NSAppDelegate 的NSWindow
类型的变量赋值的话,会 Crash。
以下为完整代码:
import Foundation
import SwiftUI
import AppKit
extension NSWindow.StyleMask {
static var defaultWindow: NSWindow.StyleMask {
var styleMask: NSWindow.StyleMask = .init()
styleMask.formUnion(.titled)
styleMask.formUnion(.fullSizeContentView)
return styleMask
}
}
class MenuBarExtraCompact: NSObject {
static let shared = MenuBarExtraCompact()
private var statusBar: NSStatusBar!
private var statusBarItem: NSStatusItem!
private lazy var mainWindow: NSWindow = {
NSWindow(contentRect: NSRect(x: 200, y: 200, width: 360, height: 600),
styleMask: NSWindow.StyleMask.defaultWindow,
backing: NSWindow.BackingStoreType.buffered,
defer: false)
}()
private var hostingViewController: NSHostingController<AnyView>? = nil
private let aiService = AIService()
private let mainNavigator = AppNavigator()
func setup() {
statusBar = NSStatusBar.system
statusBarItem = statusBar.statusItem(withLength: NSStatusItem.squareLength)
if let button = statusBarItem.button {
button.image = NSImage(named: "MenuBarIcon")!
button.target = self
button.action = #selector(openMainWindow)
}
Bundle.main.loadNibNamed("MainMenu", owner: self, topLevelObjects: nil)
NSApplication.shared.setActivationPolicy(.accessory)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
self.openMainWindow(ignoreActivation: true)
}
}
@objc func openMainWindow(ignoreActivation: Bool, showSettings: Bool = false) {
let window = mainWindow
guard let eventFrame = statusBarItem.button?.window?.frame else {
return
}
if NSApplication.shared.isActive && !ignoreActivation {
NSApplication.shared.hide(self)
return
}
let eventOrigin = eventFrame.origin
let eventSize = eventFrame.size
let screenFrame = NSScreen.main?.frame ?? .zero
// Calculate the position of the window to
// place it centered below of the status item
let windowFrame = window.frame
let windowSize = windowFrame.size
let windowTopLeftPosition: CGPoint
if eventOrigin.x > 0 {
windowTopLeftPosition = CGPoint(x: eventOrigin.x + eventSize.width / 2.0 - windowSize.width / 2.0,
y: eventOrigin.y - 10)
} else {
// If the window frame of status icon is outside from the screen(that's managed by Menu bar Manager app like Bartender)
// Then we place the location of the window to the right.
windowTopLeftPosition = CGPoint(x: screenFrame.width - windowSize.width - 20,
y: eventOrigin.y - 10)
}
window.setFrameTopLeftPoint(windowTopLeftPosition)
if self.hostingViewController == nil {
let mainView = MainViewWrapper(aiService: aiService,
navigator: mainNavigator)
let hostingViewController = NSHostingController(rootView: AnyView(mainView))
self.hostingViewController = hostingViewController
self.hostingViewController?.view.frame = window.contentView!.bounds
window.contentViewController = hostingViewController
}
window.configure()
window.makeKeyAndOrderFront(nil)
NSApplication.shared.activate(ignoringOtherApps: true)
}
@MainActor
func cleanupOnHide() {
self.translator?.cancel()
NSApplication.shared.setActivationPolicy(.accessory)
}
}
fileprivate extension NSWindow {
func configure() {
self.backgroundColor = .clear
self.titlebarAppearsTransparent = true
self.titleVisibility = .hidden
self.collectionBehavior = [.moveToActiveSpace, .transient, .ignoresCycle]
self.isReleasedWhenClosed = false
}
}
后记
以上介绍了 SwiftUI 上 MenuBarExtra
的使用以及兼容性问题,从而引申出如何实现一个能向下兼容的 Menu Bar only 的 macOS app。
所以实际上,你并不需要 macOS 13.0 里 AppKit 的 API 也能实现类似的效果,在支持 Swift 的 Back Deployed 之前,我们只能接受「新 API 只能在当前最新的系统上使用」这样的情况。
一些 Menu Bar only 的 app 也会使用 NSPopover 的方式来实现,这样的好处是不用自己计算窗口的位置,省了点功夫,但 NSPopover 本身弹出的动画还有箭头不是很可控,可能也能做一些自定义,不过这就是另一个故事了。
希望此文能帮到你。
Subscribe to JuniperPhoton's Blog
Get the latest posts delivered right to your inbox