最近想利用闲暇时间,做一些托盘工具类app的开发,主要是方便自己在Mac上的部分操作。在遇到各种问题后,总结了一下实现托盘SwiftUI应用的两种方式。

小声吐槽:swiftUI在macOS上的适配真的是很烂,相当多的组件可以说是不完善甚至是不可用的状态,似乎苹果也没有完善的意思,这已经是swift迭代到5.10了啊,竟然还有这么多问题存在。

方案一:NSApplicationDelegateAdaptor

这种方式,应该是从Objective-C时代承袭过来的,我没有做过Objective-C的应用,这只是我的猜测。
新建一个项目,通常会生成一个XXXApp.swift的文件,文件的内容如下:

1
2
3
4
5
6
7
8
@main
struct XXXApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}

我们要添加一行代码,变成如下这样:

1
2
3
4
5
6
7
8
9
@main
struct ByteCatApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
var body: some Scene {
WindowGroup {
ContentView()
}
}
}

加入了@NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate这一行,现在开始写AppDelegate的代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
class AppDelegate: NSObject, NSApplicationDelegate {
var statusItem: NSStatusItem!
var popover: NSPopover!

override init() {}

func applicationDidFinishLaunching(_ notification: Notification) {
setupStatusBarItem()
setupPopover()
}

func setupStatusBarItem() {
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength)

if let button = statusItem.button {
let imageSize = NSSize(width: 22, height: 22)
button.image = NSImage(named: "TrayIcon")
button.image?.size = imageSize
button.action = #selector(togglePopover(_:))
}
}

func setupPopover() {
popover = NSPopover()
popover.behavior = .transient
popover.contentViewController = PopoverViewController().makeNSViewController()
}

@objc func togglePopover(_ sender: AnyObject?) {
if popover.isShown {
closePopover(sender)
} else {
showPopover(sender)
}
}

func showPopover(_ sender: AnyObject?) {
if let button = statusItem.button {
popover.show(relativeTo: button.bounds, of: button, preferredEdge: NSRectEdge.minY)
}
}

func closePopover(_ sender: AnyObject?) {
popover.performClose(sender)
}

}

struct PopoverViewController: NSViewControllerRepresentable {
func makeNSViewController(context: Context) -> NSViewController {
return makeNSViewController()
}

func makeNSViewController() -> NSViewController {
let viewController = NSViewController()
viewController.view = NSHostingView(rootView: ContentView())
return viewController
}

func updateNSViewController(_ nsViewController: NSViewController, context: Context) {
// Update code if needed
}
}

方案二:MenuBarExtra

1
2
3
4
5
6
7
8
9
10
@main
struct XXXApp: App {
var body: some Scene {
return MenuBarExtra {
ContentView()
} label: {
Image(systemName: "tray.2.fill")
}.menuBarExtraStyle(.window)
}
}

这个方案就是这么简单,几行代码就搞定,缺点是,弹出的窗口位置不可调整,也不会有一个三角形指向托盘图标的位置,而且窗口位置一直与托盘图标左对齐。

问题

List导致UI异常

方案一,当弹出窗口UI中包含了List组件,会使得弹出的窗口显示异常。
正常情况是这样的:
images/normal_popover.png
异常的情况如下:
images/unnormal_popover.png
代码的差别,只是把其中的红色布局换成了List组件而已,顶端的三角指向消失了,而且窗口整体上移了。

使用方案二,窗口弹出是这样的:
images/menu_bar_extra.png
同样没有顶部三角指向,固定与托盘图标左对齐。

横向滑动列表不支持鼠标滚轮

这并不是某个方案中的问题,而是二者都有的问题,这个应该是SwiftUI的相关组件没有适配macOS所致,这个并没有找到解决办法,似乎苹果也不想解决,毕竟大多数做桌面端app的,都是使用Electron方案,使用原生开发,而且只开发托盘应用的实在是少数。

一点小改进

如果你想让托盘应用启动后,只有在托盘中有图标,而在dock栏中没有图标,则需要在Info.plist中,设置一个属性**Application is agent(UIElement)**为YES。

总结

swiftUI真的不适合在macOS端开发稍微复杂一点的应用,之前开发过一个翻译的应用,只能说勉强够用而已,前提是足够简单。
对于托盘应用这个极小的领域来说,尤其不能使用,electron不支持自定义UI的弹出窗口,只支持菜单,而swiftUI支持的情况更糟糕,可能只有原始的Objective-C方案可行了,原始人方案。

本文采用CC-BY-SA-3.0协议,转载请注明出处
Author: boybeak