基于 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!)
}
}
}





