SwiftUI 入门 — 系统状态栏程序

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: 28)
        statusBarButton = statusItem.button!
        statusBarButton.image = NSImage(imageLiteralResourceName: "StatusBarIcon")
        statusBarButton.image!.size = NSSize(width: 18, height: 18)
        
        // #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,这就是一个状态栏程序的基础模版了。

发表评论

电子邮件地址不会被公开。 必填项已用*标注