肇鑫的技术博客

肇鑫 / Owen Zhao

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

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

最新文章

Best Practice of URL Related Operations with Strings

Foundation

What is a URL?

A Uniform Resource Locator (URL), colloquially termed a web address, is a reference to a web resource that specifies its location on a computer network and a mechanism for retrieving it.

https://en.wikipedia.org/wiki/URL

URL shown in address bar of a browser

In a browser like Safari or Chrome, a URL is shown in a human friendly way. For example, you may see a URL like this for https://zh.wikipedia.org/wiki/统一资源定位符, this is the Chinese version of URL page on Wikipedia.

https://zh.wikipedia.org/wiki/统一资源定位符

URL dealt by social media

However, the human friendly URLs are not standard and not allowed by social media platforms like Weibo and Twitter. Here are the results that sharing https://zh.wikipedia.org/wiki/统一资源定位符 on the above two platforms.

Weibo
url shared on weibo

Twitter
url shared on twitter

Both of them are not shown the URL correctly, as the Chinese characters are not allowed to be used in a URL directly.

URL with percentage encoding

In fact, those characters are converted by a method called percentage. Here is a percentage url:https://zh.wikipedia.org/wiki/%E7%BB%9F%E4%B8%80%E8%B5%84%E6%BA%90%E5%AE%9A%E4%BD%8D%E7%AC%A6. The percentage URL can be used in social media platforms as well as in browsers.

Detecting URLs from Strings

We use NSDataDetector to get the URLs from a String.

let urlString = "This is a URL: https://zh.wikipedia.org/wiki/统一资源定位符"
let dataDetector = try! NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
dataDetector.matches(in: urlString, range: NSRange(location: 0, length: (urlString as NSString).length))
    .forEach {
        print($0.range) // {15, 37}
        print(urlString[Range($0.range, in: urlString)!]) // https://zh.wikipedia.org/wiki/统一资源定位符
        print($0.url!.absoluteString) // https://zh.wikipedia.org/wiki/%E7%BB%9F%E4%B8%80%E8%B5%84%E6%BA%90%E5%AE%9A%E4%BD%8D%E7%AC%A6
}

The benefit of using NSDataDetector is that we could get the percentage URL automatically.

Bugs of NSDataDetector with link type

The above was enough for String and URLs. However, good things do not always happen. In practice, I found that there were some bugs in NSDataDetector which lead something fatal.

let s = """
// no issue
1. iOS版:https://itunes.apple.com/cn/app/咕唧2/id1366583897?mt=8
2. iOS:https://itunes.apple.com/cn/app/咕唧2/id1366583897?mt=8
3. iOS https://itunes.apple.com/cn/app/咕唧2/id1366583897?mt=8

// issue
4. iOS:https://itunes.apple.com/cn/app/咕唧2/id1366583897?mt=8
5. iOS·https://itunes.apple.com/cn/app/咕唧2/id1366583897?mt=8
6. iOSˆhttps://itunes.apple.com/cn/app/咕唧2/id1366583897?mt=8
7. iOSøhttps://itunes.apple.com/cn/app/咕唧2/id1366583897?mt=8
8. iOS_https://itunes.apple.com/cn/app/咕唧2/id1366583897?mt=8

// unknown scheme issue
9. iOShttps://itunes.apple.com/cn/app/咕唧2/id1366583897?mt=8
"""

let dataDetector = try! NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)

dataDetector.matches(in: s, range: NSRange(location: 0, length: (s as NSString).length))
     .enumerated().forEach { index, match in
        print(index + 1)
        print(match.url!.absoluteString)
        print(match.url!.scheme!)
        print(s[Range(match.range, in: s)!])
        print()
}

The result is

1
https://itunes.apple.com/cn/app/%E5%92%95%E5%94%A72/id1366583897?mt=8
https
https://itunes.apple.com/cn/app/咕唧2/id1366583897?mt=8

2
https://itunes.apple.com/cn/app/%E5%92%95%E5%94%A72/id1366583897?mt=8
https
https://itunes.apple.com/cn/app/咕唧2/id1366583897?mt=8

3
https://itunes.apple.com/cn/app/%E5%92%95%E5%94%A72/id1366583897?mt=8
https
https://itunes.apple.com/cn/app/咕唧2/id1366583897?mt=8

4
http://itunes.apple.com/cn/app/%E5%92%95%E5%94%A72/id1366583897?mt=8
http
itunes.apple.com/cn/app/咕唧2/id1366583897?mt=8

5
http://itunes.apple.com/cn/app/%E5%92%95%E5%94%A72/id1366583897?mt=8
http
itunes.apple.com/cn/app/咕唧2/id1366583897?mt=8

6
http://itunes.apple.com/cn/app/%E5%92%95%E5%94%A72/id1366583897?mt=8
http
itunes.apple.com/cn/app/咕唧2/id1366583897?mt=8

7
http://itunes.apple.com/cn/app/%E5%92%95%E5%94%A72/id1366583897?mt=8
http
itunes.apple.com/cn/app/咕唧2/id1366583897?mt=8

8
http://itunes.apple.com/cn/app/%E5%92%95%E5%94%A72/id1366583897?mt=8
http
itunes.apple.com/cn/app/咕唧2/id1366583897?mt=8

9
iOShttps://itunes.apple.com/cn/app/%E5%92%95%E5%94%A72/id1366583897?mt=8
iOShttps
iOShttps://itunes.apple.com/cn/app/咕唧2/id1366583897?mt=8

From 4 to 8, the range of match dropped the https:// part from the original string. For the last, the scheme of the URL was not as expected.

Workaround

NSDataDetector is an API provided by Apple. We could file a bug and wait until Apple fixes it. Meanwhile, we should write a workaround and keep our apps working.

For issues in 4 to 8, we should double check if there were missing schemes ahead. If there was, we should recalculate the new NSRange and URL, as the old ones were not accuracy.

For the last issue, we thought it was a typing mistake and we would leave it alone.

import Foundation

extension NSTextCheckingResult {
    // FIXME: - Workaround for Apple API Issue
    public func extendedResultForHttp(of str:String) -> (NSRange, URL)? {
        guard resultType == .link else {
            return nil
        }
        
        guard url?.scheme?.lowercased().hasPrefix("http") ?? false else {
            return nil
        }
        
        // check bug with http:// or https://
        let httpScheme = "http://"
        let httpsScheme = "https://"
        let otherScheme = "x://"
        var location = self.range.location - (httpScheme as NSString).length
        
        if location >= 0 {
            let lowerBound = str.index(str.startIndex, offsetBy: location)
            let upperBound = str.index(lowerBound, offsetBy: httpScheme.count)
            
            if httpScheme == str[lowerBound..<upperBound] {
                let _nsRange = NSRange(location: location, length: (httpScheme as NSString).length + self.range.length)
                let url = URL(string: urlStringWithOriginalScheme(httpScheme)!)!
                
                return (_nsRange, url)
            }
        }
        
        location = self.range.location - (httpsScheme as NSString).length
        
        if location >= 0 {
            let lowerBound = str.index(str.startIndex, offsetBy: location)
            let upperBound = str.index(lowerBound, offsetBy: httpsScheme.count)
            
            if httpsScheme == str[lowerBound..<upperBound] {
                let _nsRange = NSRange(location: location, length: (httpsScheme as NSString).length + self.range.length)
                let url = URL(string: urlStringWithOriginalScheme(httpsScheme)!)!
                
                return (_nsRange, url)
            }
        }
        
        // check bug with other protocols
        location = self.range.location - (otherScheme as NSString).length
        
        if location >= 0 {
            let lowerBound = str.index(str.startIndex, offsetBy: location)
            let upperBound = str.index(lowerBound, offsetBy: httpsScheme.count)
            let schemeStr = String(str[lowerBound..<upperBound])
            let _nsRange = NSRange(location: 0, length: (schemeStr as NSString).length)
            let regularExpress = try! NSRegularExpression(pattern: "^[a-zA-Z]+://", options: .anchorsMatchLines)
            
            if regularExpress.firstMatch(in: schemeStr, range: _nsRange) != nil {
                return nil
            }
        }
        
        // good result
        return (self.range, self.url!)
    }
    
    private func urlStringWithOriginalScheme(_ originalScheme:String) -> String? {
        if let url = self.url, var scheme = url.scheme {
            scheme += "://"
            let str = url.absoluteString
            return originalScheme + String(str[str.index(str.startIndex, offsetBy: scheme.count)...])
        }
        
        return nil
    }
}

Others

Swift 5 String补遗

A Reply to Apple on SiriKit Issues

SiriKit

I installed the profile of Siri for iOS. And the issue did seem to be solved at first. In fact it didn't. It just passed the above example. If we revised the example a little, this issue was still there.

Here is the new example:

  1. in Xcode, download iOS 13.3 simulator, as SiriKit doesn't work in 13.5 simulator at all.
  2. run against "SiriKit Intent" target with Siri in sample project with iOS 13.3 simulator, say iPhone 8 Plus.
  3. open Shortcuts, create a new shortcut with "SiriKit Issue" app and named it as "Find My Fruit".
  4. Hold home button and say "Find My Fruit".
  5. Siri asks:"What fruit do you want to have?"
  6. answer: Green.
  7. Siri asks:"Just to confirm, you wanted 'Green Apple'?"
  8. answer: Yes.
  9. Siri says:"Here is your Green Apple!"

In a real device, the steps 7 to 9 will be:

  1. Siri asks:"Confirm for name?" // The name here was a placeholder, I think. Siri used the placeholder instead of the parameter's value.
  2. answer: Yes.
  3. Siri says:"OK, done. Here is your Green!"

Profile of Siri for iOS reset current Siri setting and fixed some issue

In fact, I have two projects. One is the project I am working on in Chinese. The other is the sample project in English I sent to you as the issue example. Since both of them are in different languages, I switch the language of Siri a lot.

After receiving the email from you, I switched Siri to English and tested. The issue was still there. Then I installed the profile of Siri. The issue was gone after the system rebooted. However, when I changed Siri to Chinese and tested the Chinese project, the issue still existed.

I then removed the profile and rebooted. Then I installed the profile again with Siri in Chinese as current setting. After rebooting, some parts of the issue of the project in Chinese was gone. Then I switched Siri to English. Again, the issue of the English example was back.

My conclusion: installing the Siri profile reset the current setting of Siri, which fixed some issue. But Siri in other languages still have the issue. Maybe Apple should reset Siri's setting every time the language of Siri is changed.

With above conclusion, we could run the issue of the old example by these addition steps:

  1. Remove Siri profile and reboot.
  2. Switch Siri to a language other than English, say Chinese.
  3. Install Siri profile and reboot.
  4. Switch Siri back to English. Since the profile reset the Chinese Siri. The English Siri still has the issue.
  5. Run the steps of the old example again on the original post.

API regression of SiriKit after iOS 13.3

//
//  INStringResolutionResult.h
//  Intents
//
//  Copyright © 2016-2020 Apple Inc. All rights reserved.
//

@available(iOS 10.0, *)
open class INStringResolutionResult : INIntentResolutionResult {

    
    // This resolution result is for when the app extension wants to tell Siri to proceed, with a given string. The resolvedString can be different than the original string. This allows app extensions to apply business logic constraints to the string.
    // Use +notRequired to continue with a 'nil' value.
    open class func success(with resolvedString: String) -> Self

    
    // This resolution result is to ask Siri to disambiguate between the provided strings.
    open class func disambiguation(with stringsToDisambiguate: [String]) -> Self

    
    // This resolution result is to ask Siri to confirm if this is the string with which the user wants to continue.
    open class func confirmationRequired(with stringToConfirm: String?) -> Self
}

Above is the header file copied from Xcode. You can see that for a single string, a developer could use either open class func disambiguation(with stringsToDisambiguate: [String]) -> Self or open class func confirmationRequired(with stringToConfirm: String?) -> Self. However, in my own test, both of them didn't work with single result on iOS later than iOS 13.3. When the results were more than 1 elements, the api worked. When only one element existed, Siri used the placeholder, which was called "name", instead of the parameter's value.

You can see it from differences between the old example and the new one above. For the old one, there were 3 kinds of apples, so Siri listed them successfully. For the new one, there was only one apple, the Green Apple, Siri asked "Confirm for name?" in a real device on iOS 13.6 beta, on an iOS 13.3 simulator, Siri asked "Just to confirm, you wanted 'Green Apple'?".

Final Conclusions

  1. When switching languages, Siri some times, if not all the times, has an issue of using the default sentences to replace the sentences that a developer provides.
  2. Installing Siri profile and reboot may fixed above issue on Siri of current setting. But if you change the language of Siri, the issue is still there.
  3. There were regressions on APIs of INStringResolutionResult. For single result, both open class func disambiguation(with stringsToDisambiguate: [String]) -> Self and open class func confirmationRequired(with stringToConfirm: String?) -> Self wouldn't work on iOS later than iOS 13.3. Siri used a placeholder called "name" instead of the actual value.

Others

Internationalisation issue with Sirikit Custom Intents & iOS 13.4.1

Weight and Line Height of Font between macOS and iOS

Foundation

When converting text to image, the converted images were different between macOS and iOS. The main differences are font weight and line height.

Font Weight

Thought the font weight and the fonts are the same, on macOS the font result is always thicker. I don't know why. But in my experience, if you use "HelveticaNeue-Light" for iOS, use "HelveticaNeue-Thin" for macOS.

Line Height

The line height of font is even tricky.

Equation

In Apple's doc, Apple gives below graph. We could draw a simple equation from the graph.

line height = ascent + decent + line gap (leading)

textpg_intro_2x

So I did two tests on both iOS and macOS in Playgound.

// macOS
func getFontInfo(_ name:String) {
    let font = NSFont(name: name, size: 17.0)!
    print(font.ascender) // 13.09033203125
    print(font.descender) // -3.90966796875
    print(font.leading) // 0.0
    print(font.ascender - font.descender + font.leading) // 17.0
    
    let layoutManager = NSLayoutManager()
    print(layoutManager.defaultLineHeight(for: font)) // 20.0
}

getFontInfo("Helvetica")
// iOS
func getFontInfo(_ name:String) {
    let font = UIFont(name: name, size: 17.0)!
    print(font.ascender) // 15.64033203125
    print(font.descender) // -3.90966796875
    print(font.leading) // 0.0
    print(font.ascender - font.descender + font.leading) // 19.55
    print(font.lineHeight) // // 19.55
}

getFontInfo("Helvetica")

From the two tests, we could draw two conclusions:
  1. The equation on iOS was balanced, but on macOS was not.
  2. For the same font with the same weight, the ascender were different.

I didn't know why those happened. So I sent an "Apple Developer Technical Support". Here was the reply from Apple.

apple's reply

According to Apple, if I wanted to use the equation, I should use Core Text framework. But in fact Apple didn't provide line height in Core Text.

Then I did another two tests.

// macOS
func getLineHeightForFontName(_ name:String) {
    let font = CTFontCreateWithName(name as CFString, 17.0, nil)
    
    print(CTFontGetAscent(font)) // 13.09033203125
    print(CTFontGetDescent(font)) // 3.90966796875
    print(CTFontGetLeading(font)) // 0.0
}

getLineHeightForFontName("Helvetica")
// iOS
func getLineHeightForFontName(_ name:String) {
    let font = CTFontCreateWithName(name as CFString, 17.0, nil)
    
    print(CTFontGetAscent(font)) // 13.09033203125
    print(CTFontGetDescent(font)) // 3.90966796875
    print(CTFontGetLeading(font)) // 0.0
}

getLineHeightForFontName("Helvetica")

From all four tests, we could get the conclusions:
  1. Though on iOS, the equation was balanced. The ascent property was modified by Apple.
  2. On macOS, the line height was modified by Apple.
  3. From the above two conclusions, both NSFont and UIFont were not trusted. The only trusted line height was something we get from Core Text.

Line Height

#if os(macOS)
func getLineHeight(_ font:NSFont) -> CGFloat {
    let ctFont = CTFontCreateWithName(font.fontName as CFString, font.pointSize, nil)
    return CTFontGetAscent(ctFont) + CTFontGetDescent(ctFont) + CTFontGetLeading(ctFont)
}
#else
func getLineHeight(_ font:UIFont) -> CGFloat {
    let ctFont = CTFontCreateWithName(font.fontName as CFString, font.pointSize, nil)
    return CTFontGetAscent(ctFont) + CTFontGetDescent(ctFont) + CTFontGetLeading(ctFont)
}
#endif

Others

NSTextView Best Practice

Text Programming Guide for iOS

Cocoa Text Architecture Guide

Core Text - Calculating line heights

NSTextView Best Practice

macOS

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

微博账户过期提示算法的改进

通用

对于使用RestAPI的应用,微博强制用户必须每30天登录一次。我原本的思路是类似这样的:

设备1:@小明 x号登录,x+27天后开始提醒。
设备2:@小明 y号登录,y+27天后开始提醒。(x < y)

但是实际使用中我发现,当x+30之后,在不重新登录账户@小明的情况下,还是可以通过@小明的验证信息发送微博。

分析

表面上,设备1上的授权是过期的,但是为什么还能继续发微博呢?我使用微博提供的API查询了token的信息。结果显示,实际上的设备1上的授权时间,要比x+30要长。

我分析,可是这样的。我原本以为微博服务器会记录每次用户登录的授权,即

设备1:@小明 x号登录,x+30授权结束。
设备2:@小明 y号登录,y+30授权结束。 (x<y)

但实际上,为了方便,微博根本没有记录每次的过期时间,而是每次小明通过同一个应用的授权进行登录时,就自动延长了该授权对应的时间。所以实际上发生的,可能是这样的:

设备1:@小明 x号登录,x+30授权结束。
设备2:@小明 y号登录,y+30授权结束。 (x<y)
因为x<y,所以设备1和2上的@小明,都变成y+30授权结束。

结论

根据上面的分析,新算法就变成了,当x+27时,先向微博服务器进行查询,查看剩余时间是否不足3天,如果超出,则先不进行提示,而是更新下一个时点,这样用户就可以少登录几次了。

The Real Reason of Set NSMenuDelegate in Interface Builder Not Working

When implemented the "Open Recent..." menu item under "File" menu, I connected the menu of "Open Recent"'s delegate to "AppDelegate" class. However, the delegate functions never called.

nsmenudelegate_issue_with_interface_builder

I tried to connect the NSMenuDelegate with the main menu, the result was the same.

I looked this issue up in stackoverflow site. There are many questions on this. Someone says that "Menu delegates are not used that often, so Apple hasn't made them too easy to set up in Interface Builder. Instead, do this in awakeFromNib:".

I don't accept the theory. But the answer does imply that setting NSMenuDelegate in code works.

Then I looked up on how to implementing "Open Recent..." menu. I found this post, Respond to Open Recent clicks in NSMenu. I was glad that I could use NSDocumentController to get the feature of "Open Recent...".

But the routine approach is not suit for my app.

For apps like Pages or Numbers, the files they open varies every time on names. However, for Xliff Tool, the files it opens are exported by Xcode, and the names are fixed. So when using the default approach of NSDocumentController, the recent files shown may be the same.

open_recent_issue

As you can see in above picture, there are two files with the same name. In fact they are in different paths. I need to show the files in full paths instead of just filenames.

So the question is back again. I have to make functions to conform to NSMenuDelegate.

What the magic Apple does to make the "Open Recent..." menu working?

Apple must have implemented its own class that conforms to NSMenuDelegate protocol. Since in Interface Builder I could not find any, I would debug it on AppDelegate's applicationDidFinishLaunching(_:) function.

I connect a @IBOutlet of menu of "Open Recent..." to "AppDelegate", and found that when app runs, the openRecentMenu has been set a NSMenuDelegate called NSDocumentControllerSubMenuDelegate.

open_recent_menu_outlet

After another digging, I found that NSDocumentControllerSubMenuDelegate is a hidden class that should not be used by third-party developers.

Answer

The answer of NSMenuDelegate set in Interface Builder not working is that for mechanism of magic like "Open Recent...", Apple resets the NSMenuDelegate of all menus under an app's main menu.

Anyone who wants to use a NSMenuDelegate, should set it in code.

My Own Solution

After set the NSMenuDelegate, the rest is easy.

extension AppDelegate:NSMenuDelegate {
    func menuNeedsUpdate(_ menu: NSMenu) {
        let clearMenuMenuItem = menu.items.last!
        let urls = NSDocumentController.shared.recentDocumentURLs
        let menuItems = urls.map {
            NSMenuItem(title: $0.path, action: #selector(openFile(_:)), keyEquivalent: "")
        }
        
        menu.items = [
            menuItems,
            [NSMenuItem.separator(), clearMenuMenuItem]
        ].flatMap({$0})
    }
}

Others

The differences between @ojbc and @IBAction through an interesting bug/feature of Interface Builder.

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

macOS

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

macOS

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#