基于 Xcode Version 11.5
1. 新建项目
输入项目名称,选择 SwiftUI,选择 Core Data。
因为用到了 Core Data,这里需要修改自动生成的 AppDelegate.swift 中的一行 BUG 语句(升级到 Xcode 11.6 了依然存在。。)
// let contentView = ContentView().environment(\.managedObjectContext, persistentContainer.viewContext) // 注释掉上面这句,修改为如下两句 👇 let context = persistentContainer.viewContext let contentView = ContentView().environment(\.managedObjectContext, context)
2. 在 ContentView 新增“退出”按钮
struct ContentView: View { var body: some View { VStack { Text("Hello, World!") .frame(maxWidth: .infinity, maxHeight: .infinity) Button("Quit"){ NSApplication.shared.terminate(self) } } } }
运行该程序会显示如下窗口,点击 Quit 按钮可以直接退出程序。
3. 将程序图标追加到系统状态栏
3.1 新增图标
点击项目面板的 Assets.xcassets 图标,然后在编辑面板的左边点击右键,选择 “New Image Set”。
在新建的 Image Set 的 Attributes inspector 面板中,修改图片集的名字与可选尺寸。然后将一个 png 图标拖动到 image 面板的虚线框。
3.2 添加到系统状态栏 NSStatusBar
在项目根目录下新建一个 StatusBarController.swift 文件:
import AppKit class StatusBarController { private var statusItem: NSStatusItem private var statusBarButton: NSStatusBarButton init() { statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) statusBarButton = statusItem.button! // 设置状态栏图标与标题 statusBarButton.image = NSImage(imageLiteralResourceName: "StatusBarIcon") statusBarButton.image!.size = NSSize(width: 18, height: 18) statusBarButton.imagePosition = .imageLeft statusBarButton.title = "倒计时" // 设置状态栏图标点击事件 // #selector(func) 语法糖生成一个 Selector 实例,它对应 Object-C 的 SEL 类型,实际上就是“函数指针” statusBarButton.action = #selector(statusBarButtonClicked(_:)) statusBarButton.target = self } // Swift 中的 @objc 特性表示表示这个声明可以被 Object-C 代码调用 @objc func statusBarButtonClicked(_ sender: Any?) { print("status bar button clicked") } }
然后在 AppDelegate 类中添加存储属性:let statusBar = StatusBarController()
。
编译运行程序,在系统状态栏就会多出一个程序图标,点击图标即会在 Xcode 的控制台打印 “status bar button clicked”。
4. 隐藏 Dock 图标与关闭程序主窗口
4.1 隐藏 Dock 图标
在 Xcode 中打开 Info.plist,右键点击“ Add Row”。新建一行配置:Application is agent (UIElement)
并设置值为 YES。然后重新编译运行,就会发现 Dock 栏不再出现该程序图标。
4.2 关闭主窗口
打开 AppDelegate.swift,将默认的生成窗口被设置主视图的那段代码删除即可:
// 删除下面这段代码 window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 480, height: 300), styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView], backing: .buffered, defer: false) window.center() window.setFrameAutosaveName("Main Window") window.contentView = NSHostingView(rootView: contentView) window.makeKeyAndOrderFront(nil)
这时候编译运行会有一个 Warning 提示 let contentView
常量定义了却没有被使用。
5. 显示弹出视图
此时我们运行程序,什么都没有只有一个状态栏图标。我们想要的是点击这个状态栏图标能弹出一个程序视图,为此,我们需要生成一个 NSPopover 实例,并将 ContentView 绑定到该 NSPopover 实例上。
5.1 弹出框 NSPopover
首先,新建一个 ContentViewController.swift 文件:
import AppKit class ContentViewController: NSViewController { // 内容为空即可 }
然后修改 StatusBarController 类,给他新增一个 popover 属性以及一些显示弹出窗口的方法:
import AppKit class StatusBarController { private var statusItem: NSStatusItem private var statusBarButton: NSStatusBarButton private var popover: NSPopover init(_ popover: NSPopover) { statusItem = NSStatusBar.system.statusItem(withLength: 28) statusBarButton = statusItem.button! self.popover = popover statusBarButton.image = NSImage(imageLiteralResourceName: "StatusBarIcon") statusBarButton.image!.size = NSSize(width: 18, height: 18) // #selector(func) 语法糖生成一个 Selector 实例,它对应 Object-C 的 SEL 类型,实际上就是“函数指针” statusBarButton.action = #selector(togglePopover(_:)) statusBarButton.target = self } // Swift 中的 @objc 特性表示表示这个声明可以被 Object-C 代码调用 @objc func togglePopover(_ sender: AnyObject) { print("status bar button clicked") if popover.isShown { hidePopover(sender) } else { showPopover(sender) } } func showPopover(_ sender: AnyObject) { // relativeTo 参数表示 popover 关联视图的边界 // of 参数表示 popover 要关联的视图 // preferredEdge 参数表示 popover 的箭头要在关联视图的哪一边出现 popover.show(relativeTo: statusBarButton.bounds, of: statusBarButton, preferredEdge: NSRectEdge.maxY) } func hidePopover(_ sender: AnyObject) { popover.performClose(sender) } }
最后,需要修改 AppDelegate.swift,在创建 StatusBarController 与 NSPopover 实例。
@NSApplicationMain class AppDelegate: NSObject, NSApplicationDelegate { // 类型后面用 ! 号表示这是一个隐式解析的可选类型 var statusBar: StatusBarController! var popover: NSPopover! func applicationDidFinishLaunching(_ aNotification: Notification) { // 创建 ContentView 主视图实例,并附加持久化存储上下文环境 let context = persistentContainer.viewContext let contentView = ContentView().environment(\.managedObjectContext, context) // 创建 NSPopover 类型实例 popover = NSPopover() // 必须先为 NSPopover 设置视图控制器后才能添加视图 popover.contentViewController = ContentViewController() popover.contentSize = NSSize(width: 360, height: 360) // 这里用 ? 问号表示是一个可选链式调用。如果改用 ! 的话则表示强制解包,强制解包的链式调用遇到 nil 时会报错 popover.contentViewController?.view = NSHostingView(rootView: contentView) // 创建状态栏图标控制器 statusBar = StatusBarController(popover) } // ...
编译运行程序,这时候点击状态栏图标就会弹出 ContentView 视图,再次点击则会关闭弹出视图。
5.2 监听外部事件 NSEvent
对于状态栏图标程序,我们还需要一个基本功能就是,当 popover 视图打开的时候,用户点击桌面的其他地方也能关闭 popover 视图。要实现这一功能,我们需要添加一个系统全局事件监听器,当监听到用户在程序外部点击鼠标时触发事件监听器的处理方法。
新建一个 EventMonitor 类:
import Cocoa class EventMonitor { private var monitor: Any? private let mask: NSEvent.EventTypeMask private let handler: (NSEvent?) -> Void // mask 是要监听的事件类型 // handler 是事件处理函数,它是一个逃逸闭包,接受一个 NSEvent? 类型作为参数,返回 Void(无返回值) public init(mask: NSEvent.EventTypeMask, handler: @escaping (NSEvent?) -> Void) { self.mask = mask self.handler = handler } deinit { stop() } public func start() { // 添加一个系统全局事件监听器,并返回给 monitor 存储属性 // as! 表示将前面的可选类型当作 NSObject 进行强制解包 monitor = NSEvent.addGlobalMonitorForEvents(matching: mask, handler: handler) as! NSObject } public func stop() { if monitor != nil { // 从系统全局事件监听器队列中删除自己的监听器 NSEvent.removeMonitor(monitor!) // 解除引用,使得该事件监听器实例被销毁 monitor = nil } } }
然后需要修改 StatusBarController.swift,在 StatusBarController 中添加新的属性声明:
private var eventMonitor: EventMonitor?
在 StatusBarController 构造函数的末尾添加对 eventMonitor 属性的赋值语句:
eventMonitor = EventMonitor(mask: [.leftMouseDown, .rightMouseDown], handler: outerClickedHandler)
在 StatusBarController 类中新增事件处理方法 outerClickedHandler 并修改原来的显示隐藏弹出框的方法:
func showPopover(_ sender: AnyObject) { // relativeTo 参数表示 popover 关联视图的边界 // of 参数表示 popover 要关联的视图 // preferredEdge 参数表示 popover 的箭头要在关联视图的哪一边出现 popover.show(relativeTo: statusBarButton.bounds, of: statusBarButton, preferredEdge: NSRectEdge.maxY) eventMonitor?.start() } func hidePopover(_ sender: AnyObject) { popover.performClose(sender) eventMonitor?.stop() } func outerClickedHandler(_ event: NSEvent?) { if(popover.isShown) { hidePopover(event!) } }
编译运行现在的项目,OK,这就是一个状态栏程序的基础模版了。
5.3 监听外部事件 NSEvent (更新)
在 5.2 中,我们监听到程序外部的鼠标点击事件后,通过 NSPopover.performClose() 来关闭弹出窗口,随之就停止了事件监听器。但这里其实存在一个 Bug,因为 NSPopover.performClose() 并不保证关闭,官方文档中有说明:The operation will fail if the popover is displaying a nested popover or if it has a child window.
而我也确实遇到了这个情况:在弹出窗口中添加了一个按钮,并且给该按钮设置了 .popover() 修饰器,使得点击该按钮会弹出一个子弹出框/这时候如果再点击程序外部,也会触发 5.2 中定义的 outerClickedHandler() 函数,并执行 NSPopover.performClose() 与 eventMonitor?.stop()。但此时 performClose() 只是关闭了子弹出窗,主弹出窗本身并不关闭。而我们又执行了eventMonitor?.stop(),那么后面再怎么点击程序外部,都不会关闭主弹出窗口了(点击状态栏按钮还行,不影响)。
正确的做法,应该是把停止事件监听器的动作,放在监控到 NSPopover 真正关闭之后才执行。如果监控 NSPopover 是否关闭呢?其实有一个很好的做法:我在查看了 NSPopover 的文档之后,发现它会在显示、关闭的时候向 NotificationCenter 发出 NSPopover.didShowNotification、NSPopover.didCloseNotification 这些通知(NSWindow 也有这样的行为)。那么就能通过订阅这些通知来执行停止外部点击监听器的操作。
修改后的 StatusBarController.swift 代码如下:
import AppKit import Combine class StatusBarController { private var statusItem: NSStatusItem private var statusBarButton: NSStatusBarButton private var popover: NSPopover private var eventMonitor: EventMonitor? private var subscribePopoverDidClose: AnyCancellable? private var subscribePopoverDidShow: AnyCancellable? init(_ popover: NSPopover) { self.statusItem = NSStatusBar.system.statusItem(withLength: 30) self.statusBarButton = statusItem.button! self.popover = popover // 设置状态栏图标 statusBarButton.image = NSImage(imageLiteralResourceName: "StatusBarIcon") statusBarButton.image!.size = NSSize(width: 18, height: 18) // 设置状态栏图标点击事件 // #selector(func) 语法糖生成一个 Selector 实例,它对应 Object-C 的 SEL 类型,实际上就是“函数指针” statusBarButton.action = #selector(togglePopover(_:)) statusBarButton.target = self // 设置一个事件监听器,监听鼠标在程序外部的点击事件 self.eventMonitor = EventMonitor(mask: [.leftMouseDown, .rightMouseDown], handler: outerClickedHandler) self.subscribePopoverDidShow = NotificationCenter.default.publisher(for: NSPopover.didShowNotification, object: popover).sink(receiveValue: { _ in log.debug("收到弹出窗口已打开的消息") self.eventMonitor?.start() }) self.subscribePopoverDidClose = NotificationCenter.default.publisher(for: NSPopover.didCloseNotification, object: popover).sink(receiveValue: { _ in log.debug("收到弹出窗口已关闭的消息") self.eventMonitor?.stop() }) } // Swift 中的 @objc 特性表示表示这个声明可以被 Object-C 代码调用 @objc func togglePopover(_ sender: AnyObject) { if popover.isShown { hidePopover(sender) } else { showPopover(sender) } } func showPopover(_ sender: AnyObject) { log.debug("显示弹出窗口") // relativeTo 参数表示 popover 关联视图的边界 // of 参数表示 popover 要关联的视图 // preferredEdge 参数表示 popover 的箭头要在关联视图的哪一边出现 popover.show(relativeTo: statusBarButton.bounds, of: statusBarButton, preferredEdge: NSRectEdge.maxY) } func hidePopover(_ sender: AnyObject) { log.debug("关闭弹出窗口") popover.performClose(sender) } func outerClickedHandler(_ event: NSEvent?) { log.debug("监听到程序外部的点击事件") if(popover.isShown) { hidePopover(event!) } } }