表視圖是 macOS 應(yīng)用程序中最普遍的控件之一,熟悉的例子如 Mail 的消息列表和 Spotlight 的搜索結(jié)果。它能以吸引人的方式表現(xiàn)表格數(shù)據(jù)。
NSTableView 將數(shù)據(jù)按行列排列。每一行代表數(shù)據(jù)集合中的一個模型對象,每一列顯示模型對象的一個屬性。
在這個教程里,你將創(chuàng)建一個和 Finder 非常相似的文件瀏覽器。完成這個教程你將學(xué)到很多關(guān)于 table view 的知識。比如:
- 怎樣填充表視圖
- 怎樣改變視覺樣式
- 怎樣響應(yīng)用戶交互,比如選擇和雙擊
準(zhǔn)備好創(chuàng)建你的第一個表視圖了嗎?往下看!
準(zhǔn)備開始
下載 啟動項(xiàng)目,在 Xcode 中打開它。
編譯運(yùn)行,看看開始是什么樣子:

你有了一個用于創(chuàng)建很酷的文件瀏覽器的空白畫布,起始 APP 已經(jīng)有了一些本教程需要用到的功能。
打開應(yīng)用程序,選擇 File > Open… (或者使用 Command+O 快捷鍵)

在新打開的彈出窗口中選擇你想打開的文件夾,點(diǎn)擊 Open 按鈕。你將在 Xcode 控制面板中看到:
Represented object: file:///Users/tutorials/FileViewer/FileViewer/
這條消息顯示了所選擇文件夾的路徑,程序代碼會把這個 URL 傳遞給視圖控制器。
如果你感到好奇,想學(xué)習(xí)這些是怎么實(shí)現(xiàn)的,你應(yīng)該看看下面這些:
- Directory.swift:包含了 Directory 結(jié)構(gòu)體的實(shí)現(xiàn),用來讀取文件夾中的內(nèi)容。
- WindowController.swift:包含了呈現(xiàn)選擇文件夾面板的代碼,并將選擇的文件夾傳遞個 ViewController。
- ViewController.swift):包含了 ViewController 的實(shí)現(xiàn),這里是你要話費(fèi)時間的地方,這也是你創(chuàng)建表視圖和顯示文件列表的地方。
創(chuàng)建表視圖
打開故事板,選擇 View Controller Scene,從對象庫里拖一個 table view 到視圖里。在視圖層級里有一個叫 Table Container 的容器。

下一步需要添加一些約束。點(diǎn)擊自動布局工具條上的 Pin 按鈕。在彈出窗口中選擇邊緣約束如下:
-
Top, Bottom, Leading and Trailing:0
image.png
確保 Update Frames 設(shè)置為 Items of New Constraints,點(diǎn)擊 Add 4 Constraints。
看一下新創(chuàng)建的表視圖的機(jī)構(gòu)。就像名稱顯示的一樣,它通常有下面這樣的機(jī)構(gòu):
- 它由行和列構(gòu)成
- 每一行代表數(shù)據(jù)模型集合中的一個條目
- 每一列代表數(shù)據(jù)模型中的一個屬性
- 每一寫都有一個表頭
- 表頭對數(shù)據(jù)列進(jìn)行描述

如果你對 iOS 中的 UITableView 屬性,那你算是踏進(jìn)了熟悉的水域了,不過 macOS 里更深。實(shí)際上,你可能對構(gòu)造一個 NSTableView 時對象層級里需要構(gòu)造的 UI 對象的數(shù)量感到吃驚。
UITableView 是一個比 UITableView 更老更復(fù)雜的控件。它提供了不同的用戶接口范式,特別是用戶使用鼠標(biāo)可觸控板時。
與 UITableView 的主要不同是,它有多列的可能,表頭可用來和有何進(jìn)行交互,比如選擇和排序。
同時 NSScrollView 和 NSClipView 分別用來滾動和剪裁 NSTableView 的內(nèi)容。有兩個 NSScroller 分別用于垂直和水平滾動。還有許多列對象。NSTableView** 擁有許多列對象,這些列都含有標(biāo)題頭部。用戶可以調(diào)整列的大小和尺寸很重要,盡管你可以將其設(shè)置為默認(rèn)禁用。
解剖 NSTableView
在 Interface Builder 里,你已經(jīng)看到了表視圖層級的復(fù)雜性了。多個類配合起來構(gòu)建表視圖。最終看起來像這樣:

這是 NSTableView 的關(guān)鍵部分:
- Header View:頭部視圖是 NSTableHeaderView 類的實(shí)例。它負(fù)責(zé)在表格的頂部畫表頭。如果你需要自定義的頭部,可以子類化這個類。
- Row View:行視圖顯示與表中每一行相關(guān)的可視屬性,例如選擇高亮。顯示在表中的每一行都有自己的 Row view 實(shí)例。一個重要的區(qū)別是行不代表你的數(shù)據(jù),那是 Cell 的職責(zé)。它只處理可視屬性,比如選擇顏色和分割符。你可以創(chuàng)建新的行子類來定義不同的表格樣式。
- Cell Views:Cell 可能是表視圖中最重要的對象。在行和列的交叉點(diǎn)可以找到 Cell,每一個 Cell 都是 NSView 或者 NSTableCellView。它的作用是顯示最終的數(shù)據(jù)。你可以創(chuàng)建自定義的 Cell 類來按自己喜歡的方式顯示內(nèi)容。
- Column:列被 NSTableViewColumn 類表示,它的任務(wù)是管理列的寬度和行為,比如調(diào)整大小和位置。這不是一個視圖,而是一個控件。你用它來指定列的行為,但是不能控制視覺樣式,因?yàn)楸眍^、行、單元視圖已經(jīng)負(fù)責(zé)這些了。
注意:有兩種模式的 NSTableView。第一種是基于 cell 的,叫 NSCell。它像 NSView,但是更年久更輕量。它是在桌面系統(tǒng)需要以最小花銷繪制控件時出現(xiàn)的。
蘋果建議使用基于 View 的表視圖,但是在 AppKit 中你將看到很多 NSCell,所以,它是什么,它是怎么出現(xiàn)的值得看一下。你可以在蘋果的 Control and Cell Programming Topics 了解更多關(guān)于 NSCell。
以上是表視圖結(jié)構(gòu)背后的基本原理?,F(xiàn)在知道了這些,是時候回到 Xcode 在自己的表視圖上做些事情了。
玩弄表視圖的列
Interface Builder 創(chuàng)建表視圖時默認(rèn)是兩列,但是你需要三列來顯示文件的名稱、日期和大小。
回到 Main.storyboard。
選擇 View Controller Scene 里的表視圖。確保你選的是表視圖,而不是包含它的滾動視圖:

打開屬性檢查器。將 Columns 改為 3。就這么簡單,現(xiàn)在你的表視圖有 3 列了。
下一步,選擇 Selection 部分的 Multiple 復(fù)選框,因?yàn)槟阆M淮芜x擇多個文件。同樣選擇 Highlight 部分的 Alternating Rows 復(fù)選框。啟用這一項(xiàng),是用來告訴表視圖使用交替的行背景顏色,就像 Finder 中一樣。

從命名行的表頭,使文本更具有描述性。選擇 View Controller Scene 中的第一列。

打開屬性檢查器,將列的 Title 改為 Name。

重復(fù)相同的操作,將第二列、第三列的標(biāo)題分別改為 Modification Date 和 Size。
注意:改變列的標(biāo)題有一種替代的方法。你可以雙擊表頭讓它變成可編輯。兩種方法最終結(jié)果是一樣的,所以隨便選一種你喜歡的。
最后,如果你沒有看到尺寸列,選擇 Modification Date 列,大小改為 200。

編譯運(yùn)行,你應(yīng)該看到下面這樣:

改變信息的表現(xiàn)方式
在當(dāng)前狀態(tài)下,Table View 有三列,每一個列都包含一個 Cell View,其中有一個文本框用來顯示文本。
它有點(diǎn)單調(diào),給它增加點(diǎn)趣味,在文件名的旁邊顯示文件的圖標(biāo)。這一點(diǎn)提升之后,表格看起來更干凈了。
你需要用一個包含圖片和文本框的新的 Cell 類型來替換第一列中的 Cell。很幸運(yùn),Interface Builder 提供了一個這樣的內(nèi)置 Cell 類型。
選擇 Name 列中的 Table Cell View 刪除它。

打開對象庫,拖動一個 ** Image & Text Table Cell View into** 到表視圖的第一個列里面,或者拖到 *View Controller Scene tree 里面,剛好放到 Name 列下面。

現(xiàn)在東西快要成型了!
賦予標(biāo)識符(Assigning Identifiers)
每一個 Cell 類型都需要一個標(biāo)示符。否則編寫代碼時,你不能創(chuàng)建一個關(guān)聯(lián)到指定列的 Cell 視圖。
選擇第一列中的 cell view,在 Identity Inspector 里將 Identifier 改為 NameCellID。

用同樣的方法將第二個、第三個的標(biāo)識符分別設(shè)置為DateCellID 和 SizeCellID。
填充表格
注意:你可以用兩種方法來填充表格。一種是使用數(shù)據(jù)源和委托協(xié)議,這是你將在本教程中看到的方法;或者使用 Cocoa bindings。這兩種方法不是互斥的,有時你會同時使用它們來得到你想要的。
表視圖現(xiàn)在還不知道任何你想要顯示的數(shù)據(jù)以及怎么顯示它。你將實(shí)現(xiàn)這兩個協(xié)議來提供這些信息:
- NSTableViewDataSource:告訴 Table View 它需要呈現(xiàn)多少行。
- NSTableViewDelegate: 提供用來顯示指定行列的 Cell View。

可視化的過程是表視圖、委托對象和數(shù)據(jù)源之間的合作。
在輔助編輯器中打開 **ViewController.swift **,按住 CTRL 鍵重 Table View 拖到 **ViewController **創(chuàng)建一個 outlet。

確保類型是 NSTableView,**Connection **是 outlet,命名為 tableView。

現(xiàn)在你可以在代碼中用 outlet 來引用 Table View 了。
切換到標(biāo)準(zhǔn)編輯器,打開 ViewController.swift。實(shí)現(xiàn)需要的數(shù)據(jù)源方法 ViewController,在類的末尾添加以下代碼:
extension ViewController: NSTableViewDataSource {
func numberOfRows(in tableView: NSTableView) -> Int {
return directoryItems?.count ?? 0
}
}
它創(chuàng)建了一個遵循 NSTableViewDataSource協(xié)議的一個擴(kuò)展,實(shí)現(xiàn)了所需的 numberOfRows(in:) 方法來返回文件將中的文件數(shù)目,也就是 directoryItems 數(shù)組的大小。
現(xiàn)在來實(shí)現(xiàn)委托,將以下代碼添加到 ViewController.swift:
extension ViewController: NSTableViewDelegate {
fileprivate enum CellIdentifiers {
static let NameCell = "NameCellID"
static let DateCell = "DateCellID"
static let SizeCell = "SizeCellID"
}
func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? {
var image: NSImage?
var text: String = ""
var cellIdentifier: String = ""
let dateFormatter = DateFormatter()
dateFormatter.dateStyle = .long
dateFormatter.timeStyle = .long
// 1
guard let item = directoryItems?[row] else {
return nil
}
// 2
if tableColumn == tableView.tableColumns[0] {
image = item.icon
text = item.name
cellIdentifier = CellIdentifiers.NameCell
} else if tableColumn == tableView.tableColumns[1] {
text = dateFormatter.string(from: item.date)
cellIdentifier = CellIdentifiers.DateCell
} else if tableColumn == tableView.tableColumns[2] {
text = item.isFolder ? "--" : sizeFormatter.string(fromByteCount: item.size)
cellIdentifier = CellIdentifiers.SizeCell
}
// 3
if let cell = tableView.make(withIdentifier: cellIdentifier, owner: nil) as? NSTableCellView {
cell.textField?.stringValue = text
cell.imageView?.image = image ?? nil
return cell
}
return nil
}
}
這些代碼聲明了一個遵循 NSTableViewDelegate協(xié)議的擴(kuò)展,它實(shí)現(xiàn)了 tableView(_:viewFor:row) 方法。它將被調(diào)用來獲取每一行每一列合適的 cell 類型。
這個方法內(nèi)容很多,讓我們一步一步分解:
- 如果沒有數(shù)據(jù)顯示,不返回 Cell。
- 根據(jù)列顯示的位置 (Name, Date or Size),設(shè)置它們的標(biāo)識符、文本和圖片。
- 通過調(diào)用
make(withIdentifier:owner:)獲取一個 Cell view。這個方法創(chuàng)建或者重用一個帶有那個標(biāo)識符的 Cell。然后用上一步的信息填充它并返回。
下一步,在 viewDidLoad 里添加以下代碼:
tableView.delegate = self
tableView.dataSource = self
這里告訴表視圖委托對象和數(shù)據(jù)源是 view controller。
最后一步是當(dāng)新的文件夾被選擇時更新表視圖。
首先在 ViewController 里添加下面的方法:
func reloadFileList() {
directoryItems = directory?.contentsOrderedBy(sortOrder, ascending: sortAscending)
tableView.reloadData()
}
這個幫助方法刷新文件列表。
首先它調(diào)用 directory 的contentsOrderedBy(_:ascending) 方法,返回排好序的文件數(shù)組。然后它調(diào)用表視圖的 reloadData 方法刷新它。
注意,你只需要在新的文件夾被選擇時調(diào)用它。
到 representedObject 的觀察器 didSet 里替換這行代碼:
print("Represented object: \(url)")
用這行替換:
directory = Directory(folderURL: url)
reloadFileList()
你剛創(chuàng)建了一個指向文件夾 URL 的 Directory 實(shí)例,然后它調(diào)用 reloadFileList() 方法刷新表視圖的數(shù)據(jù)。
編譯運(yùn)行。
使用菜單 File > Open… 或 Command+O 快捷鍵打開一個文件夾,然后魔法發(fā)生了。現(xiàn)在表中填滿了你選擇的文件夾的內(nèi)容。調(diào)整列的大小查看文件和文件夾的所有信息。

做的好!
表視圖交互
在這一節(jié),你將做一些有關(guān)交互的工作來增強(qiáng)有 UI。
響應(yīng)用戶選擇
當(dāng)用戶選擇一個或多個文件時,應(yīng)用程序應(yīng)該更新底部的信息來顯示文件夾中的文件數(shù)和選擇了多少。
為了讓 Table View 選擇項(xiàng)改變時能被通知,需要在委托對象里實(shí)現(xiàn) tableViewSelectionDidChange 方法。當(dāng) Table View 檢測到選擇改變時,它會調(diào)用這個方法。
在 ViewController里添加以下代碼:
func updateStatus() {
let text: String
// 1
let itemsSelected = tableView.selectedRowIndexes.count
// 2
if (directoryItems == nil) {
text = "No Items"
}
else if(itemsSelected == 0) {
text = "\(directoryItems!.count) items"
}
else {
text = "\(itemsSelected) of \(directoryItems!.count) selected"
}
// 3
statusLabel.stringValue = text
}
這個方法根據(jù)用戶選擇更新狀態(tài)欄標(biāo)簽。
Table View 的
selectedRowIndexes屬性包含了被選擇的條目索引。要知道選擇了多少,只需要獲取數(shù)組的數(shù)目就可以了。基于條目數(shù)量生成信息字符串。
設(shè)置狀態(tài)標(biāo)簽。
現(xiàn)在你只需要在用戶選擇時調(diào)用該方法就可以了。在 Table View 的委托擴(kuò)展中添加以下代碼:
func tableViewSelectionDidChange(_ notification: Notification) {
updateStatus()
}
當(dāng)選擇改變時,Table View 調(diào)用該方法,然后更新狀態(tài)欄。
編譯運(yùn)行。

自己試一試。選擇一個或多個文件,觀看狀態(tài)欄改變信息文本來反映你的選擇。
響應(yīng)雙擊
在 macOS 中,雙擊通常意味著觸發(fā)了一個操作,你的程序需要執(zhí)行它。
例如,當(dāng)你在處理文件時,你期望雙擊文件時打開它的默認(rèn)應(yīng)用程序,對于文件夾,你希望查看它的內(nèi)容。
現(xiàn)在你將要實(shí)現(xiàn)雙擊的響應(yīng)。
雙擊消息不是由 Table View 的委托對象發(fā)出的。相反,它們是作為一個操作傳到 Table View 的目標(biāo) target。為了在 view controller 里接受這些消息,需要設(shè)置表視圖的 target 和 doubleAction 屬性。
注意:在 Cocoa 里,Target-action 是一種大部分控件用來通知事件的模式。如果你對它不熟悉,你可以在蘋果的 Cocoa Application Competencies for macOS 文檔的 Target-Action
部分學(xué)習(xí)它。
在 ViewController 的 viewDidLoad() 里添加以下代碼:
tableView.target = self
tableView.doubleAction = #selector(tableViewDoubleClick(_:))
這告訴 Table View 視圖控制器將成為它的操作的目標(biāo),然后設(shè)置雙擊是調(diào)用的方法。
添加 tableViewDoubleClick 方法的實(shí)現(xiàn):
func tableViewDoubleClick(_ sender:AnyObject) {
// 1
guard tableView.selectedRow >= 0,
let item = directoryItems?[tableView.selectedRow] else {
return
}
if item.isFolder {
// 2
self.representedObject = item.url as Any
}
else {
// 3
NSWorkspace.shared().open(item.url as URL)
}
}
這是以上代碼的一步一步分解:
如果 Table View 的選擇時空的,它上面也不做就返回。同時注意,在 Table View 的空白區(qū)域雙擊會導(dǎo)致
tableView.selectedRow返回 -1。如果它是一個文件夾,
representedObject屬性的值被設(shè)置為條目的 URL。然后 Table View 刷新顯示文件夾的內(nèi)容。如果它是一個文件,通過調(diào)用
NSWorkspace的openURL方法在默認(rèn)應(yīng)用程序里打開它。
編譯運(yùn)行,看看你的手藝!
在任意文件上雙擊,看看它是怎么打開的。然后雙擊一個文件夾,看看 Table View 怎么刷新和顯示文件夾內(nèi)容的。
哇哦!等等,你剛剛是不是做了一個 Finder 的 DIY 版本?肯定是!
排序數(shù)據(jù)
任何人都喜歡好的排序,在下一節(jié)你將學(xué)習(xí)怎樣根據(jù)用戶的選擇對 Table View 排序。
表的最好的特色之一是雙擊或單擊一個特定的列來排序。點(diǎn)擊將按升序排序,再次單擊將按降序排序。
實(shí)現(xiàn)這個特殊的 UI 很容易,因?yàn)?NSTableView將大部分功能打包好了。
Sort descriptors 是你用來處理這一點(diǎn)的東西,它只是簡單的指定了所需屬性和排序順序的 NSSortDescriptor 類的一個實(shí)例。
設(shè)置好描述符(descriptors)后,會發(fā)生這些:當(dāng)在表頭點(diǎn)擊時將通過委托對象通知你那個屬性將用來排序數(shù)據(jù)。
一旦你設(shè)置了 sort descriptors,Table View 將提供所有的 UI 來處理排序,例如可點(diǎn)擊的表頭、箭頭和被選擇的描述符的通知。但是根據(jù)信息來排序和刷新表格來響應(yīng)排序是你自己的責(zé)任。
現(xiàn)在你將學(xué)習(xí)怎么做這些!

在 viewDidLoad 里添加以下代碼來創(chuàng)建排序描述符 (sort descriptors):
// 1
let descriptorName = NSSortDescriptor(key: Directory.FileOrder.Name.rawValue, ascending: true)
let descriptorDate = NSSortDescriptor(key: Directory.FileOrder.Date.rawValue, ascending: true)
let descriptorSize = NSSortDescriptor(key: Directory.FileOrder.Size.rawValue, ascending: true)
// 2
tableView.tableColumns[0].sortDescriptorPrototype = descriptorName
tableView.tableColumns[1].sortDescriptorPrototype = descriptorDate
tableView.tableColumns[2].sortDescriptorPrototype = descriptorSize
這些代碼做了這些事:
為每一列創(chuàng)建了一個排序描述符,(Name, Date or Size) 表明了可以根據(jù)哪一個屬性來排序文件列表。
通過設(shè)置
sortDescriptorPrototype屬性為每一列添加排序描述符。
當(dāng)用在任意一列的表頭點(diǎn)擊時,Table View 將調(diào)用數(shù)據(jù)源的 tableView(_:sortDescriptorsDidChange:) 方法,在這個方法里將根據(jù)提供的描述符來排序數(shù)據(jù)。
在數(shù)據(jù)源擴(kuò)展中添加以下代碼:
func tableView(_ tableView: NSTableView, sortDescriptorsDidChange oldDescriptors: [NSSortDescriptor]) {
// 1
guard let sortDescriptor = tableView.sortDescriptors.first else {
return
}
if let order = Directory.FileOrder(rawValue: sortDescriptor.key!) {
// 2
sortOrder = order
sortAscending = sortDescriptor.ascending
reloadFileList()
}
}
這些代碼完成以下事情:
- 獲取用戶點(diǎn)擊的表頭相關(guān)的第一個排序描述符。
- 為視同控制器的 sortOrder 和 sortAscending 屬性賦值,然后調(diào)用
reloadFileList()方法。你早些時候創(chuàng)建了這個方法來獲得一個排序的文件數(shù)組,并通知表視圖更新數(shù)據(jù)。
編譯運(yùn)行.

點(diǎn)擊任意表頭看你的表視圖排序,再次點(diǎn)擊同一個表頭讓它在升序和降序之間切換。
你用 Table View 創(chuàng)建了一個很棒的文件瀏覽器。祝賀你!
