正如很多开发者一样,进入一个新平台开发连带学习,通常都会开发一个极简的应用,很多人都会开发Todo类应用,这类应用一般来说比较简单容易上手。我也打算开发一个,一来是练手,二来是写一个自己用的极简Todo类应用。 目前市面上的Todo类应用,一般来说,都比较复杂,太多我不需要的功能了,而且还有一些增值付费,对于我来说,根本没有那么多的复杂需求,只需要一个随手打开和关闭的记录而已,放在托盘上就非常的方便。 所以,需求如下:

  1. macOS托盘应用,随手打开和关闭;
  2. 带有分页标签,以便标记不同类型的todo;
  3. 最好有利用iCloud的数据云同步,方便未来与其他平台同步数据;

基于以上需求,做一个技术选型:

  1. 由于SwiftUI在macOS上表现实在是不敢恭维,所以选用传统的AppKit进行开发;
  2. 基于个人写Android应用的习惯,更倾向于使用代码或者XML的方式构建界面,所以在使用AppKit时,不使用Storyboard或者XIB的方式构建界面。

一、创建项目

打开Xcode,创建一个新的macOS项目,语言选择swift,Interface暂时选storyboard,先创建项目,在稍后删除相关的storyboard文件和配置。

二、项目配置

2.1 删除Main.storyboard

首先,删除自动创建的storyboard文件Main.storyboard,此时再构建项目会出现错误,先不用着急,接下来进行修改。

2.2 替换应用程序入口

其次,在target目录下,创建一个main.swift文件,然后把AppDelegate.swift中的@main注解删除掉,main.swift文件内容如下:

import Cocoa

class MyApplication: NSApplication {}

let app = MyApplication.shared
let delegate = AppDelegate()

app.delegate = delegate
app.run()

2.3 创建托盘应用的基本框架

在AppDelegate.swift文件中,添加相关代码以实现点击托盘图标,主窗口的显示/隐藏。 创建一个Tray类,用于管理托盘图标的创建和窗口的弹出。

class Tray {
    
    public static let POPOVER_SIZE = NSSize(width: 320, height: 400)
    
    private let iconName: String
    private var statusItem: NSStatusItem!
    private var popover: NSPopover!
    private var viewController: NSViewController
    
    init(iconName: String, viewController: NSViewController) {
        self.iconName = iconName
        self.viewController = viewController
    }
    
    func install() {
        statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
        
        if let trayBtn: NSStatusBarButton = statusItem.button {
            trayBtn.image = NSImage(systemSymbolName: iconName, accessibilityDescription: nil)
            
            trayBtn.target = self;
            trayBtn.action = #selector(togglePopover(_:))
        }
        
        viewController.loadViewIfNeeded()
        viewController.view.frame = NSRect(x: 0, y: 0, width: Tray.POPOVER_SIZE.width, height: Tray.POPOVER_SIZE.height)
        
        popover = NSPopover()
        popover.contentViewController = viewController
        
    }
    
    @objc private func togglePopover(_ sender: Any?) {
        if popover.isShown {
            closePopover(sender: sender)
        } else {
            showPopover(sender: sender)
        }
    }

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

    private func closePopover(sender: Any?) {
        popover.performClose(sender)
    }
    
}

此类中,接收两个构造参数,一个是图标名称,对应着资源名称或者是SF Symbols图标名称,另外一个参数就是ViewController的具体实现。 在AppDelegate中的applicationDidFinishLaunching方法中,执行Tray.install(),就添加了一个系统图标。

class AppDelegate: NSObject, NSApplicationDelegate {
    
    private let tray = Tray(iconName: "text.badge.checkmark", viewController: ViewController())
    
    func applicationDidFinishLaunching(_ aNotification: Notification) {
        tray.install()
    }
    
}

此处需要注意的是,必须要声明一个Tray的成员变量,如果不声明成员变量,则系统图标不会添加成功。

现在在ViewController中,显示一个Hello World。

class ViewController: NSViewController {

    override func viewDidLoad() {
        super.viewDidLoad()

        let text = NSTextView()
        text.translatesAutoresizingMaskIntoConstraints = false
        text.alignment = .center
        text.string = "Hello World"
        
        self.view.addSubview(text)
        
        NSLayoutConstraint.activate([
            text.leadingAnchor.constraint(equalTo: self.view.leadingAnchor),
            text.trailingAnchor.constraint(equalTo: self.view.trailingAnchor),
            text.topAnchor.constraint(equalTo: self.view.topAnchor),
            text.bottomAnchor.constraint(equalTo: self.view.bottomAnchor)
        ])
    }
}

注意此处的text.translatesAutoresizingMaskIntoConstraints = false,这里如果不设置的话,弹出窗口是不会填满整个父布局的。 translatesAutoresizingMaskIntoConstraints 是一个布尔属性,用于指示是否启用自动布局中的自动转换。

在 iOS 和 macOS 开发中,通常使用自动布局来管理界面的布局。自动布局系统使用约束(constraints)来描述视图之间的关系和布局规则。当你通过 Interface Builder 或代码创建视图时,默认情况下,视图的 translatesAutoresizingMaskIntoConstraints 属性是设置为 true 的。这意味着视图会根据其 frame 和 autoresizingMask 属性自动转换为相应的约束。

但是,在使用自动布局时,通常推荐将 translatesAutoresizingMaskIntoConstraints 设置为 false。这样做的原因是,如果你手动创建约束来布局视图,那么这些约束将会和自动转换的约束发生冲突,导致布局问题。通过将 translatesAutoresizingMaskIntoConstraints 设置为 false,你可以明确地告诉系统不要自动转换视图的 autoresizingMask 为约束,从而避免这种冲突。

最终的效果如下: tray_app_hello_world

另外,若要应用图标不显示在Docker栏上,需要在Info.plist上设置 Application is agent (UIEelement) 为YES。