# 給 NSTableView 新增對 App Bundle 檔案的拽入辨識功能
威注音輸入法 3.3.8 原訂加入一個功能,但這個功能推遲到 3.3.9 版了。寫這篇文章時,3.3.9 版還正在開發。
這個功能是:允許使用者將 Finder 視窗內的 App Bundle 檔案直接拽入至輸入法的客體管理視窗內,藉由此方式迅速登記要管理的客體。
這個功能的設計目的,源於一個苦衷:
> 任何輸入法在 macOS 10.9 - 10.11 系統下,只要當前的輸入方式是該輸入法,那麼就會在藉由該輸入法本身點開 ComDlg32 檔案視窗(也就是系統公用的檔案開啟/存檔視窗)的時候出現「輸入法無限 hang,且輸入法從現在開始沾過的任何客體軟體都會跟著 hang」這樣一種災難狀況,除非你 SSH 連到這台電腦上用終端指令 `killall vChewing` 幹掉輸入法的執行緒、或者你切換到其它輸入方式之後等上幾分鐘讓 hang 狀態自動解除。
>
> 本人沒有測試過 macOS 10.12 是否有這問題,只知道 macOS 10.13 開始是沒有問題的。因為這個問題,只能忍痛對專門給 macOS 10.9 - 10.12 的 [威注音 Aqua 特別版](https://github.com/vChewing/vChewing-OSX-Legacy/releases) 閹割了「磁帶模式」和「使用者辭典目錄自訂」的功能,以防止使用者踩到這個地雷。(當然,也還有與 Sandbox Bookmark 特性支援有關的原因,這裡先不繼續扯下去了。)
簡要講一下實現條件與原理。條件如下:
1. NSTableView 要有被指派 DataSource 物件:
```myNSTableViewInstance.dataSource = XXX```
2. 這個 DataSource 物件有遵循 NSTableViewDataSource 協定,且針對這個 DataSource 物件實作兩個與此有關的 NSTableViewDataSource 協定函式。
3. 在 .windowDidLoad() / .viewDidLoad() 等合適的時候正確登記 NSTableView 的 `.registerForDraggedTypes`。
先提第三點,登記方式有兩種。第一種是可以用給 macOS 10.9 的:
```swift
// 這裡的 .init 是 NSPasteboard.PasteboardType 的建構子 (Constructor)。
tblClients.registerForDraggedTypes([.init(rawValue: kUTTypeFileURL as String)])
// macOS 10.13 開始的寫法(語法糖):
tblClients.registerForDraggedTypes([.fileURL])
```
同理,稍後我們要講的第二點當中也會用到類似的參數,但只有一個寫法:
```swift
// 這裡的 .init 是 NSPasteboard.PasteboardType 的建構子:
.init(rawValue: kUTTypeApplicationBundle as String)
```
(於是要開始講第二點了…)這個參數要用在第二點當中提到的兩個協定函式實作過程當中。
> 下述程式碼當中的 NSTableView 的副本被命名為 tblClients,且其 NSWindowController 本身被定義為 tblClients 的 dataSource、遵循 NSTableViewDataSource 協定。
由於第一個函式與第二個函式存在著一定篇幅的共用內容,所以我們將這一部分抽象出來、且給定 lambda expression 特性。
```swift=
/// 檢查傳入的 NSDraggingInfo 當中的 URL 對應的物件是否是 App Bundle。
/// - Parameters:
/// - info: 傳入的 NSDraggingInfo 物件。
/// - onError: 當不滿足判定條件時,執行給定的 lambda expression。
/// - handler: 當滿足判定條件時,讓傳入的 lambda expression 處理已經整理出來的 URL 陣列。
private func validatePasteboardForAppBundles(
neta info: NSDraggingInfo, onError: @escaping () -> Void?, handler: (([URL]) -> Void)? = nil
) {
let board = info.draggingPasteboard
let type = NSPasteboard.PasteboardType(rawValue: kUTTypeApplicationBundle as String)
let options: [NSPasteboard.ReadingOptionKey: Any] = [
.urlReadingFileURLsOnly: true,
.urlReadingContentsConformToTypes: [type],
]
guard let urls = board.readObjects(forClasses: [NSURL.self], options: options) as? [URL], !urls.isEmpty else {
onError()
return
}
if let handler = handler {
handler(urls)
}
}
```
其實也不是非得用 lambda expression,筆者只是為了節省篇幅而已。
接下來是第一個實作函式,用來決定 NSTableView 是否要對拽給自己的 Finder 內容物件做出響應。可以在這裡規定要給哪些物件做出響應。
```swift=
func tableView(
_: NSTableView, validateDrop info: NSDraggingInfo, proposedRow _: Int,
proposedDropOperation _: NSTableView.DropOperation
) -> NSDragOperation {
var result = NSDragOperation.copy
validatePasteboardForAppBundles(
neta: info, onError: { result = .init(rawValue: 0) } // 對應 NSDragOperationNone。
)
return result
}
```
接下來是第二個實作函式,用來規定:在鬆開滑鼠按鍵時,該怎樣處理那些被拽入進來的物件。
```swift=
func tableView(
_: NSTableView, acceptDrop info: NSDraggingInfo,
row _: Int, dropOperation _: NSTableView.DropOperation
) -> Bool {
var result = true // 對外結果記錄:操作是否成功。
validatePasteboardForAppBundles(
neta: info, onError: { result = false } // 對應 NSDragOperationNone。
) { theURLs in
var dealt = false // 操作成功狀態記錄器,有成功操作至少一次的話才設為 true。
theURLs.forEach { url in
// 先看是不是真的能當作 Application Bundle 來讀取、且能讀到 bundleID。
guard let bundle = Bundle(url: url), let bundleID = bundle.bundleIdentifier else { return }
// 然後就是拿這個 bundleID 執行自己自訂的操作。
self.applyNewValue(bundleID, highMitigation: true)
// 記錄這次成功的操作。
dealt = true
}
// 根據上文是否有成功操作,決定對外結果記錄的內容。
result = dealt
}
defer { if result { tblClients.reloadData() } }
return result
}
```
這樣就應該算是完成了。如果還有故障的話,那一定是有其它地方出錯。
P.S.: 該方法與 App Sandbox 相容。
$ EOF.
---
# English ver.
I solved this. Prerequisites:
1. NSTableView instance must have its DataSource object assigned:
```swift myNSTableViewInstance.dataSource = XXX ```
2. The DataSource object conforms to NSTableViewDataSource protocol, having two necessary functions implemented (will discuss below).
3. Whenever appropriate (e.g.: .windowDidLoad(), .viewDidLoad(), etc.), register the `.registerForDraggedTypes` for NSTableView instance.4.
In the prerequisite 3, the registration method is:
```swift
// Constructing the NSPasteboard.PasteboardType.
tblClients.registerForDraggedTypes([.init(rawValue: kUTTypeFileURL as String)]) // Compatible with macOS 10.9 Mavericks.
tblClients.registerForDraggedTypes([.fileURL]) // Since macOS 10.13 High Sierra.
```
In the prerequisite 2, the registration method is:
```swift
// Constructing the NSPasteboard.PasteboardType.
.init(rawValue: kUTTypeApplicationBundle as String)
```
We start implementing. Suppose that (in this case):
1. the DataSource object is the WindowController itself.
2. the name of the NSTableView instance is `tblClients`.
Since the two functions mentioned above share a lot of things, we extract them as a standalone function with lambda expressions:
```swift=
/// See whether the incoming NSDraggingInfo is an array of App Bundles.
/// - Parameters:
/// - info: the incoming NSDraggingInfo object.
/// - onError: On error perform this given lambda expression.
/// - handler: When conditions are met, perform this given lambda expression to process the URL array.
private func validatePasteboardForAppBundles(
neta info: NSDraggingInfo, onError: @escaping () -> Void?, handler: (([URL]) -> Void)? = nil
) {
let board = info.draggingPasteboard
let type = NSPasteboard.PasteboardType(rawValue: kUTTypeApplicationBundle as String)
let options: [NSPasteboard.ReadingOptionKey: Any] = [
.urlReadingFileURLsOnly: true,
.urlReadingContentsConformToTypes: [type],
]
guard let urls = board.readObjects(forClasses: [NSURL.self], options: options) as? [URL], !urls.isEmpty else {
onError()
return
}
if let handler = handler {
handler(urls)
}
}
```
Finally, the two implementations.
1. The one to check whether the dragged file object is the one we need to let the NSTableView response to:
```swift=
func tableView(
_: NSTableView, validateDrop info: NSDraggingInfo, proposedRow _: Int,
proposedDropOperation _: NSTableView.DropOperation
) -> NSDragOperation {
var result = NSDragOperation.copy
validatePasteboardForAppBundles(
neta: info, onError: { result = .init(rawValue: 0) } // NSDragOperationNone.
)
return result
}
```
2. The handling process once the mouse click releases:
```swift=
func tableView(
_: NSTableView, acceptDrop info: NSDraggingInfo,
row _: Int, dropOperation _: NSTableView.DropOperation
) -> Bool {
var result = true // Outer report: Operation successful or not.
validatePasteboardForAppBundles(
neta: info, onError: { result = false } // NSDragOperationNone.
) { theURLs in
var dealt = false // Inner report. Will be true if at least one URL entry is processed with successful result.
theURLs.forEach { url in
// See whether this is an app bundle with valid bundleID:
guard let bundle = Bundle(url: url), let bundleID = bundle.bundleIdentifier else { return }
// Performing customized operations toward this bundleID string:
self.applyNewValue(bundleID, highMitigation: true)
// Mark this operation as "successful".
dealt = true
}
// Tell the outer report that whether we did at least one successful operation.
result = dealt
}
defer { if result { tblClients.reloadData() } }
return result
}
```
This should work unless there are other things screwed up.
$ EOF.