肇鑫的技术博客

肇鑫 / Owen Zhao

独立开发者,主要开发 iOS、watchOS、macOS 应用。

目前在维护 SleepTapRooster Time,以及 Markdown Writer 相关工具。

最新文章

A Well-formed macOS Menu Bar Application in Sandbox

macOS

Many apps start with system menu bar items. Some of them are not shown in Dock. Some of them are shown in Dock but are not in sandbox. In this article I will design a well-formed macOS menu bar application in sandbox.

Goals

An application which has below features.

  1. Launch itself when a user login.
  2. When auto launched, only the menu bar item shows.
  3. When a user launch the app, both menu bar item and app UI are shown.
  4. When left clicking on the menu bar item, the app shows/hides itself as well as shows and hides itself in Dock.
  5. When a user quits the app, the app disappears from Dock but the menu bar item stays.
  6. When a user right clicking/two finger touching on a trackpad, a menu is shown and the user could quit the app entirely.

Difficulties

Unless iOS, fewer developers are putting their attentions on macOS. And with the so long history, there are two many wrappers on how to manipulating a macOS application. What you need is patience on how to debugging the experience that you want.

Here is the basic states need to compare.

basic related states

You should aware that isClosed is not native and we can simplify it with NSWindowDelegate's method windowShouldClose(_:), so the NSWindow will never close.

extension AppDelegate:NSWindowDelegate {
    func windowShouldClose(_ sender: NSWindow) -> Bool {
        hide()
        return false
    }
}

basic related states simplified

@objc private func mouseLeftButtonClicked() {
    guard let window = self.window else {
        showWindow()
        return
    }
    
    var operated = false
    
    if NSApp.isHidden {
        unhide()
        if !operated { operated = true }
    }
    
    if window.isMiniaturized {
        window.deminiaturize(nil)
        if !operated { operated = true }
    }
    
    if !NSApp.isActive {
        NSApp.activate(ignoringOtherApps: true)
        if !operated { operated = true }
    }
    
    guard window.isKeyWindow else { return }
    
    if !operated {
        hide()
    }
}

LSUIElement

For start an application with only menu bar item, we should set LSUIElement to true in Info.plist. When set to true, this key has two effects:

  1. Main UI is not initialized automatically. You should create them yourself when needed.
  2. Main Window will release itself when it closes.

Dock

Use NSApp.setActivationPolicy(.regular) and NSApp.setActivationPolicy(.accessory) to show/hide your app in Dock.

Background Start, Foreground Start

Use UserDefaults to transfer the state that whether the app is started by a user or the launcher app.

You will need to create a group to share the defaults.

Quit to Menu Bar Item

In order to quit to menu bar item, you should implemented applicationShouldTerminate(_:) method of NSApplicationDelegate. Besides, since macOS 10.6, Apple introduced Sudden Termination. It is a counter instead of a switcher. So you must use it balancingly.

When Sudden Termination is enable in Info.plist, which it is by default when you create a new macOS application, and a user quits your app, applicationShouldTerminate(_:) method of NSApplicationDelegate will be skipped and the app quits itself immediately.

So you should call ProcessInfo.processInfo.disableSuddenTermination() when you want applicationShouldTerminate(_:) method to be called.

Also, when applicationShouldTerminate(_:) returns .terminateCancel, this app will stop the system from logout, shutdown and reboot. So you must implemented those notifications.

extension AppDelegate {
    func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
        // must delay this operation or the main menu will leave a selected state when the app shows next time.
        DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) {
            self.hide()
        }
        return .terminateCancel
    }
    
    private func setupWorkspaceNotifications() {
        let center = NSWorkspace.shared.notificationCenter
        center.addObserver(self, selector: #selector(willSleep(_:)), name: NSWorkspace.willSleepNotification, object: nil)
        center.addObserver(self, selector: #selector(willPowerOff(_:)), name: NSWorkspace.willPowerOffNotification, object: nil)
    }
    
    @objc private func willSleep(_ noti:Notification) {
        quit()
    }
    
    @objc private func willPowerOff(_ noti:Notification) {
        quit()
    }
    
    @objc private func quit() {
        ProcessInfo.processInfo.enableSuddenTermination()
        NSApp.terminate(nil)
    }
}

Other Considerations

If you want to save some memory, you could nil the window property every time you hide you app.

Know Issue

When you debugging this app in Xcode, the menu will be not responsible at first. This is the issue of Xcode and the release app won't have this issue. You can switch to other apps and switch back to overcome this issue.

Sample Project

LoginItem-Sample

Others Related

macOS应用登录时启动的实现方式

macOS应用登录时启动的实现方式

macOS

Mac版咕唧2移除了今日扩展,改为了在菜单栏常驻图标的方式。这是因为,今日扩展的方式,不方便使用表情键盘,一旦弹出点击表情键盘,今日栏就会自动关闭。

常驻图标,拥有一个开机启动才是最好的。实现登录时启动,有多种方式,不过随着macOS的发展,一些方式因为沙盒的缘故已经不能使用了。本文介绍的是目前最新的通用方式,适合macOS 10.6及以上,iOS 12.1及以上,wathcOS 5.1及以上的系统。

原理

原理是这样的,对于较新的苹果系统,应用可以通过ServiceManagementSMLoginItemSetEnabled(_:_:)函数注册和取消开机自启。

这个自启是针对当前账户级别的。即每个用户,都需要在开启应用时单独同意,才会在自己进入系统后,自动启动对应的应用。

下面我们来具体看一看这个函数,func SMLoginItemSetEnabled(_ identifier: CFString, _ enabled: Bool) -> Bool

函数的第一个参数是id,这个id就是要执行的应用的包的ID。并且这个应用,必须位于主应用相对路径为Contents/Library/LoginItems的位置。

函数的第二个参数是注册还是取消开机自启。是为开启,否为取消。

函数的返回值则是这个操作是否成功。操作成功返回是,操作失败返回否。

小结

苹果在系统中预定了一项服务叫ServiceManagement,它允许用户在编写主程序时,额外添加一个程序,用于登录时自启。这个程序在主程序中的位置是固定的,必须位于Contents/Library/LoginItems,然后主程序通过SMLoginItemSetEnabled(_:_:)来实现对于开机自启的注册和取消。

实现

知道了原理。实现就简单了。需要第二个应用,所以我们就需要创建它。因为它是服务类型的,不需要界面,所以要将其设定为后台应用。因为它是伴随着主应用安装的,所以它不需要单独安装等等。

这个步骤我就不详细说明了。需要的可以看看这篇文章:Modern Login Items

你创建的辅助应用,Xcode默认会使用最新的系统,而不是你在项目中限定的系统。比如你的项目支持macOS 10.14及以上,但是Xcode创建的辅助应用却是macOS 10.15的。你必须在目标的系统信息里删掉这个10.15,才会应用你的默认限制。

如果你不删除,就会发现你的应用在10.14的系统里无法伴随用户登录自动启动。并且找不到任何提示。你只有在Finder中主动解包,才会看到应用上面的不能执行的标记。

这个是Xcode的锅。

例子

如果你需要的是Objective C的实现,那么看上面的那个说明。
我自己参考Objective C版的,写了一个Swift版的。你可以在这里下载

其它

Register as Login Item with Cocoa?

A Well-formed macOS Menu Bar Application in Sandbox

NSPressGestureRecognizer在模态时失效问题的解决

macOS

最近在使用NSPressGestureRecognizer处理长按的时候发现了问题。如果弹出的视图控制器,是采用的show方式,则一切正常。但如果是使用modal的方式,则无法识别长按。

代码如下:

import Cocoa

class VC: NSViewController {

    override func viewDidLoad() {
        super.viewDidLoad()

        let view = NSView(frame: NSRect(x: 100, y: 100, width: 100, height: 100))
        view.wantsLayer = true
        view.layer?.backgroundColor = NSColor.systemBrown.cgColor
        let longPress = NSPressGestureRecognizer(target: self, action: #selector(longPress(_:)))
        view.addGestureRecognizer(longPress)
        self.view.addSubview(view)
    }

    @objc func longPress(_ sender:Any) {
        print("long")
    }
}

分析

我自己弄了半天,没能找到解决方案,于是跑到SO上问。NSPressGestureRecognizer doesn't work in modal ViewController。一觉睡醒,发现已经有人回答了。

Interesting. What seems to be happening is that the recognizer state never changes from possible to "began" in the modal example.

class Recognizer: NSPressGestureRecognizer {

    override func mouseDown(with event: NSEvent) {
        super.mouseDown(with: event)
        self.state = .began
    }

}

解答的代码虽然不完整,但是至少提供了一个方向。按照解答的思路,我重新实现了NSPressGestureRecognizer

模拟的目标是实现在show方式下同样的状态改变。即在正常长按的情况下,依次实现possible、began、end。使用一个Timer,在满足长按时间的情况下,发送began,并且在用户抬起鼠标且有began的情况下,发送end。

import Cocoa

class VC: NSViewController {

    override func viewDidLoad() {
        super.viewDidLoad()

        let view = NSView(frame: NSRect(x: 100, y: 100, width: 100, height: 100))
        view.wantsLayer = true
        view.layer?.backgroundColor = NSColor.systemBrown.cgColor
        let longPress = MyPressGestureRecognizer(target: self, action: #selector(longPress(_:)))
        view.addGestureRecognizer(longPress)

        let click = NSClickGestureRecognizer(target: self, action: #selector(click(_:)))
        view.addGestureRecognizer(click)

        self.view.addSubview(view)
    }

    @objc func click(_ sender:Any) {
        print("click")
    }

    @objc func longPress(_ sender:Any) {
        guard let gesture = sender as? NSGestureRecognizer else { return }

        switch gesture.state {
        case .ended:
            print("long")
        default:
            print(gesture.state)
        }
    }
}

class MyPressGestureRecognizer: NSPressGestureRecognizer {
    private weak var timer:Timer? = nil
    private var hasBegan = false
    private var hasCancelled = false

    override func mouseDown(with event: NSEvent) {
        super.mouseDown(with: event)

        timer = Timer.scheduledTimer(withTimeInterval: minimumPressDuration, repeats: false) { (timer) in
            defer {
                timer.invalidate()
            }

            DispatchQueue.main.async {
                self.state = .began
                self.hasBegan = true
            }
        }
    }

    override func mouseUp(with event: NSEvent) {
        if hasBegan {
            self.state = .ended
            self.hasBegan = false
        }

        super.mouseUp(with: event)
    }

    override func reset() {
        timer?.invalidate()
        super.reset()
    }
}

extension NSGestureRecognizer.State:CustomStringConvertible {
    public var description:String {
        switch self {
        case .possible:
            return "possible"
        case .began:
            return "began"
        case .changed:
            return "changed"
        case .ended:
            return "ended"
        case .cancelled:
            return "cancelled"
        case .failed:
            return "failed"
        @unknown default:
            return "default"
        }
    }
}

运行之后,发现我实现的代码,和苹果原本的NSPressGestureRecognizer,效果完全一样。也就是说,我的代码同样有模态方式下,无法识别长按的问题。

再次思考,我发现问题出现Timer上,在模态运行的视图控制器,Timer不会执行。我猜,苹果大概也是代码中使用了Timer,才会有同样的问题。

解决

最终的方案是不使用Timer,增加一个是否取消的参数进行判断。代码如下:

import Cocoa

class VC: NSViewController {

    override func viewDidLoad() {
        super.viewDidLoad()

        let view = NSView(frame: NSRect(x: 100, y: 100, width: 100, height: 100))
        view.wantsLayer = true
        view.layer?.backgroundColor = NSColor.systemBrown.cgColor
        let longPress = MyPressGestureRecognizer(target: self, action: #selector(longPress(_:)))
        view.addGestureRecognizer(longPress)

        let click = NSClickGestureRecognizer(target: self, action: #selector(click(_:)))
        view.addGestureRecognizer(click)

        self.view.addSubview(view)
    }

    @objc func click(_ sender:Any) {
        print("click")
    }

    @objc func longPress(_ sender:Any) {
        guard let gesture = sender as? NSGestureRecognizer else { return }

        switch gesture.state {
        case .ended:
            print("long")
        default:
            print(gesture.state)
        }
    }
}

class MyPressGestureRecognizer: NSPressGestureRecognizer {
    private var hasBegan = false
    private var hasCancelled = false

    override func mouseDown(with event: NSEvent) {
        super.mouseDown(with: event)

        hasCancelled = false

        DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(Int(minimumPressDuration * 1000))) {
            if !self.hasCancelled {
                self.state = .began
                self.hasBegan = true
            }
        }
    }

    override func mouseUp(with event: NSEvent) {
        if hasBegan {
            self.state = .ended
            self.hasBegan = false
        } else {
            self.hasCancelled = true
        }

        super.mouseUp(with: event)
    }
}

extension NSGestureRecognizer.State:CustomStringConvertible {
    public var description:String {
        switch self {
        case .possible:
            return "possible"
        case .began:
            return "began"
        case .changed:
            return "changed"
        case .ended:
            return "ended"
        case .cancelled:
            return "cancelled"
        case .failed:
            return "failed"
        @unknown default:
            return "default"
        }
    }
}

总结

  1. Timer在模态时会失效。我们在使用时需要小心。
  2. 上面的代码只处理了鼠标左键,如果想处理其它按键的长按,也可以用同样的方式进行处理。

参考

NSPressGestureRecognizer doesn't work in modal ViewController

NSCollectionView的选中与恢复

macOS

在使用NSCollectionView中发现,如果有选中的项目,那么在重新加载后,选中的项目可能会出现随机的错位。如图:

seleted ite

已经选中了中间的项目,点击底部的Reload按钮后,选中的背景跑到右边的项目上去了。

seleted item reload

代码如下:

import Cocoa

class ViewController: NSViewController {
    @IBOutlet weak var collectionView: NSCollectionView!
    
    private var sources:[String] = [
        "step one",
        "step two",
        "step three",
        "step four",
        "step five",
        "step six",
        "step seven",
        "step eight"
    ]
    
    override func viewDidLoad() {
        super.viewDidLoad()

        registerCollectionViewItem()
    }
    
    private func registerCollectionViewItem() {
        collectionView.register(Item.self, forItemWithIdentifier: NSUserInterfaceItemIdentifier("Item"))
    }

    override var representedObject: Any? {
        didSet {
        // Update the view, if already loaded.
        }
    }

    @IBAction func reloadButtonClicked(_ sender: Any) {
        collectionView.reloadData()
    }
}

extension ViewController:NSCollectionViewDataSource {
    func collectionView(_ collectionView: NSCollectionView, numberOfItemsInSection section: Int) -> Int {
        return sources.count
    }
    
    func collectionView(_ collectionView: NSCollectionView, itemForRepresentedObjectAt indexPath: IndexPath) -> NSCollectionViewItem {
        
        let item = collectionView.makeItem(withIdentifier: NSUserInterfaceItemIdentifier("Item"), for: indexPath)
        item.textField?.stringValue = self.sources[indexPath.item]
         
        return item
    }
}

extension ViewController:NSCollectionViewDelegateFlowLayout {
    func collectionView(_ collectionView: NSCollectionView, didSelectItemsAt indexPaths: Set<IndexPath>) {
        print("select")
    }
    
    func collectionView(_ collectionView: NSCollectionView, didDeselectItemsAt indexPaths: Set<IndexPath>) {
        print("deselect")
    }
}

注意:上面提到,跑偏的是“选中的背景”,而“非选中的状态”。因为在collectionView.reloadData()之后,所有的选中状态都清空了。

@IBAction func reloadButtonClicked(_ sender: Any) {
    print(collectionView.selectionIndexPaths.count) // print 1
    
    collectionView.reloadData()
    
    print(collectionView.selectionIndexPaths.count) // print 0
}

根据苹果文档,重新加载的过程是

Call this method when the data in your data source object changes or when you want to force the collection view to update its contents. When you call this method, the collection view discards any currently visible items and views and redisplays them.

也就是说,在数据源改变的时候,重新加载。而如果我们的数据源没有包含选中的信息,大多数情况,我们不会将选中信息保存到数据源,那么重新加载之后,选中的状态就消失了。

分析

既然状态是清空的,为什么还会有错位显示“选中的背景”的问题呢?这是因为,苹果在重新加载的时候,为了效率,会重复使用之前创建的项目,同时还采用了并行的处理方式进行生成,因此,会导致显示背景的错乱。

解决思路有两个,一个是手动恢复选择。一个是将选中状态写到数据源。

方法一:手动恢复选择

思路:先保存选中的数据,然后重新加载,最后恢复。代码如下:

@IBAction func reloadButtonClicked(_ sender: Any) {
    let indexPaths = collectionView.selectionIndexPaths
    
    collectionView.reloadData()
    
    collectionView.selectItems(at: indexPaths, scrollPosition: .init())
}

func collectionView(_ collectionView: NSCollectionView, itemForRepresentedObjectAt indexPath: IndexPath) -> NSCollectionViewItem {
    
    let item = collectionView.makeItem(withIdentifier: NSUserInterfaceItemIdentifier("Item"), for: indexPath)
    item.textField?.stringValue = self.sources[indexPath.item]
    
    item.highlightState = .none
     
    return item
}

注意看一下第14行,即item.highlightState = .none,因为在应用中恢复选中,并不会激发选中的状态改变,所以我们必须手动更新选中状态。这里相应的代码如下。

import Cocoa

class Item: NSCollectionViewItem {

    override func viewDidLoad() {
        super.viewDidLoad()
        
        self.view.wantsLayer = true
        self.highlightState = .none
    }
    
    override var highlightState: NSCollectionViewItem.HighlightState {
        didSet {
            switch highlightState {
            case .none:
                self.view.layer?.backgroundColor = isSelected ? NSColor.systemBlue.withAlphaComponent(0.8).cgColor : NSColor.systemGreen.withAlphaComponent(0.8).cgColor
            case .forSelection:
                self.view.layer?.backgroundColor = NSColor.systemBlue.withAlphaComponent(0.5).cgColor
            case .forDeselection:
                self.view.layer?.backgroundColor = NSColor.systemGreen.withAlphaComponent(0.5).cgColor
            case .asDropTarget:
                fatalError()
            @unknown default:
                fatalError()
            }
        }
    }
}

那么问题来了,我们的思路是先重新加载,之后恢复选中。既然是这样的顺序,为什么在重新加载的时候,项目会是已经选中的状态呢?不应该是重新加载完成,才选中的吗?这是因为,所有的重新加载,都不是立即重新加载的。而是先恢复状态,然后在下一个事件循环才加载。所以实际的运行类似如下的伪代码:

public func reloadData() {
    // 恢复状态
    DispatchQueue.main.async {
        // 重新加载
    }
}

因此,实际运行的顺序是:

  1. 恢复状态
  2. 恢复选中
  3. 重新加载

这样在实际的重新加载中,项目本身已经是选中的状态了。

另外一个小问题

我们知道,在macOS中,我们可以使用快捷键来进行一些常用的操作。比如利用cmd+a,我们可以进行全部选中。但是如果现在我们使用全部选中,虽然在控制台,我们看到的确是选中了。但是实际上,选中的背景并没有应用。这是因为NSCollectionViewItem.highlightState是针对鼠标操作的,直接用键盘操作,没有鼠标操作的过程,因此背景颜色就没有改变。

分析

highlightState没有改变的,但是选中状态改变了。所以我们在选中状态改变之后,进行背景颜色的改变。代码如下:

import Cocoa

class Item: NSCollectionViewItem {
    
    override var isSelected: Bool {
        didSet {
            self.view.layer?.backgroundColor = isSelected ? NSColor.systemBlue.withAlphaComponent(0.8).cgColor : NSColor.systemGreen.withAlphaComponent(0.8).cgColor
        }
    }

    override func viewDidLoad() {
        super.viewDidLoad()
        
        self.view.wantsLayer = true
        self.isSelected = false
    }
    
    override var highlightState: NSCollectionViewItem.HighlightState {
        didSet {
            switch highlightState {
            case .none:
                break
            case .forSelection:
                self.view.layer?.backgroundColor = NSColor.systemBlue.withAlphaComponent(0.5).cgColor
            case .forDeselection:
                self.view.layer?.backgroundColor = NSColor.systemGreen.withAlphaComponent(0.5).cgColor
            case .asDropTarget:
                fatalError()
            @unknown default:
                fatalError()
            }
        }
    }
}
func collectionView(_ collectionView: NSCollectionView, itemForRepresentedObjectAt indexPath: IndexPath) -> NSCollectionViewItem {
    
    let item = collectionView.makeItem(withIdentifier: NSUserInterfaceItemIdentifier("Item"), for: indexPath)
    item.textField?.stringValue = self.sources[indexPath.item]
     
    return item
}

这里,我们移除了.none状态对于背景颜色的改变,而是采用isSelecteddidSet。并且在viewDidLoad()中恢复状态。这样,我们就不必在collectionView(_:itemForRepresentedObjectAt:)恢复状态了。

方法二:将选中状态写到数据源

单纯的将选中状态写入到数据源很简单,就不详细叙述了。这里介绍的是在不将选中状态写入到数据源的情况下,利用中间对象来进行处理的办法。

我们创建了一个中间对象StateObject<Element>来保存真正的数据和是否选中的状态。这样,就可以不必在真正的数据源中记录选中信息了。

class ViewController: NSViewController {
    @IBOutlet weak var collectionView: NSCollectionView!
    
    private var sources:[String] = [
        "step one",
        "step two",
        "step three",
        "step four",
        "step five",
        "step six",
        "step seven",
        "step eight"
    ]
    
    lazy private var elements:[StateObject<String>] = self.sources.map { StateObject($0) }
    
    override func viewDidLoad() {
        super.viewDidLoad()

        registerCollectionViewItem()
    }
    
    private func registerCollectionViewItem() {
        collectionView.register(Item.self, forItemWithIdentifier: NSUserInterfaceItemIdentifier("Item"))
    }

    override var representedObject: Any? {
        didSet {
        // Update the view, if already loaded.
        }
    }

    @IBAction func reloadButtonClicked(_ sender: Any) {
        collectionView.reloadData()
    }
}

extension ViewController:NSCollectionViewDataSource {
    func collectionView(_ collectionView: NSCollectionView, numberOfItemsInSection section: Int) -> Int {
        return elements.count
    }
    
    func collectionView(_ collectionView: NSCollectionView, itemForRepresentedObjectAt indexPath: IndexPath) -> NSCollectionViewItem {
        
        let dataObject = elements[indexPath.item]
        let item = collectionView.makeItem(withIdentifier: NSUserInterfaceItemIdentifier("Item"), for: indexPath)
        item.textField?.stringValue = dataObject.object
        
        if dataObject.isSelected {
            item.isSelected = true
            collectionView.selectionIndexPaths.insert(indexPath)
        } else {
            item.isSelected = false
        }
         
        return item
    }
}

extension ViewController:NSCollectionViewDelegateFlowLayout {
    func collectionView(_ collectionView: NSCollectionView, didSelectItemsAt indexPaths: Set<IndexPath>) {
        print("select")
        
        indexPaths.forEach {
            elements[$0.item].isSelected = true
        }
    }
    
    func collectionView(_ collectionView: NSCollectionView, didDeselectItemsAt indexPaths: Set<IndexPath>) {
        print("deselect")
        
        indexPaths.forEach {
            elements[$0.item].isSelected = false
        }
    }
}

class StateObject<Element> {
    var isSelected:Bool
    var object:Element
    
    init(_ object:Element) {
        self.object = object
        isSelected = false
    }
}
import Cocoa

class Item: NSCollectionViewItem {
    
    override var isSelected: Bool {
        didSet {
            self.view.layer?.backgroundColor = isSelected ? NSColor.systemBlue.withAlphaComponent(0.8).cgColor : NSColor.systemGreen.withAlphaComponent(0.8).cgColor
        }
    }

    override func viewDidLoad() {
        super.viewDidLoad()
        
        self.view.wantsLayer = true
    }
    
    override var highlightState: NSCollectionViewItem.HighlightState {
        didSet {
            switch highlightState {
            case .none:
                break
            case .forSelection:
                self.view.layer?.backgroundColor = NSColor.systemBlue.withAlphaComponent(0.5).cgColor
            case .forDeselection:
                self.view.layer?.backgroundColor = NSColor.systemGreen.withAlphaComponent(0.5).cgColor
            case .asDropTarget:
                fatalError()
            @unknown default:
                fatalError()
            }
        }
    }
}

总结

  1. 项目的选中,从鼠标点击角度看,一个分成三步:
    1. 项目原始状态,此时的NSCollectionViewItem.HighlightStatenone
    2. 鼠标按压,此时的NSCollectionViewItem.HighlightStateforSelection
    3. 鼠标放开,此时的NSCollectionViewItem.HighlightStatenone
  2. 而如果之前有选中的项目,并且只允许单选的话,那么已选中项目,对应上面2的是forDeselection
  3. 我们可以通过状态none结合isSelected来判断应该设置何种的背景颜色。但是这种方式不如直接在isSelecteddidSet中设置更为方便。这是因为,只有鼠标交互才会改变NSCollectionViewItem.HighlightState,而无论何种交互方式,都会改变isSelected,后者才是确认项目是否已经选中的方式。
  4. 每次重新加载,NSCollectionView的状态都会立即重置,并且在下一个主线程周期进行实际加载。
  5. 我们可以通过暂存并手动恢复的方式来恢复选择,也可以直接将选中状态直接写入到数据源。如果后者不方便,我们可以创建中间对象,来进行处理。
  6. 相对于手动恢复的方式,使用中间对象来进行处理,更加自然,代码也更加集中,更方便今后的修改。

苹果文档NotificationCenter中removeObserver(_:),讨论的部分是错误的

macOS

苹果的文档,在讨论中说:

If your app targets iOS 9.0 and later or macOS 10.11 and later, you don't need to unregister an observer in its dealloc method. Otherwise, you should call this method or removeObserver(_:name:object:) before observer or any object specified in addObserver(forName:object:queue:using:) or addObserver(_:selector:name:object:) is deallocated.

这里很容易被认为是说,对于iOS 9.0和macOS 10.11以上的系统,开发者没有必要再手动移除观察器了。但是我测试的结果却并非如此。

小实验

view controllers 2

假设两个视图控制器的关系如上图所示。点击上面的视图控制器的Show按钮,会弹出下面的视图控制器,然后点击发送通知按钮,会发送一个通知。代码如下:

import Cocoa

class ViewController: NSViewController {
    static let foo = Notification.Name("foo")

    override func viewDidLoad() {
        super.viewDidLoad()

        // Do any additional setup after loading the view.
    }

    override var representedObject: Any? {
        didSet {
        // Update the view, if already loaded.
        }
    }

    @IBAction func postNotificationButtonClicked(_ sender: Any) {
        NotificationCenter.default.post(Notification(name: ViewController.foo))
    }
}
import Cocoa

class V2ViewController: NSViewController {
    private let a = 200

    override func viewDidLoad() {
        super.viewDidLoad()
        
        NotificationCenter.default.addObserver(forName: ViewController.foo, object: nil, queue: nil) { [unowned self] (_) in
            
            print("foo!")
            self.run()
        }
    }
    
    deinit {
        print("V2ViewController deinit.")
    }
    
    private func run() {
        print(self.a)
    }
}

测试步骤

  1. 运行应用
  2. 点击Show按钮
  3. 点击Post Notification按钮
  4. 关闭弹出的窗口。此时控制台会显示V2视图控制器完全退出。
  5. 点击Post Notification按钮。
  6. Xcode提示应用崩溃,因为V2视图控制器已经从内存中销毁了。没有想应的实例。

小结

虽然苹果说对于iOS 9.0和macOS 10.11之后的系统,开发者不必手动移除观察器。但是实际上,如果不移除,那么该观察器就是一直存在的,并有可能造成程序崩溃。

这是因为,NotificationCenter.defaultNotificationCenter的静态属性,它一直在内存中存在。注册在它上面的观察器,因此也就一直在内存中存在。

解决

方法1

在deinit中手动移除观察器。

import Cocoa

class V2ViewController: NSViewController {
    private let a = 200
    private var observer:NSObjectProtocol!

    override func viewDidLoad() {
        super.viewDidLoad()
        
        observer = NotificationCenter.default.addObserver(forName: ViewController.foo, object: nil, queue: nil) { [unowned self] (_) in
            
            print("foo!")
            self.run()
        }
    }
    
    deinit {
        if let observer = self.observer {
            NotificationCenter.default.removeObserver(observer)
        }
        
        print("V2ViewController deinit.")
    }
    
    private func run() {
        print(self.a)
    }
}

方法2

使用[weak self]替代[unowned self]。

import Cocoa

class V2ViewController: NSViewController {
    private let a = 200

    override func viewDidLoad() {
        super.viewDidLoad()
        
        NotificationCenter.default.addObserver(forName: ViewController.foo, object: nil, queue: nil) { [weak self] (_) in
            
            print("foo!")
            self?.run()
        }
    }
    
    deinit {
        print("V2ViewController deinit.")
    }
    
    private func run() {
        print(self.a)
    }
}

这个方法只能避免应用的崩溃。其余的代码还是会执行。如果你有涉及到存储之类的操作,这种方式可能并不适合。

方法3

NotificationCenter.default文档中,苹果说:

All system notifications sent to an app are posted to the default notification center. You can also post your own notifications there.

If your app uses notifications extensively, you may want to create and post to your own notification centers rather than posting only to the default notification center. When a notification is posted to a notification center, the notification center scans through the list of registered observers, which may slow down your app. By organizing notifications functionally around one or more notification centers, less work is done each time a notification is posted, which can improve performance throughout your app.

我尝试自建一个NotificationCenter的实例。这么做的原理是,既然类属性会一直存在,那么我们就不使用类属性,而是使用实例。实例应该在V2视图控制器销毁时自动销毁。

import Cocoa

class ViewController: NSViewController {
    static let foo = Notification.Name("foo")
    private weak var center:NotificationCenter? = nil

    override func viewDidLoad() {
        super.viewDidLoad()

        // Do any additional setup after loading the view.
    }

    override var representedObject: Any? {
        didSet {
        // Update the view, if already loaded.
        }
    }

    @IBAction func postNotificationButtonClicked(_ sender: Any) {
        center?.post(Notification(name: ViewController.foo))
    }
    
    override func prepare(for segue: NSStoryboardSegue, sender: Any?) {
        if segue.identifier == "showV2Segue" {
            let v2 = segue.destinationController as! V2ViewController
            center = v2.center
        }
    }
}
import Cocoa

class V2ViewController: NSViewController {
    private let a = 200
    var center  = NotificationCenter()

    override func viewDidLoad() {
        super.viewDidLoad()
        
        center.addObserver(forName: ViewController.foo, object: nil, queue: nil) { [unowned self] (_) in
            
            print("foo!")
            self.run()
        }
    }
    
    deinit {
        print("V2ViewController deinit.")
    }
    
    private func run() {
        print(self.a)
    }
}

我万万没想到,这个方案居然失败了。我本以为NotificationCenter的实例会自动释放。但是实际上并没有。查看苹果的文档。我发现这么一段:

The block is copied by the notification center and (the copy) held until the observer registration is removed.

块被复制到通知中心,(这个复制品)一直存在,知道观察器被移除。

因此,这个其实和closure导致的循环引用是类似的。

方法4

方法3失败了。不过苹果的说明,启发了我对于方法4的尝试。addObserver(_:selector:name:object:)是添加观察器的另外一个方法,它本身不复制块,而是发送消息给指定对象的函数。这个方式很Objective-C。因此,它需要@objc属性的函数。

import Cocoa

class V2ViewController: NSViewController {
    private let a = 200

    @objc fileprivate func extractedFunc() {
        print("foo!")
        self.run()
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()

        NotificationCenter.default.addObserver(self, selector: #selector(extractedFunc), name: ViewController.foo, object: nil)
    }
    
    deinit {
        print("V2ViewController deinit.")
    }
    
    private func run() {
        print(self.a)
    }
}

这个方法才是符合苹果文档的描述的添加方式。
不过,从原理上讲。这个能够生效的原因,是因为addObserver(_:selector:name:object:)的第一个参数,即observer,它是将整个对象作为观察器,然后在需要时发送消息(第二个参数selector)给观察器。因此,当观察器(一般是当前的视图控制器)自动销毁时,就相当于给nil发送了一个消息。这种方式在Objective-C中是允许的,什么事情也不会发生。从理论上讲,这个其实是一种更安全的方法2。

方法5

搞懂了方法4,我们现在可以改进方法2。方法5,改进了方法2。能够达到方法4类似的结果。不过我们要始终记得,无论是方法2还是方法5,只要不采用方法1的方式移除观察器,那么被复制的块就会一直在等待被执行。

import Cocoa

class V2ViewController: NSViewController {
    private let a = 200

    override func viewDidLoad() {
        super.viewDidLoad()
        
        NotificationCenter.default.addObserver(forName: ViewController.foo, object: nil, queue: nil) { [weak self] (_) in
            
            guard let strongSelf = self else { return }
            
            print("foo!")
            strongSelf.run()
        }
    }
    
    deinit {
        print("V2ViewController deinit.")
    }
    
    private func run() {
        print(self.a)
    }
}

总结

综合苹果的文档和实际测试的结果。不难发现,苹果文档存在错误。总结如下:

  1. 对于addObserver(_:selector:name:object:)添加的观察器。如果是iOS 9.0,macOS 10.11以后的系统,无需用户手动移除观察器。
  2. 对于addObserver(forName:object:queue:using:)添加的观察器。需要使用方法1或方法2的方法来解决。推荐使用方法1。
  3. 对于removeObserver(_:)文档。苹果的讨论是错误的。
  4. addObserver(_:selector:name:object:)的优点是不用手动注销观察器,缺点是需要单独创建一个函数,并且该函数必须是@objc的动态函数。
  5. addObserver(forName:object:queue:using:)的优点是无需单独创建函数,运行代码与添加代码紧密相连。缺点是每个控制器都需要单独用类变量记录下来,并且需要手动移除。(建议在deinit函数中移除)
  6. 综上,我认为方法4的方案是目前代价最小的方案。方法1是最完善的方案。方法5是最偷懒的方案。

macOS下自定义文件类型(传统篇)

macOS

起因

最近开发macOS的应用,需要想Xcode那样打开.xcloc结尾的文件夹。如图,虽在在Finderzh-Hans.xcloc显示为文件夹,但是在Xcode打开它时,它却显示为一个文件(前面没有文件夹的三角形标志)。

Xcode open xcloc folde

在Xcode中的Info.plist里,xcloc文件是定义在Exported Type UTIs中的。因此,如果只是想达到和Xcode同样的效果,只需要将这段xml片段,复制到你自己的Info.plist里。

Xcode xclo

更进一步

Xcode的做法是只在它的导入中将.xcloc的文件夹视为文件,而在Finder中,.xcloc的文件夹还是文件夹的状态。但是,如果是想直接通过双击的方式打开.xcloc的文件夹,则需要Finder同样显示.xcloc的文件夹视为文件。

原理

macOS通过UTI来判断数据的类型,若UTI不存在,则通过扩展名判断。

Finder中我们见到的文件文件夹,从本质上讲,都是存储在电脑中的数据。不过为了管理的需要,我们将单独的数据称之为文件,将包含了其它数据的数据称之为文件夹
特别的,macOS允许我们将特定的文件夹,在Finder中显示为文件。通过在Info.plist中设定LSTypeIsPackage属性为真可以让Finder文件夹看作是文件
但是,这个属性

英文版

Show certain folder as a file as Xcode does

参考资料:

macOS中自定义应用名的坑(更新)

macOS

我们知道在Info.plist中添加CFBundleDisplayName可以更改应用的显示名称,也就是自定义应用名。iOS可以在Xcode的项目->目标->通用中直接设定。

iOS指定Display Name

macOS的目标没有这个设置,必须手动添加。我们直接在Info.plist中添加这个属性。然后在需要翻译的目标语言的InfoPlist.strings中翻译成对应的语言。

这里面有一个坑,导致了一些问题。实践中,在macOS中CFBundleDisplayName的值,必须是包名的最后一项,不然就不会生效。比如,你有一个叫应用叫"com.foo.Orange",然后直接CFBundleDisplayName设置为“橘子”,以为这样就不用翻译了。可你用Xcode编译之后,生成的应用却是“Orange.app”而不是你想要的“橘子.app”。

也就是说,在macOS中,你只能将CFBundleDisplayName设置成“Orange”,然后在翻译文件中翻译。

另外,在测试中我发现也可以将CFBundleDisplayName设置为“空”也能生效。除了设置为包名和空之外,其余的方式都会导致该设置无效。

实际上,这个坑属于bug,确切的说,是macOS中Finder的bug。因为你解包看的话,Xcode生成的文件都是没有问题的。因此只能认为是Finder在读取包时额外作出了错误的限制。我已经向苹果报了这个bug,就不知道苹果肯不肯修了。

收到苹果回复了,苹果认为这个是已知的特性

This is correct behaviour on macOS. Because users can rename apps, CFBundleDisplayName must match the file system name of the app bundle for localization to become active. This behaviour is documented.
See https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html#//apple_ref/doc/plist/info/CFBundleDisplayName

苹果的文档是这么写的。

In macOS, before displaying a localized name for your bundle, the Finder compares the value of this key against the actual name of your bundle in the file system. If the two names match, the Finder proceeds to display the localized name from the appropriate InfoPlist.strings file of your bundle. If the names do not match, the Finder displays the file-system name.

如图,这实际上是两个路径

分析

  1. Xcode生成应用,使用的名称,是项目名称。
  2. Finder上的应用名(文件名),由于用户可以更改,可能与开发者当初设定的不一致。其判断规则是,Finder中应用的文件名,如果与应用开发语言中的CFBundleDisplayName一致,则显示翻译,否则不显示。

原理是搞清楚了。不过我觉得苹果还是有点儿蠢的。开发语言中的CFBundleDisplayName实际上,只是起到判断的作用,而并没有体现为Display Name。这是与其名字不符的。实际上,开发者并没有其它任何办法更改默认的显示名,只能更改项目名。

macOS应用修改默认名称的唯一方法

更改项目名是唯一的方法。
更改项目名

可以在Xcode如图的位置更改项目名。其它方式都只能更改目标语言的显示名,而不能更改默认语言的。

macOS菜单栏状态项小结

macOS

所谓菜单栏状态项,指的是macOS顶部菜单栏右侧的那一排图标。Windows里叫系统托盘,macOS就叫菜单栏状态项。后面简称状态项。

获取状态项并制定图标

let statusItem = NSStatusBar.system.statusItem(withLength:NSStatusItem.squareLength)
guard let button = statusItem.button else {
    fatalError()
}
        
button.image = NSImage(imageLiteralResourceName: "TrayIcon")

图标的图片选择40x40像素的,放在2x下。

添加响应或菜单项

这里是一个坑。虽然状态项支持单击和显示菜单,但是苹果规定,当存在菜单时,单击无效,而只会显示菜单。

var menu: NSMenu?
When non-nil, the status item’s single click action behavior is not used. The menu can be removed by setting the value of this property to nil.

所以你要么添加button的点击功能,要么选择构建一个菜单。这个坑可以通过技术手段绕过,我们后面再提。

鼠标左键和鼠标右键对应不同的响应

状态项默认只支持鼠标左键的点击,而不支持其它的鼠标事件。如果想要支持鼠标右键的点击,我们需要自行实现。

https://stackoverflow.com/questions/32188581/call-action-when-nsstatusbarbutton-is-right-clicked

我选择是上面链接中的方案,新建一个自定义的视图,该视图支持鼠标右键点击,然后再将该视图添加到button上。

自定义的视图:

class MouseRightClickView: NSView {
    var closure:(() -> ())!

    override func rightMouseUp(with event: NSEvent) {
        super.rightMouseUp(with: event)
        
        closure()
    }
}

同时支持鼠标单击和菜单项

现在状态项支持鼠标左键和鼠标右键了,下面我们来实现鼠标左键点击“显示/隐藏应用”,右键点击“显示菜单项”。

guard let button = statusItem.button else {
    fatalError()
}
    
button.image = NSImage(imageLiteralResourceName: "TrayIcon")
button.action = #selector(mouseLeftButtonClicked)
    
// Add mouse right click
let subView = MouseRightClickView(frame: button.frame)
subView.closure = {
    self.constructMenu()
    button.performClick(nil) // menu won't show without this
}
button.addSubview(subView)

大家注意看,创建完成菜单后,必须手工添加一个按钮的点击。如果没有这个,那么只会生成菜单,而不会立即弹出菜单。最后,由于苹果规定如果存在菜单,则不会响应鼠标点击,我们必须在每次菜单关闭时,自动去掉菜单。这就需要设置菜单的代理。

private func constructMenu() {
    let menu = NSMenu()
    menu.delegate = self
    ...
}

菜单代理

extension AppDelegate:NSMenuDelegate {
    // remove the menu or later mouse left click will call it.
    func menuDidClose(_ menu: NSMenu) {
        statusItem.menu = nil
    }
}