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。

image

再選擇該 Table Column 底下的 Table Cell View,將它的 Identifier 填入 outlineViewCell。

image

至此 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

    }

 

準備資料

這裡先看一下想要呈現的畫面,底下的描述才會比較好理解。

image

我先設計一個名為 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 會不斷詢問我們四個問題,我們要一一回答,只要全部回答正確,它就會完成樹狀圖了。

再看一下樹狀圖,我用口語來表達它的四個問題與我們的答案。

image

 

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

        }

    }

}

 

執行程式

實際執行的畫面,全部展開後,的確如程式所設計的結果。

image

根據追蹤程式碼的運作,樹狀目錄在完成第一層就停止了,等到使用者點開第二層,程式才繼續那四組程式逐一展開。因此它的效率應該不錯,不會因為第一次要全部載入而花費時間。

不過也因為每次點開子層都要重新處理,所以官方文件也說明這些程式應該要有良好的效率,因為它們會常常被執行。

預設展開

如果要在一開始就全部展開或展開指定的節點,可在 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。

image

覆寫 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")

image

一開始我在「心經」Click,出現紅框的訊息。

然後在「心經」連點二下,出現綠框的訊息,它會先執行一次 Click,然後才印出 Double Click。

那個 2 則是由 mouseDown 呈現的,可以判斷有出現連點的情況。

至於出現的座標 (0,2) 是相對於對面位置,並不是絕對的。如果把阿含也展開,再次點選心經,就可以發現座標不同了,由 (2,0) 變成了 (5,0)

image

 

補記:使用 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

}

 

一開始測試不太順,看起來有載入資料,但沒有文字。

image

這在測試綁定時遇過,就把 Table Cell View 的 Identifier 清空,再執行就看到如下,雖然有文字了,但都是 "Table View Cell"。

image

查了一些資料,好像是說要進行綁定,我試了一下,將左下方二個 Table View Cell 任一個綁定至 Table Cell View 就可以了。

image

執行後畫面如下。

大概是傳回的字串是 Table View Cell,而實際呈現是 Table Cell View,所以將二者綁定,就可以順利看到了。

後來又發現,在 Storyboard 將 OutlineView 的 Content Mode 設定成 Cell Base,好像就不用綁定了。(用"好像"是因為我是在 TableView 測試成功的)

image

 
 

 

重要度:
文章分類:

回應

今天補充一段:

補記:實做時遇到一個問題,在 OutlineView 中,快速先點選 item1 再點 item2,這只是快速切到另一個 item,結果卻是啟動了 item2 的 doubleAction。所以在處理 doubleAction 時,也是要先處理 action,才能判斷 doubleAction 是不是發生在同一個 item 上面。

前幾天也補充了最後一個段落:

補記:使用 NSView 與 NSCell 呈現的差別

發表新回應

借我放一下廣告