本篇 macOS NSTableView 教程已经更新到 Xcode 8 和 Swift 3。
Table view 是 macOS 应用程序中最普遍的控件之一,“邮件”的消息列表和 Spotlight 的搜索结果都是我们很熟悉的例子。它可以让 Mac 用好看的方式展示表格数据。
NSTableView 按行和列排列数据。每一行表示给定的数据集中的单个模型对象,每一列表示这个模型对象的某个属性。
在这篇 macOS NSTableView 教程中,我们会使用 table view 创建一个实用的文件浏览器,看起来会和 Finder 惊人的相似。学习过程中,你会学到很多关于 table view 的知识,例如:
- 如何填充 table view
- 如何改变它的视觉风格
- 如何对用户交互作出反应,例如选择或双击
准备好创建第一个 table view 了吗?继续!
开始
下载起始项目然后在 Xcode 中打开。
构建并运行,查看起始项目:
这里有一张空白的画布,我们会在这里创建一个炫酷的文件浏览器。起始 app 已经具备了一些教程后面会用到的功能。
保持应用程序开启,选择 File > Open…(或者用快捷键 Command+O )。
从弹出的新窗口中,随便选一个文件夹,然后点 Open 按钮。你会在 Xcode 的 console 中看到这些东西:
Represented object: file:///Users/tutorials/FileViewer/FileViewer/
这条消息显示了选中的文件夹的路径,初始项目中的代码将该 URL 传递到视图控制器。
如果你好奇这是如何实现的,可以看看这些:
- Directory.swift:包含 Directory 结构体的实现,它读取了目录的内容。
- WindowController.swift:包含显示文件夹选择面板以及将选择的目录传递给 ViewController 的代码。
-
ViewController.swift:包含
ViewController
类的实现,今天我们会花时间处理这个类。这是创建 table view 和显示文件列表的地方。
创建 Table View
打开项目导航器中的 Main.storyboard。选中 View Controller Scene 然后从对象库中拖一个 table view 到视图中。容器已经准备好了,就是视图层级中的 Table Container。
下一步,需要添加一些约束。点击自动布局工具条上的 Pin 按钮。在弹出的窗口里,如下设置边缘约束:
-
Top,Bottom,Leading 和 Trailing:0。
确保将 Update Frames 设置为 Items of New Constraints,然后点击 Add 4 Constraints。
看看新创建的 table view 的结构。人如其名,它是典型的表格结构:
- 由行和列组成。
- 每行代表数据模型集中的单个项目。
- 每列表示模型的某个属性。
- 每列可以有标题行。
- 标题行描述列中的数据。
如果你熟悉 iOS 上的 UITableView
,那你会对它也感到熟悉,但在 macOS 上会深入很多。实际上,你可能会对组成 NSTableView
的对象层次结构中的不同 UI 对象的数目感到吃惊。
NSTableView
与 UITableView
相比,是更老、更复杂的控件,支持不同的用户使用情境,具体比如说用户在用鼠标或触摸板的时候。
与 UITableView 的主要区别是,它可能有多个列,以及一个可用于与 table view 交互的标题行,比如排序和选择。
一起的还有 NSScrollView
和 NSClipView
,它们分别表示滚动和剪切 NSTableView
的内容。
有两个 NSScroller
对象——分别用于垂直和水平滚动表格。
还有一些列对象。NSTableView
有列,这些列有标题行。要注意,重要的是,用户可以调整列的大小和顺序,不过你有权利默认禁用这个功能。
剖析 NSTableView
在 Interface Builder 中,已经见识了 table view 的视图层级的复杂性。多个类一起构建了表格结构,最终通常看起来像这样:
NSTableView
有几个关键部分:
-
头部视图:头部视图是
NSTableHeaderView
的实例。它负责绘制表格最上方的头部。如果需要显示自定义的头部,可以使用自己的头部子类。 - 行视图:行视图显示 table view 中每行的相关视觉属性,例如高亮选中。表格中显示出的每一行都有自己的行视图实例。一个重点是行不代表数据;数据由 cell 负责。它只处理视觉属性,如选中颜色或间隔符。可以创建新的行子类,以便为 table view 设置不同的样式。
- 单元格(Cell)视图:单元格可以说是 table view 中最重要的对象。在行和列的交叉处就有一个单元格。每个都是 NSView 或 NSTableCellView 的子类,它负责显示实际数据。已经猜到了?我们可以创建自定义单元格视图类,以显示任何喜欢的内容。
-
列:列由
NSTableViewColumn
类表示,负责管理列的宽度和行为,例如调整大小和位置。这个类不是视图,而是控制器类。使用它指定列的行为,但是不能控制列的视觉样式,因为它已经被包含在头部、行和单元格视图中了。
注意:NSTableView 有两种模式。第一种是基于单元格的 table view,叫做 NSCell。它像 NSView 一样,但更老、更轻量。它来自早期计算机时代,当时桌面需要优化以缩小绘制控件的开销。
苹果建议使用基于视图的 table view,但可以在 AppKit 中的许多控件中发现NSCell
,所以了解它是什么以及它从何而来是有意义的。可以在苹果的 Control and Cell Programming Topics 中详细了解NSCell
好的,以上是关于 tableview 结构的背后理论非常好的热身。现在你已经知道了,是时候回到 Xcode 然后处理自己的 table view 了。
使用 Table View 中的列
默认情况下,Interface Builder 会创建具有两个列的 table view,但我们需要三列以显示名称、日期和文件尺寸信息。
回到 Main.storyboard。
选中 View Controller Scene 中的 table view。确保选中了 table view,而不是包裹它的 scroll view。
打开 Attributes Inspector。把 Columns 的数量调整为 3。就这么简单!你的 table view 选中有三列了。
下一步,勾选 Selection 部分的 Multiple ,因为我们希望一次可以选中多个文件。还要勾选 Highlight 部分的 Alternating Rows。启用时,它会告诉 table view 使用交替的背景颜色,就像 Finder 一样。
重命名列的标题,使文字更具描述性。选中 View Controller Scene 中的第一列。
打开 Attributes Inspector 然后将列 Title 改为 Name。
为第二和第三列重复相同的操作,分别将 Title 改为 Modification Date 和 Size。
注意:更改列标题还有另一个方法。可以在 table view 直接双击标题,它就可以被编辑了。两种方式有完全相同的效果,随便选择一种你喜欢的方式。
最后,如果还看不到 Size 列,选中 Modification Date 列,然后调整大小为 200。也可以用鼠标拖动调整。:]
构建并运行。可以看到如下所示:
更改信息的表示方式
在当前状态下,table view 有三列,每列包含一个在 text field 中显示文字的单元格。
但这样很乏味,所以在文件名旁边增加文件的图标来调节一下。这次调整后,我们的表格看起来会更清爽。
我们需要把第一列中的单元格视图替换为包含图片和 text field 的新单元格类型。
我们很幸运,因为 Interface Builder 内置了这种类型的单元格。
选中 Name 列中的 Table Cell View,删除掉。
打开 Object Library 然后拖一个 Image & Text Table Cell View 到 table view 的第一列,或者拖到 View Controller Scene 树中,放到 Name table view 列下面。
马上就要成形了!
分配标识符
每个单元格类型都需要分配一个标识符。否则,编码时将无法创建与那列相对应的单元格视图。
选中第一列的单元格视图,在 Identity Inspector 里把 Identifier 改为 NameCellID。
对第二列和第三列中的单元格视图重复相同的过程,将标识符分别命名为 DateCellID 和 SizeCellID。
填充 Table View
注意:有两种方式可以填充 tableview,本教程中使用 datasource 和 delegate 协议的方式,也可以通过 Cocoa bindings 。这两种技术不是相互排斥的,如果喜欢的话可以一起使用。
Table view 目前根本不知道你想要显示什么数据、或是如何显示数据,但这确实需要规划一下!所以,我们会实现这两个协议来提供所需的信息:
-
NSTableViewDataSource
:告诉 table view 它需要显示多少行。 -
NSTableViewDelegate
:提供对应行列显示的单元格视图。
可视化过程就是 table view、delegate 和 data source 之间的配合。
- Table view 调用 data source 方法
numberOfRows(in:)
,它返回了表格将会显示的行数。 - Table view 为每个行列调用 delegate 方法
tableView(_:viewFor:row:)
。delegate 创建相应位置的视图,用正确的数据填充它,然后返回给 table view。
这两个方法必须按顺序实现以在 table view 中显示数据。
在 Assistant editor 中打开 ViewController.swift,然后按住 Control,把 table view 拖到 ViewController
类实现中以添加一个 outlet。
确定 Type 是 NSTableView,Connection 是 Outlet。将 outlet 命名为 tableView
。
现在可以在代码中使用这个 outlet 来指向 table view。
切换到标准编辑器,然后打开 ViewController.swift。把下面的代码添加到类的末尾以实现必须的 data source 方法:
extension ViewController: NSTableViewDataSource {
func numberOfRows(in tableView: NSTableView) -> Int {
return directoryItems?.count ?? 0
}
}
这样就创建了一个符合 NSTableViewDataSource
协议的扩展,并且实现了必须的 numberOfRows(in:)
方法以返回目录中的文件数,即 directoryItems
数组的大小。
现在我们需要实现 delegate。添加如下扩展到 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
协议,并且实现了 tableView(_:viewFor:row)
方法。之后 table view 就会在每个行列都调用它以得到正确的单元格。
这个方法有很多内容,一步步拆开看:
- 如果没有数据要显示,就不要返回单元格。
- 基于列(Name、Date 或 Size)设置标识符、文字和图片。
- 调用
make(withIdentifier:owner:)
获得单元格视图。这个方法创建或重用了具有该标识符的单元格。然后,它用上一步中提供的信息填充自己,并将自己返回。
下一步,把下面的代码添加到 viewDidLoad()
:
tableView.delegate = self
tableView.dataSource = self
在这里告诉 table view,它的 data source 和 delegate 就是视图控制器。最后一步是告诉 table view 选择新目录后要刷新数据。首先,将这个方法添加到 ViewController
的实现中:
func reloadFileList() {
directoryItems = directory?.contentsOrderedBy(sortOrder, ascending: sortAscending)
tableView.reloadData()
}
这个辅助方法刷新了文件列表。首先,它调用目录方法 contentsOrderedBy(_:ascending)
然后返回带有目录文件的有序数组。然后它调用了 table view 方法 reloadData()
来让它刷新。
注意,只有在选择了新目录时,才需要调用这个方法。
找到 representedObject
的 observer didSet
,将下面这行代码:
print("Represented object: \(url)")
替换为:
directory = Directory(folderURL: url)
reloadFileList()
我们刚刚创建了 Directory
的一个实例,指向了文件夹 URL,然后调用了 reloadFileList()
方法来刷新 table view 数据。
构建并运行。
使用 File > Open… 或快捷键 Command+O 打开文件夹,然后看着奇迹发生!现在表格全都是来自刚刚选择的文件夹的内容。调整列的大小以看到每个文件或目录的全部信息。
Nice!
Table View 交互
在这个部分,我们会做一些交互来提升 UI。
响应用户选择
当用户选择一个或多个文件时,应用程序应更新底栏中的信息以显示文件夹中的文件总数以及选择了多少个文件。
为了能够在 table view 的选择改变时收到通知,需要实现 delegate 中的 tableViewSelectionDidChange(_:)
。这个方法会在 table view 检测到选择改变时调用。
将下面的代码添加到 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
}
这个方法会根据用户选择更新 status label text。
- Table view 属性
selectedRowIndexes
包含了选中行的索引。要了解选中了多少项目,只需要获取数组 count 即可。 - 根据项目的数量,构建了信息化文本字符串。
- 设置 status label 文字。
现在,只需要在用户更改 table view 选择时调用这个方法。在 table view delegate 扩展中添加如下代码:
func tableViewSelectionDidChange(_ notification: Notification) {
updateStatus()
}
选择改变时,table view 会调用这个方法,然后更新状态文本。
构建并运行。
动手试一下;在 table view 中选择一个或多个文件,然后观察信息化文本是如何改变以反映用户选择的。
响应双击
在 macOS 中,双击通常表示用户触发了一个操作,程序需要去执行它。
例如,在处理文件时,通常期望双击会在默认程序中打开文件,如果是文件夹,就期望看到它的内容。
我们现在要实现双击响应。
双击通知不是由 table view delegate 发送的;相反,它们作为操作被发送到 table view 目标。但为了在 view controller 中接收这些通知,需要设置 table view 的 target
和 doubleAction
属性。
注意:Target-action 是 Cocoa 中大多数控件通知事件使用的模式。如果你不熟悉这个模式,可以在苹果的 Cocoa Application Competencies for macOS 文档里的 Target-Action 部分中进行学习。
在 ViewController
的 viewDidLoad()
中添加如下代码:
tableView.target = self
tableView.doubleAction = #selector(tableViewDoubleClick(_:))
这样就告诉了 table view,视图控制器会成为操作的目标,然后为它设置双击后会调用的方法。
添加 tableViewDoubleClick(_:)
方法实现:
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 的空白区域上双击会导致
tableView.selectedRow
值被设置为 -1。 - 如果是文件夹,就将
representedObject
属性设置为项目的 URL。然后刷新 table view 以显示文件夹的内容。 - 如果项目是文件,就在默认程序中打开它,调用
NSWorkspace
方法openURL()
即可
构建并运行,看看我们的作品。
在任意文件上双击,观察它是如何在默认程序中打开的。现在选择文件夹,观察 table view 如何刷新以及显示文件夹内容。
哇,我们刚刚是创建了一个 DIY 版本的 Finder 吗?看起来一模一样!
排序数据
每个人都喜欢良好的顺序,下一节中,我们会学习如何根据用户的选择对 table view 进行排序。
表最棒的特性之一就是单或双击对指定列排序。单击按升序排序,双击则是降序。
实现这种特定的 UI 很简单,因为 NSTableView
把大部分功能包装起来了,开箱即用。
Sort descriptors 用于处理这种情况,它们只是 NSSortDescriptor
类的实例,指定所需的属性和排序顺序。
设置 descriptor 后,会发生以下情况:单击 table view 的列标题,将会通过 delegate 通知该使用哪个属性,然后用户就能够对数据进行排序了。
设置了排序 descriptor 后,table view 会提供用于处理排序的所有 UI,例如可点击的头部、箭头和选择了哪个 sort descriptor 的通知。然而,我们需要负责实现根据信息排序数据,然后刷新 table view 以反映新顺序。
现在就学习怎么实现。
在 viewDidLoad()
中添加如下代码以创建排序 descriptor:
// 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
这段代码做了这些事情:
- 为每列都创建了一个 sort descriptor,带有一个键(Name,Date 或 Size),该键指明可以对文件列表进行排序的属性。
- 设置
sortDescriptorPrototype
属性以为每列添加 sort descriptor。
如果用户点击任意列标题,table view 会调用 data source 方法 tableView(_:sortDescriptorsDidChange:)
,app 应根据提供的 descriptor 对数据进行排序。
将如下代码添加到 data source 扩展中:
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()
}
}
这段代码做了下面的事情:
- 检索用户点击的列标题相应的 sort descriptor。
- 分配视图控制器的
sortOrder
和sortAscending
属性,然后调用reloadFileList()
。刚刚设置它们以获取排序的文件数组,然后告诉 table view 要重载数据。
构建并运行。
随便单击一个标题,看看 table view 排序数据。在同一个标题再点一次,在升序和降序之间切换。
我们已经使用 table view 构建了一个好看的文件浏览器。好棒棒!
下一步?
可以在 这里 下载完整的项目。
这篇 macOS NSTableView 教程涉及了相当多内容,现在你应该对使用 table view 组织数据更有信心了。此外,我们还学习了:
- table view 构建的基础知识,包括头、行、列和单元格。
- 如何添加列以显示更多数据。
- 如何识别不同的组件以供引用。
- 如何在表格中加载数据。
- 如何响应不同的用户交互。
还可以用 table view 做很多事情来为 app 构建优雅的 UI。如果你想学习,可以参考下面的资源:
- 苹果的 Table View Programming Guide for Mac。
- WWDC 2016 - Session 239 Video - Crafting Modern Cocoa Apps , 快速了解如何构建先进的 table view。
- WWDC 2011 - Session 120 Video View Based NSTableView Basic to Advanced。
- TableViewPlayGround 有 Objective-C 的示例代码,展示如何创建不同类型的自定义 table view。
如果对于本教程有任何问题或评论,随意在下方发表!