# 給 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.