- NSOutlineView 使用程式呈現樹狀資料
- Mac Cocoa NSOutlineView 資料綁定
前言
在 Mac AppKit 中,樹狀圖元件是使用 NSOutlineView,這個又被翻譯成「大綱視圖」。
官方文件在此:
https://developer.apple.com/documentation/appkit/NSOutlineView
簡單來說,這個文件大概只比天書簡單一點,真不知誰能看的懂?沒有範例大概是最大的問題,整個 Mac 的說明檔似乎都沒有範例(有啦,上次看 WebView 有一個),而一些有範例的文件,不是時效有點久,就是用 Object-C 寫的,實在很難參考。
翻了許多文件,剛好看到這一篇有人提出 NSOutlineView 的問題,其中一位解答的內容很豐富,有圖有程式,我就試著把程式複製起來,竟然順利執行成功,於是我逐一研究追蹤它的程式,再對照天書一般的說明文件,搭配 Google 翻譯服用,終於又有了初步的成果了。
一些心得
這陣子的研究,對 Mac 程式中的代理 (delegate) 有一點點感覺了。以前用 C++ Builder 處理樹狀目錄時,想法其實很直覺,就是先用程式把每一個項目產生並設定,然後把它們依順序串好,最後把這串交給樹狀目錄主元件,然後再呈現,就完成了。
NSOutlineView 處理的方法不同,它有四個主要的資料處理和代理程式,我們要依它的規則去實作,最後才會產生結果。
我想這大概是以前看過某篇提到的感想,Windows 的作法是把程式、資料、元件等混在一起,不是良好的 MVC 模式,而 Mac 的分離就比較清楚。資料歸資料、處理程式歸程式,畫面由代理程式處理。
底下一一實作並說明。
設定基本 NSOutlineView 元件
首先 Storyboard 主視窗放上 NSOutlineView 元件,預設有二個 Column(欄),過去處理的程式都只有一欄(那些 TreeView 元件本來就只有一欄嘛),這裡就保留二欄,順便研究如何處理第二欄。
如下圖,選擇紅圈 1 處,注意是選擇 Outline View 底下第一個 Table Column,再選紅圈 2 的 Identity inspector,確認紅圈 3 的地方是 NSTableColumn,若不是就表示選錯元件了。
最後在紅圈 4 的 Identifier 填入 keyColumn。
再選擇該 Table Column 底下的 Table Cell View,將它的 Identifier 填入 outlineViewCell。
至此 Storyboard 上基本的設定就完成了。
設定資料來源 (dataSource) 與代理 (delegate)
將 Outline View 拉 Outlet 至 ViewController 中,命名為 outlineView,並設定資料來源和代理為 self,如紅色的程式碼。
class ViewController: NSViewController {
@IBOutlet weak var outlineView: NSOutlineView!
override func viewDidLoad() {
super.viewDidLoad()
// Do any additional setup after loading the view.
outlineView.dataSource = self
outlineView.delegate = self
}
準備資料
這裡先看一下想要呈現的畫面,底下的描述才會比較好理解。
我先設計一個名為 Sutra 的類別,它有二個屬性,一個是名稱 name,一個是 sub,是 Sutra 陣列,用來儲存下一層的資料。
class Sutra {
var name: String = ""
var sub: [Sutra] = []
init (_ name: String) {
self.name = name
}
}
在 ViewController 中設定 sutra 屬性,它就是 Sutra 的陣列。
var sutra: [Sutra] = []
在 viewDidLoad() 之中將資料準備好。
sutra.append(Sutra("阿含"))
sutra[0].sub.append(Sutra("雜阿含"))
sutra[0].sub.append(Sutra("中阿含"))
sutra[0].sub.append(Sutra("長阿含"))
sutra.append(Sutra("般若"))
sutra[1].sub.append(Sutra("心經"))
sutra[1].sub.append(Sutra("金剛經"))
sutra[1].sub[1].sub.append(Sutra("能斷金剛"))
sutra[1].sub[1].sub.append(Sutra("般若金剛"))
撰寫資料來源和代理程式
需要實現底下四組程式,才能將樹狀圖呈現出來。
後記:第三組程式其實有二種做法,這裡是採用 View 的方式呈現,最底下還有另一種方法。參考本文最後一段:補記:使用 NSView 與 NSCell 呈現的差別
// 將代理程式另外獨立出來,也是不錯的方法
extension ViewController: NSOutlineViewDataSource, NSOutlineViewDelegate {
// 1.詢問 item 這個節點底下有多少子節點
func outlineView(_ outlineView: NSOutlineView, numberOfChildrenOfItem item: Any?) -> Int {
....
}
// 2.詢問 item 節點下第 index 個節點的子 item 識別代號
func outlineView(_ outlineView: NSOutlineView, child index: Int, ofItem item: Any?) -> Any {
....
}
// 3.詢問在某一個 Column 時,某子 item 要呈現什麼內容
func outlineView(_ outlineView: NSOutlineView, viewFor tableColumn: NSTableColumn?, item: Any) -> NSView? {
....
}
// 4.詢問某一個子 item 底下還有沒有子 item?
func outlineView(_ outlineView: NSOutlineView, isItemExpandable item: Any) -> Bool {
....
}
}
我的理解是 NSOutlineView 會不斷詢問我們四個問題,我們要一一回答,只要全部回答正確,它就會完成樹狀圖了。
再看一下樹狀圖,我用口語來表達它的四個問題與我們的答案。
1.主 item 底下有幾個 item? => 答:2 個
2.主 item 第 1 個子節點代碼是什麼? => 答:阿含節點
3.阿含節點這個 item 在第一欄要呈現什麼? => 答:「阿含」
3.阿含節點這個 item 在第二欄要呈現什麼? => 答:(由程式決定)
4.阿含節點這個 item 有沒有子 item? => 答:有
因為有二欄,所以第 3 個問題會問二次。
最後就會在第一欄呈現「阿含」,並且在前面畫出三角形符號,表示有子層。至於第二欄要呈現什麼,就看程式是怎麼處理的。
因為上面第一題知道有二個子節點,因此會接著會繼續問:
2.主 item 第 2 個子節點代碼是什麼? => 答:般若節點
3.般若節點這個 item 在第一欄要呈現什麼? => 答:「般若」
3.般若節點這個 item 在第二欄要呈現什麼? => 答:(由程式決定)
4.般若節點這個 item 有沒有子 item? => 答:有
因為只有二個子節點,因此就結束了,程式就畫出第一層的二個節點:「阿含」和「般若」。
若使用者點開了「阿含」,因為要畫出第二層,問答再度展開:
1.阿含節點這個 item 底下有幾個 item? => 答:3 個
2.阿含節點這個 item 第 1 個子節點代碼是什麼? => 答:雜阿含節點
3.雜阿含節點這個 item 在第一欄要呈現什麼? => 答:「雜阿含」
3.雜阿含節點這個 item 在第二欄要呈現什麼? => 答:(由程式決定)
4.雜阿含節點這個 item 有沒有子 item? => 答:沒有
同樣,此時雜阿含就完成了。因為第一題回答 3 個,因此相同的程序會執行三次,直到「雜阿含」、「中阿含」、「長阿含」都完成。
以上的提問,就是系統會呼叫這四組程式,至於回答,就是要在程式中提供的。
底下是全部程式碼,用註解說明每一段程式的作用。
// 將代理程式另外獨立出來,也是不錯的方法
extension ViewController: NSOutlineViewDataSource, NSOutlineViewDelegate {
// 1.詢問 item 這個節點底下有多少子節點
func outlineView(_ outlineView: NSOutlineView, numberOfChildrenOfItem item: Any?) -> Int {
if item == nil { // nil 表示是根目錄
return sutra.count // 傳回 sutra 的數量
} else {
// 若是子層,就傳回子層的 sub 陣列數量
if let item = item as? Sutra {
return item.sub.count
}
}
return 0
}
// 2.詢問 item 節點下第 index 個節點的子 item 識別代號
func outlineView(_ outlineView: NSOutlineView, child index: Int, ofItem item: Any?) -> Any {
// 這裡的識別代碼必須是獨一無二的代號,用來判斷是在哪一層,在此就是傳回該 Sutra 的地址 (應該是地址吧?)
if item == nil {
return sutra[index]
} else {
if let item = item as? Sutra {
return item.sub[index]
}
}
return 0
}
// 3.詢問在某一個 Column 時,某子 item 要呈現什麼內容
func outlineView(_ outlineView: NSOutlineView, viewFor tableColumn: NSTableColumn?, item: Any) -> NSView? {
// 抄來的,實際追踨時並沒有進入裡面
guard let columnIdentifier = tableColumn?.identifier.rawValue else {
return nil
}
// 因為 item 是 Sutra 物件的地址,
// 所以直接取它的 name 屬性當成內容
var text = (item as! Sutra).name
// 前面把第一個 Column 定義為 keyColumn
// 這裡就處理成若不是 keyColumn (表示第二欄)
// text 就加上書名號,用來區別第一欄
if columnIdentifier != "keyColumn" {
text = "《\(text)》"
// 但如果這個節點是有子層的,就不要有內容
if (item as! Sutra).sub.count != 0 {
text = ""
}
}
// 這裡比較不好懂,也是抄來的
// outlineViewCell 是第一個 Column 的 Table Cell View 的 Identifier
// 好像就是做出一個 view ,內容是 text,然後傳回去
// 最底下的補記段落有較詳細說明
let cellIdentifier = NSUserInterfaceItemIdentifier("outlineViewCell")
let cell = outlineView.makeView(withIdentifier: cellIdentifier, owner: self) as! NSTableCellView
cell.textField!.stringValue = text
return cell
}
// 4.詢問某一個子 item 底下還有沒有子 item?
func outlineView(_ outlineView: NSOutlineView, isItemExpandable item: Any) -> Bool {
// 如果 item 的 sub 有內容,就表示有子層,傳回 true
if let item = item as? Sutra , item.sub.count > 0 {
return true
} else {
return false
}
}
}
執行程式
實際執行的畫面,全部展開後,的確如程式所設計的結果。
根據追蹤程式碼的運作,樹狀目錄在完成第一層就停止了,等到使用者點開第二層,程式才繼續那四組程式逐一展開。因此它的效率應該不錯,不會因為第一次要全部載入而花費時間。
不過也因為每次點開子層都要重新處理,所以官方文件也說明這些程式應該要有良好的效率,因為它們會常常被執行。
預設展開
如果要在一開始就全部展開或展開指定的節點,可在 viewDidAppear() 呼叫 expandItem。
override func viewDidAppear() {
super.viewDidAppear()
// 全部展開
outlineView.expandItem(nil, expandChildren: true)
// 展開第一個節點,但它的子層不要展開
outlineView.expandItem(outlineView.item(atRow: 0), expandChildren: false)
}
判斷 Click 的內容
有了樹狀目錄,還有二件重要的事要處理,一個是判斷使用者點選的內容,另一個是如何處理拖曳。拖曳還沒研究,這裡先記錄如何處理 Click。
程式設定
網路上查到一個使用 code 處理的方法,紅色是增加上去的,onItemClicked 則是自行命名。
override func viewDidLoad() {
super.viewDidLoad()
// 資料實作
makeSutra()
outlineView.dataSource = self
outlineView.delegate = self
// 處理 click
outlineView.target = self
outlineView.action = #selector(self.onItemClicked)
}
// 處理 click
@objc private func onItemClicked() {
if let item = outlineView.item(atRow: outlineView.clickedRow) as? Sutra {
print("(\(outlineView.clickedRow),\( outlineView.clickedColumn)) : \(item.name)")
}
}
Click 的 Item 先轉換成 Sutra,就可以由 Sutra 取得相關的資料了。
如果是要處理 Double Click,則改成如下。
outlineView.target = self
outlineView.doubleAction = selector(self.onItemDbClicked)
不過若同時指定了 action 和 doubleAction,則連續點二下滑鼠時,會先執行 action 的動作,再去執行 doubleAction 的動作。網上有查到有人說,執行單擊時,程式要等一下,判斷有沒有 doubleAction,沒有的話才確定是只有單擊而已。
補記:實做時遇到一個問題,在 OutlineView 中,快速先點選 item1 再點 item2,這只是快速切到另一個 item,結果卻是啟動了 item2 的 doubleAction。所以在處理 doubleAction 時,也是要先處理 action,才能判斷 doubleAction 是不是發生在同一個 item 上面。
Storyboard 設定
另一個設定 action 和 doubleAction 的方法,是在 Outline View 按右鍵,出現如下視窗,再由 action 和 doubleAction 後面的圓圈拉線到 ViewController 建立 @IBAction。
覆寫 NSOutlineView 的 mouseDown
還有一種方法是覆寫 NSOutlineView 的 mouseDown,而且可以由 event.clickCount 去判斷是不是 Double Click,若是的話,則 event.clickCount 為 2,否則為 1。
extension NSOutlineView {
open override func mouseDown(with event: NSEvent) {
print(event.clickCount)
super.mouseDown(with: event)
}
}
底下是執行畫面,程式除了處理 Click 和覆寫 NSOutlineView 的 mouseDown。
同時又加上處理 doubleAction,只有一行
print("Double Click")
一開始我在「心經」Click,出現紅框的訊息。
然後在「心經」連點二下,出現綠框的訊息,它會先執行一次 Click,然後才印出 Double Click。
那個 2 則是由 mouseDown 呈現的,可以判斷有出現連點的情況。
至於出現的座標 (0,2) 是相對於對面位置,並不是絕對的。如果把阿含也展開,再次點選心經,就可以發現座標不同了,由 (2,0) 變成了 (5,0)
補記:使用 NSView 與 NSCell 呈現的差別
在上面提到四組處理資料來源的程式中,第三組是做出要呈現的 View
// 3.詢問在某一個 Column 時,某子 item 要呈現什麼內容
func outlineView(_ outlineView: NSOutlineView, viewFor tableColumn: NSTableColumn?, item: Any) -> NSView? {
....
}
官方資料:
https://developer.apple.com/documentation/appkit/nsoutlineviewdelegate/1535566-outlineview
資料中就有提到要用此方法 makeView(withIdentifier:owner:),
所以上面的過程中,就會在 Table Cell View 的 Identifier 填入 "outlineViewCell",讓 makeView 函式所用。
在另一個測試中,我同時放二個 OutlineView,不過只要有一個 Table Cell View 的 Identifier 填入供辨識的字串,二個 OutlineView 都能正常呈現資料。
底下是另一個做法,而且是在官方文件中
https://developer.apple.com/documentation/appkit/nsoutlineviewdatasource
正式提到的做法。
// 3.詢問在某一個 Column 時,某子 item 要呈現什麼內容
func outlineView(_ outlineView: NSOutlineView, objectValueFor tableColumn: NSTableColumn?, byItem item: Any?) -> Any? {
....
}
官方資料:
https://developer.apple.com/documentation/appkit/nsoutlineviewdatasource/1531606-outlineview
在只有一欄的情況下,這個函式只要傳回字串,可以寫成非常簡單。
// 3.詢問在某一個 Column 時,某子 item 要呈現什麼內容
func outlineView(_ outlineView: NSOutlineView, objectValueFor tableColumn: NSTableColumn?, byItem item: Any?) -> Any? {
var text = (item as! Sutra).name
return text
}
一開始測試不太順,看起來有載入資料,但沒有文字。
這在測試綁定時遇過,就把 Table Cell View 的 Identifier 清空,再執行就看到如下,雖然有文字了,但都是 "Table View Cell"。
查了一些資料,好像是說要進行綁定,我試了一下,將左下方二個 Table View Cell 任一個綁定至 Table Cell View 就可以了。
執行後畫面如下。
大概是傳回的字串是 Table View Cell,而實際呈現是 Table Cell View,所以將二者綁定,就可以順利看到了。
後來又發現,在 Storyboard 將 OutlineView 的 Content Mode 設定成 Cell Base,好像就不用綁定了。(用"好像"是因為我是在 TableView 測試成功的)
- NSOutlineView 使用程式呈現樹狀資料
- Mac Cocoa NSOutlineView 資料綁定
- 發表新回應
- 瀏覽次數:10800
回應
outlineView 補充二段
今天補充一段:
補記:實做時遇到一個問題,在 OutlineView 中,快速先點選 item1 再點 item2,這只是快速切到另一個 item,結果卻是啟動了 item2 的 doubleAction。所以在處理 doubleAction 時,也是要先處理 action,才能判斷 doubleAction 是不是發生在同一個 item 上面。
前幾天也補充了最後一個段落:
補記:使用 NSView 與 NSCell 呈現的差別
發表新回應