肇鑫的技术博客

业精于勤,荒于嬉

NSTextView Best Practice

Initiate Font

Unlike UITextView, you cannot assign font in NSTextView with empty string in Interface Builder. There are many ways to set the font, like to set the font of NSTextView.textStorage.font or set the attributedString of NSTextView.textStorage or just set the attributes. However, those methods are all imperfect.

Working With Input Method

The answers above are all not working with input method. The cursor indicator will still be the unset font's style and the font only applied when you have finished inputing.

The only correct way is to set the typingAttributes property of NSTextView.

inputTextView.typingAttributes = [
    .font : NSFont.userFont(ofSize: 17.0) ?? NSFont.systemFont(ofSize: 17.0),
    .foregroundColor : NSColor.labelColor
]

Don't forget to set the foregroundColor as well, or you textview won't work with light/dark mode switching.

Undo

It is easy to turn on undo in Interface Builder. However, you should know that every time you set string or attributedString property, the undo operations are clear.

You may notice that if the textview is in a modal window, its view controller is presented by presentAsModalWindow(_:), the undo operations will not work as expected.

NSTextDelegate And NSTextStorageDelegate

Both NSTextDelegate and NSTextStorageDelegate work with NSTextView, however there are slightly differences between those functions.

// NSTextDelegate
func textDidBeginEditing(_ notification: Notification)
// only runs once when a user first starts editing.

func textDidChange(_ notification: Notification) 
// only runs when a user edits in the textview.

// NSTextStorageDelegate
textStorage(_ textStorage: NSTextStorage, willProcessEditing editedMask: NSTextStorageEditActions, range editedRange: NSRange, changeInLength delta: Int)
// and
func textStorage(_ textStorage: NSTextStorage, didProcessEditing editedMask: NSTextStorageEditActions, range editedRange: NSRange, changeInLength delta: Int)
// runs every time a user edits, also runs when the developer sets string property in code.

Mixing Fonts Causing the Whole Line Moves

Jun-20-2020 19-50-37

This issue happens when using texts with emoji or texts in different languages. Both TextEdit.app and Pages.app have this issue. Changing text size over 22 may solve this issue. There is no other method to solve this issue yet.

Others

Weight and Line Height of Font between macOS and iOS

The Differences between @ojbc and @IBAction through an Interesting Bug/feature of Interface Builder

Last week I started an open source app Xliff Tool. It was an app to help developers translate Xliff files exported by Xcode.

We know that for the main menu of an app, its menu items look up the responder chain to find actions to perform. So those functions need to be dynamic. For example, a "Open..." menu item under "File" menu, it looks up for a function called openDocument(_:), so I could create a function in AppDelegate.swift like this:

@objc func openDocument(_ sender: Any?) {
    ...
}

However, when I created another customized menu item of my own, I could not find the function name in first responder with Interface Builder.

@objc func openDatabaseDirectory(_ sender: Any?) {
    ...
}

The fix was easy, just changed @objc to @IBAction, and the function name would be shown.

@IBAction func openDatabaseDirectory(_ sender: Any?) {
    ...
}

Q1: Why the prior @objc function was shown but the latter wasn't?

The answer is the prior @objc function wasn't shown. What was shown in Interface Builder is another @IBAction from NSDocumentController.

/* The action of the File menu's Open... item in a document-based application. The default implementation of this method invokes -beginOpenPanelWithCompletionHandler:, unless -fileNamesFromRunningOpenPanel is overridden, in which case that method is invoked instead for backward binary compatibility with Mac OS 10.3 and earlier. If an array other than nil is obtained from that call, it invokes -openDocumentWithContentsOfURL:display:completionHandler: for each URL and, if an error is signaled for any of them, presents the error in an application-modal panel.
*/
@IBAction open func openDocument(_ sender: Any?)

So in Interface Builder the @IBAction function was shown and in app runtime, the dynamic function on the first responder is the @objc one.

Both @IBAction and @objc are dynamic/objective-c functions, one difference is that the prior can be shown in Interface Builder.

An interesting bug/feature of Interface Builder

There was a "Save as..." menu item under "File" menu, connecting to saveAs(_:), I firstly implemented that function

@objc func saveAs(_ sender: Any?) {
    ...
}

Then I changed the "Save As..." menu item to "Export Xliff File...", and refactored the name of the function as well.

@objc func exportXliffFile(_ sender: Any?) {
    ...
}

Then I ran the app, the @objc func exportXliffFile(_ sender: Any?) still worked.

interface_builder_issue

Let me explain this. Though in the above picture it was shown that the menu item "Export Xliff File..." is connected to "exportXliffFile:" function of first responder. As we explained in Q1, there was no corresponding @IBAction function named "exportXliffFile:", so no one could choose "exportXliffFile:" in Interface Builder at all.

Q2: Then why was "exportXliffFile:" connected in Interface Builder and it still worked?

The answer is the operations that I did.

  1. There is @IBAction in NSDocument.saveAs(_:). So @objc func saveAs(_ sender: Any?) worked.
  2. When refactored in Xcode, both the name of @objc func saveAs(_ sender: Any?) and the connection "saveAs:" in Interface Builder were changed.
  3. The name of @IBAction in NSDocument.saveAs(_:) was unchanged as it was in a readonly header.
  4. When the app ran, the menu item looked up for a function called "exportXliffFile:" and found.

You can not pick a @objc function in Interface Builder, but if you refactor its name from a @IBAction, it will still work.

Others

The Real Reason of Not Working NSMenuDelegate with Interface Builder

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#