肇鑫的技术博客

业精于勤,荒于嬉

NSSavePanel Best Practice

Sometimes I will use NSSavePanel. However, every time I use it, I have to how to using it. Here is the best practice so next time I will not have to look the docs up.

@objc func exportXliffFile(_ sender: Any?) {
    let exportPanel = NSSavePanel()
    exportPanel.prompt = NSLocalizedString("Export", comment: "")
    exportPanel.allowedFileTypes = ["xliff"]
    let xliffURL = (NSApp.delegate as? AppDelegate)?.xliffURL
    exportPanel.directoryURL = xliffURL
    exportPanel.nameFieldStringValue = xliffURL!.lastPathComponent
    exportPanel.beginSheetModal(for: view.window!) { [unowned self] (response) in
        if response == .OK {
            self.save(to: exportPanel.url!)
        }
    }
}

About code is from my open source app Xliff Tool.

Others

https://developer.apple.com/documentation/appkit/nssavepanel/1534419-allowedfiletypes#

Shortcuts with modify mask in macOS

For Poster 2 Mac, which has been released lately, one of the feedbacks I got, is if I could provide a method to quickly compose and share texts. For current users, the steps are:

  1. Move mouse/trackpad to click the menu bar item of Poster 2.
  2. Move mouse to click the Write button.
  3. Compose.

Also, the user may have to close the window when texts are sent.

I want to improve those experiences. The easiest idea is to bind composing with mouse click directly. But I don't want that. In my own experience, you must make your app as simple as possible, as you may not even know, that some of the users have never known of right click. If you app need to do something with right click, those functions would be seen as never worked.

A big difference from a macOS user to an iOS user is the former uses a physical keyboard. So I want to add shortcuts for composing and hiding app.

As the beginning, I was thinking adding a menu item to do the same with Write button. However, I found I could bind Write menu item with key enter, but I could not call it in my app. I got a beep sound and nothing happened. I could use modifier mask like command, but that was not what I wanted.

Responder Chain

You should know what is a responder chain. For a brief, a responder chain is something how your app responds to a user event. For example, if a user presses keyboard a. The first responder gets the a and looks for itself if it can react the event, if not, it will pass the event to the next responder, usually is the view which contains itself. Things will go on until the event is react or there is no further responder.

NSStandardKeyBindingResponding Protocol

However, there are always some other things you have to consider. Many subclasses of NSView adopt NSStandardKeyBindingResponding protocol, and some keys have been occupied already. The good thing is, if you want to use those keys, you should override the corresponding method.

Beep Sound

Some subclasses of NSView also implemented the default beep sound if the key is not register. You must exclude the key you want in NSResponder's keyDown(with:) method.

My Resolution

Press enter key for quick editing, and press esc key to hide window.

Enter Key

Subclassing NSWindow and override keyUp(with:) method to compose.

Esc Key

Override cancelOperation(_:) for esc key.

class CancelWindow: NSWindow {
    override func keyDown(with event: NSEvent) {
        if event.keyCode == 36 { // enter key

        } else {
            super.keyDown(with: event   )
        }
    }
    
    override func keyUp(with event: NSEvent) {
        if event.keyCode == 36 { // enter key
            (self.contentViewController as? NextMainViewControllerMac)?.writePost(nil)
        } else {
            super.keyUp(with: event)
        }
    }
    
    override func cancelOperation(_ sender: Any?) {
        (NSApp.delegate as? AppDelegate)?.hide()
    }
}

Enter Key for NSButton

There is also something need to mention. If you add enter to keyEquivalent of NSButton, the border become blue automatically. This is a feature which can't be changed.

A Well-formed macOS Menu Bar Application in Sandbox

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应用登录时启动的实现方式