肇鑫的技术博客

肇鑫 / Owen Zhao

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

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

最新文章

The Simplest Sample for Dragging Operation on UITableView

iOS

One of my friend requires an app feature for Poster 2 which requires drag operation on UITableView.

I did a research and found two resolutions. They all worked. However, they were all too complex. I wanted to find the simplest way to implemented the operation. Here it is.

Theory

There are two ways to do the tricks. One is to use UITableViewDiffableDataSource, the other is to use UITableViewDataSource. This article is focus on the latter. If you want to know the prior, just find the link at the end of this article.

override func viewDidLoad() {
    super.viewDidLoad()
    
    tableView.dragInteractionEnabled = true
    tableView.dataSource = self
    tableView.dragDelegate = self
}

// MARK: - UITableViewDataSource
extension ViewController:UITableViewDataSource {
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return items.count
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath)
        cell.textLabel?.text = items[indexPath.row]
        
        return cell
    }

// MARK: - UITableViewDragDelegate
extension ViewController:UITableViewDragDelegate {
    func tableView(_ tableView: UITableView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] {
        // return [] // return [] won't stop the table view from draging. However, Apple wants us to do it on our own.
        
        return [UIDragItem(itemProvider: NSItemProvider(object: items[indexPath.row] as NSItemProviderWriting))]
    }
}

tableView.dragInteractionEnabled = true this line must not be the last line of the three-line code, or the drag operation won't work.

In docs, Apple says "Return an empty array to indicate that you do not want the specified row to be dragged." This requirement is for you to implement. Even if you return [] here, the drag operation still works.

The above code is enough for UITableView dragging operation. When you drag, the cell that you drag will move to the destination you drop. However, like all other operations on UITableView, you need to keep the model's consistence with the UI.

func tableView(_ tableView: UITableView, moveRowAt sourceIndexPath: IndexPath, to destinationIndexPath: IndexPath) {
        guard sourceIndexPath != destinationIndexPath else {
            return
        }
        
        let item = items[sourceIndexPath.row]
        
        if sourceIndexPath.row < destinationIndexPath.row {
            items.insert(item, at: destinationIndexPath.row + 1)
            items.remove(at: sourceIndexPath.row)
        } else {
            items.remove(at: sourceIndexPath.row)
            items.insert(item, at: destinationIndexPath.row)
        }
        
        items.forEach {
            print($0)
        }
        print()
    }
}

References

Sample For UITableViewDiffableDataSource catalyst_reorder_example

Apple's Official Sample Adopting Drag and Drop in a Table View

My Own Sample

NSOutlineView Briefing

macOS

The new version of Xliff Tool needs to use NSOutlineView. However, the official document by Apple is of flaw and the answers on SO are too old. I have to pay nearly a whole afternoon to find out what the right approach is. Here is the briefing.

Briefing

In fact, Apple does provide a sample project of NSOutlineView. However, that sample is based on cocoa bindings. So if you want to know cocoa bindings. Here is the way.

Navigating Hierarchical Data Using Outline and Split Views

Otherwise, welcome to go on.

On Apple's Docs, it says:

If you are using conventional data sources for content you must implement the basic methods that provide the outline view with data: outlineView(_:child:ofItem:), outlineView(_:isItemExpandable:), outlineView(_:numberOfChildrenOfItem:), and outlineView(_:objectValueFor:byItem:). Applications that acquire their data using Cocoa bindings do not need to implement these methods.

However, if you create a new project with the Xcode 12.4 with Storyboard, the sample you find from SO won't work.

That is because the document is outdated.

Steps

  1. Create a new project, using Swift and Storyboard.
  2. Add a Source List to the view controller.

The result will be look like this.

outline_view_in_view_controller

Right click the storyboard and choose "Outline View". You will find the Outline View is View based.

outline_view_is_view_based_by_default

That is the reason why Apple's document doesn't work. You need to change View based to Cell based to make it work.

codes

//
//  ViewController.swift
//  OutlineView Sample
//
//  Created by zhaoxin on 2021/3/20.
//

import Cocoa

class ViewController: NSViewController {
    @IBOutlet weak var outlineView: NSOutlineView!
    
    private let cats:[String] = {
        (1...3).map {
            return String(format: "cat%02d", $0)
        }
    }()
    
    private let dogs:[String] = {
        (1...4).map {
            return String(format: "dog%02d", $0)
        }
    }()
    
    lazy private var animals = [cats, dogs]
    
    override func viewDidLoad() {
        super.viewDidLoad()

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

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


}

extension ViewController:NSOutlineViewDataSource {
    // 2
    func outlineView(_ outlineView: NSOutlineView, child index: Int, ofItem item: Any?) -> Any {
        if item == nil {
            return animals[index]
        }
        
        if let list = item as? [String] {
            return list[index]
        }
        
        return item as! String
    }
    
    // 3
    func outlineView(_ outlineView: NSOutlineView, isItemExpandable item: Any) -> Bool {
        if item is [String] {
            return true
        }
        
        return false
    }
    
    // 1
    func outlineView(_ outlineView: NSOutlineView, numberOfChildrenOfItem item: Any?) -> Int {
        if item == nil {
            return animals.count
        }
        
        if let list = item as? [String] {
            return list.count
        }
        
        return 0
    }
    
    // 4
    // This function only works with Cell based Outline Views.
//    func outlineView(_ outlineView: NSOutlineView, objectValueFor tableColumn: NSTableColumn?, byItem item: Any?) -> Any? {
//
//        if item is [String] {
//            let row = outlineView.row(forItem: item)
//
//            if row == 0 {
//                return "cats"
//            }
//
//            return "dogs"
//        }
//
//        return item as! String
//    }
}

extension ViewController:NSOutlineViewDelegate {
    // 4
    // This function only works with View based Outline Views.
    func outlineView(_ outlineView: NSOutlineView, viewFor tableColumn: NSTableColumn?, item: Any) -> NSView? {
        let view:NSTableCellView!

        if item is [String] {
            view = outlineView.makeView(withIdentifier: NSUserInterfaceItemIdentifier("HeaderCell"), owner: self) as? NSTableCellView

            let row = outlineView.row(forItem: item)

            if row == 0 {
                view.textField?.stringValue = "cats"
            } else {
                view.textField?.stringValue = "dogs"
            }
        } else {
            view = outlineView.makeView(withIdentifier: NSUserInterfaceItemIdentifier("DataCell"), owner: self) as? NSTableCellView
            view.textField?.stringValue = item as! String
        }

        return view
    }
}

Look at the order number of the function, that is the order the system calls them.

  1. At first, the system asks how many children of root.
  2. Then the system asks you to provide the object that represent the child.
  3. Then the system asks you if the child is expandable.
  4. Then the system asks you to provide the view/cell of the child.
  5. When you click a child, the system goes to step 1, but this time, the child is the new root.

You should aware that there are two kinds of items. One is Any?, the other is Any.
They are different.
The Any? type is the parent node of the child. For first level cells, item is nil. nil means it is the root.
The Any type is the child.

References

My Sample

Apple's Document Outline View

NSOutlineView example on SO

Mac OSX 开发基础控件学习之 NSOutlineView

Speed up Xcode Building Time with CocoaPods for Beginners

Carthage

At my first thought, this article had supposed to be named "The Modern Guide of Carthage with Xcode 12 for Beginners". However, while the process of learning Carthage and replace my own projects from using CocoaPods to Carthage. I found that using Carthage alone was not enough, or too complex for a one-week-learner like me.

The purpose of this article is to speed up your Xcode building and archiving time. In my own test, the speed could be over the original 10 times.

Since I am a beginner for Carthage, I may change this article a lot with updates.

Frameworks

A Framework is a prebuilt code package that you can use in your own code.

As programmers, we use frameworks a lot. For example, when you import UIKit, you are using UIKit framework provided by Apple.

UIKit is a build-in framework, so you just need to import it. There are also many other frameworks provided by third-parties. You can even create your own frameworks. For third-party frameworks, it is your responsibility to let Xcode know how to using them.

You can manually drag and drop those frameworks to Xcode. Or you can use framework tools like CocoaPods or Carthage.

CocoaPods VS Carthage

CocoaPods is more widely used. It has a long history ever since Objective-c. It uses source code so it has the most compatibility. Since you have to compile the framework from source code every time, it takes more time when build and archive.

Unless CocoaPods, Carthage builds the frameworks while you download them. Then you can drag and drop those frameworks to Xcode as if there are prebuilt. Those frameworks won't need to recompile again as they are already binaries. Your own project building time is saved.

We could use CocoaPods or Carthage alone, or mixed, or neither. The more you use CocoaPods, the complex the source codes are, the more build time of your own project lasts. So we intent to use Carthage alone. However, Carthage is not as compatible as CocoaPods with Xcode. Sometime you may find no solution to solve the problem of Carthage. Then you can try CocoaPods.

xcframework

Xcframework is the latest framework format that Xcode 12 supports and recommended.

Before xcframework, when we use a framework on macOS. The framework is easy to build. As for a Mac, only x86_64 architecture is used. If the framework is for iOS, things go complex. As if you could run an iOS app on both iOS simulators and iOS devices. For an iOS simulator running on a Mac, its architecture is x86_64, the same as the Mac. For an iOS device, the architecture is arm64. The framework you built should work in both x86_64 iOS simulators and arm64 iOS devices.

Framework tools use lipo to merge and split frameworks of different architectures.

$ man lipo

lipo - create or operate on universal files

A framework that contains more than one architectures is called a fat framework. Xcode could use the framework for both iOS simulators and iOS devices.

Apple Silicon Macs

Things go left when M1 Macs released. As they are arm based Macs instead of Intel based Macs. Everything on them is arm64 architecture.

For a framework runs on Mac, it now needs to be built into both x86_64 and arm64. lipo could do the trick.

For a framework runs on iOS, it now needs to be built into x86_64 and arm64 for iOS simulators and arm64 for iOS devices. You may think you can still use lipo. No, you can't. As you could see, there are arm64 in both iOS simulators and iOS devices. lipo could only specify a framework by its architecture, but it doesn't know if it is an iOS simulator or an iOS device. lipo cannot combine those frameworks together as the arm64 architectures are duplicated.


Xcframework comes. Xcframework is a bundle that contains an Info.plist with the information that the frameworks it contains. For each individual framework, it could be a fat framework or not.

Below is a picture of two built xcframeworks of Realm. The left is the Realm office prebuilt framework. The right is the framework built by Carthage by my Intel based iMac.

realm_official_VS_carthage

As we could see, for the left, there is an extra iOS maccatalyst framework. And for all simulators, there are extra arm64 architectures. I think it is because that xcframework was built on a M1 based Mac.

I didn't find the way to build arm64 architecture frameworks for simulators on an Intel based Mac. This leads to a bug of Xcode 12 on iOS app with watchOS app as companion.

Carthage by default can build xcframeworks with all architectures in all platforms, except Mac Catalyst.

Xcode 12 Requests arm64 Framework for watchOS Simulator

It is unnecessary to request arm64 framework for watchOS simulator on an Intel based Mac. It is a tradeoff. As if you are in a team that uses both Intel based and Mac based Mac, this feature is unnecessary. You can get rid of it manually.

Go to watchOS extension target -> Building Settings -> Excluded Architectures -> Debug, add "arm64".

exclude_ar

Carthage Working Steps

  1. Cartfile
  2. Resolve
  3. Checkout
  4. Build

Cartfile

Cartfile is the place where you put the framework's name and version. All frameworks that in Cartfile is called dependencies.

$ cat Cartfile
github "realm/realm-cocoa"

Resolve

When you call carthage update, carthage resolves the Cartfile as a Cartfile.resolved file. The resolved file may contain more frameworks than Cartfile as some frameworks request other frameworks.

$ cat Cartfile.resolved 
github "realm/realm-cocoa" "v10.7.2"

Checkout

Checkout downloads source codes of frameworks.

Build

The building process.

Install Frameworks to Xcode 12.4

To install frameworks to Xcode, basically we just need to drag and drop the frameworks to Xcode. However, depending the dropping locations, there are two methods of adding frameworks.

add_frameworks

Install

Cartfile

XCFrameworks

full platforms

how to installing frameworks

two ways:

  1. drag to frameworks
  2. drag to general
  3. differences

tips

remove unused framework
update arguments

known issues

  1. download binary instead of create xcframework
  2. download binary along with creation of xcframework

Xcode known issues

watchOS arm64 issue on simulator of Intel based Mac

Advances

build own framework with backups.

Strongly Recommend You to Add Your Own UINavigationControllers even in iOS 14 when Using UISplitViewController

iOS

In iOS 14, Apple adapts UISplitViewController from two columns to three columns. Especially, it says that

If you set a child view controller that’s not a navigation controller, the split view controller creates a navigation controller for it.

However, Apple didn't say that it will try to reuse the UINavigationController when you reset the columns.

That means, when you set .secondary view controller to another view controller, the split view controller will push the new view controller to the back stack, which is not what you want.

Issue Sample

UISplitViewController Issue

  1. run the sample
  2. you will see detail 01, press back
  3. you will see master 01, press back
  4. you will see primary, press Master 02
  5. you will see Master 02, press Master 02
  6. you will see Detail 02, press back

You expect to see Master 02.
You see Detail 01.

Work Around

Add a UINavigationViewController to each of detail view controllers.

UISplitViewController Issue work around

I have file this as a bug to Apple. The id is FB8640357.

Apple's Reply

Just got the reply from Apple. 8 days since I have filed this issue. Apple says:

After reviewing your feedback, we have some additional information for you:

This is expected behavior.

The secondary column gets this special treatment if the view controller gets set to a non-navigation controller when it already has a nav controller—i.e., it’s pushed onto the nav stack, as they discovered.

Expected use patterns would be:

• Change the contents of the detail view controller (not the vc itself) and send -showColumn:UISplitViewControllerColumnSecondary (if desired) to confirm that the secondary column is visible, or ...
• For the secondary column only, clear it first by setting its vc to nil, then setting new desired vc, or ...
• Manage the navigation controller in the secondary column directly with -setViewControllers: or push/pop. If you’re using an automatically created UINavigationController instead of one you provided, you can find it with -viewControllerForColumn: and then ask that vc for its navigation controller.

Apple gave 3 recommends. The first was easy to think of, but it needed the two view controllers to be very similar.

I like the second recommend most. func setViewController(_ vc: UIViewController?, for column: UISplitViewController.UISplitViewController.Column) does allow vc to be nil. And it is direct. There is some performance lose though. As it will create another UINavigationViewController each time.

The third recommend has the most performance. But it is indirect. And it smells bad. It should be seen as a workaround.

Final

I recommend to fix it in storyboard as I previously did. Or, using the second recommend by Apple.

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