SwiftUI 入门 — 系统状态栏程序

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

 

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top