超谈 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,那么拿到的 NSStatusBarButtonNSWindow 的 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 的一样大。

然后设置 NSWindowcontentViewController 属性为上述的 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 本身弹出的动画还有箭头不是很可控,可能也能做一些自定义,不过这就是另一个故事了。

希望此文能帮到你。