【译】MacOS NSTableView 教程

原文

表视图是 macOS 应用程序中最普遍的控件之一,熟悉的例子如 Mail 的消息列表和 Spotlight 的搜索结果。它能以吸引人的方式表现表格数据。

NSTableView 将数据按行列排列。每一行代表数据集合中的一个模型对象,每一列显示模型对象的一个属性。

在这个教程里,你将创建一个和 Finder 非常相似的文件浏览器。完成这个教程你将学到很多关于 table view 的知识。比如:

  • 怎样填充表视图
  • 怎样改变视觉样式
  • 怎样响应用户交互,比如选择和双击

准备好创建你的第一个表视图了吗?往下看!

准备开始

下载 启动项目,在 Xcode 中打开它。

编译运行,看看开始是什么样子:


image.png

你有了一个用于创建很酷的文件浏览器的空白画布,起始 APP 已经有了一些本教程需要用到的功能。

打开应用程序,选择 File > Open… (或者使用 Command+O 快捷键)

image.png

在新打开的弹出窗口中选择你想打开的文件夹,点击 Open 按钮。你将在 Xcode 控制面板中看到:

Represented object: file:///Users/tutorials/FileViewer/FileViewer/ 

这条消息显示了所选择文件夹的路径,程序代码会把这个 URL 传递给视图控制器。

如果你感到好奇,想学习这些是怎么实现的,你应该看看下面这些:

  • Directory.swift:包含了 Directory 结构体的实现,用来读取文件夹中的内容。
  • WindowController.swift:包含了呈现选择文件夹面板的代码,并将选择的文件夹传递个 ViewController
  • ViewController.swift):包含了 ViewController 的实现,这里是你要话费时间的地方,这也是你创建表视图和显示文件列表的地方。

创建表视图

打开故事板,选择 View Controller Scene,从对象库里拖一个 table view 到视图里。在视图层级里有一个叫 Table Container 的容器。

image.png

下一步需要添加一些约束。点击自动布局工具条上的 Pin 按钮。在弹出窗口中选择边缘约束如下:

  • Top, Bottom, Leading and Trailing:0


    image.png

确保 Update Frames 设置为 Items of New Constraints,点击 Add 4 Constraints
看一下新创建的表视图的机构。就像名称显示的一样,它通常有下面这样的机构:

  • 它由行和列构成
  • 每一行代表数据模型集合中的一个条目
  • 每一列代表数据模型中的一个属性
  • 每一写都有一个表头
  • 表头对数据列进行描述
image.png

如果你对 iOS 中的 UITableView 属性,那你算是踏进了熟悉的水域了,不过 macOS 里更深。实际上,你可能对构造一个 NSTableView 时对象层级里需要构造的 UI 对象的数量感到吃惊。

UITableView 是一个比 UITableView 更老更复杂的控件。它提供了不同的用户接口范式,特别是用户使用鼠标可触控板时。

UITableView 的主要不同是,它有多列的可能,表头可用来和有何进行交互,比如选择和排序。

同时 NSScrollViewNSClipView 分别用来滚动和剪裁 NSTableView 的内容。有两个 NSScroller 分别用于垂直和水平滚动。还有许多列对象。NSTableView** 拥有许多列对象,这些列都含有标题头部。用户可以调整列的大小和尺寸很重要,尽管你可以将其设置为默认禁用。

解剖 NSTableView

Interface Builder 里,你已经看到了表视图层级的复杂性了。多个类配合起来构建表视图。最终看起来像这样:

image.png

这是 NSTableView 的关键部分:

  • Header View:头部视图是 NSTableHeaderView 类的实例。它负责在表格的顶部画表头。如果你需要自定义的头部,可以子类化这个类。
  • Row View:行视图显示与表中每一行相关的可视属性,例如选择高亮。显示在表中的每一行都有自己的 Row view 实例。一个重要的区别是行不代表你的数据,那是 Cell 的职责。它只处理可视属性,比如选择颜色和分割符。你可以创建新的行子类来定义不同的表格样式。
  • Cell Views:Cell 可能是表视图中最重要的对象。在行和列的交叉点可以找到 Cell,每一个 Cell 都是 NSView 或者 NSTableCellView。它的作用是显示最终的数据。你可以创建自定义的 Cell 类来按自己喜欢的方式显示内容。
  • Column:列被 NSTableViewColumn 类表示,它的任务是管理列的宽度和行为,比如调整大小和位置。这不是一个视图,而是一个控件。你用它来指定列的行为,但是不能控制视觉样式,因为表头、行、单元视图已经负责这些了。

注意:有两种模式的 NSTableView。第一种是基于 cell 的,叫 NSCell。它像 NSView,但是更年久更轻量。它是在桌面系统需要以最小花销绘制控件时出现的。
苹果建议使用基于 View 的表视图,但是在 AppKit 中你将看到很多 NSCell,所以,它是什么,它是怎么出现的值得看一下。你可以在苹果的 Control and Cell Programming Topics 了解更多关于 NSCell

以上是表视图结构背后的基本原理。现在知道了这些,是时候回到 Xcode 在自己的表视图上做些事情了。

玩弄表视图的列

Interface Builder 创建表视图时默认是两列,但是你需要三列来显示文件的名称、日期和大小。

回到 Main.storyboard
选择 View Controller Scene 里的表视图。确保你选的是表视图,而不是包含它的滚动视图:

image.png

打开属性检查器。将 Columns 改为 3。就这么简单,现在你的表视图有 3 列了。

下一步,选择 Selection 部分的 Multiple 复选框,因为你希望一次选择多个文件。同样选择 Highlight 部分的 Alternating Rows 复选框。启用这一项,是用来告诉表视图使用交替的行背景颜色,就像 Finder 中一样。

image.png

从命名行的表头,使文本更具有描述性。选择 View Controller Scene 中的第一列。

image.png

打开属性检查器,将列的 Title 改为 Name。

image.png

重复相同的操作,将第二列、第三列的标题分别改为 Modification Date 和 Size。

注意:改变列的标题有一种替代的方法。你可以双击表头让它变成可编辑。两种方法最终结果是一样的,所以随便选一种你喜欢的。

最后,如果你没有看到尺寸列,选择 Modification Date 列,大小改为 200。

image.png

编译运行,你应该看到下面这样:

image.png

改变信息的表现方式

在当前状态下,Table View 有三列,每一个列都包含一个 Cell View,其中有一个文本框用来显示文本。

它有点单调,给它增加点趣味,在文件名的旁边显示文件的图标。这一点提升之后,表格看起来更干净了。

你需要用一个包含图片和文本框的新的 Cell 类型来替换第一列中的 Cell。很幸运,Interface Builder 提供了一个这样的内置 Cell 类型。

选择 Name 列中的 Table Cell View 删除它。

image.png

打开对象库,拖动一个 ** Image & Text Table Cell View into** 到表视图的第一个列里面,或者拖到 *View Controller Scene tree 里面,刚好放到 Name 列下面。

image.png

现在东西快要成型了!

赋予标识符(Assigning Identifiers)

每一个 Cell 类型都需要一个标示符。否则编写代码时,你不能创建一个关联到指定列的 Cell 视图。

选择第一列中的 cell view,在 Identity Inspector 里将 Identifier 改为 NameCellID

image.png

用同样的方法将第二个、第三个的标识符分别设置为DateCellIDSizeCellID

填充表格

注意:你可以用两种方法来填充表格。一种是使用数据源和委托协议,这是你将在本教程中看到的方法;或者使用 Cocoa bindings。这两种方法不是互斥的,有时你会同时使用它们来得到你想要的。

表视图现在还不知道任何你想要显示的数据以及怎么显示它。你将实现这两个协议来提供这些信息:

  • NSTableViewDataSource:告诉 Table View 它需要呈现多少行。
  • NSTableViewDelegate: 提供用来显示指定行列的 Cell View。
image.png

可视化的过程是表视图、委托对象和数据源之间的合作。

在辅助编辑器中打开 **ViewController.swift **,按住 CTRL 键重 Table View 拖到 **ViewController **创建一个 outlet。

image.png

确保类型是 NSTableView,**Connection **是 outlet,命名为 tableView。

image.png

现在你可以在代码中用 outlet 来引用 Table View 了。

切换到标准编辑器,打开 ViewController.swift。实现需要的数据源方法 ViewController,在类的末尾添加以下代码:

extension ViewController: NSTableViewDataSource {
  
  func numberOfRows(in tableView: NSTableView) -> Int {
    return directoryItems?.count ?? 0
  }

}

它创建了一个遵循 NSTableViewDataSource协议的一个扩展,实现了所需的 numberOfRows(in:) 方法来返回文件将中的文件数目,也就是 directoryItems 数组的大小。

现在来实现委托,将以下代码添加到 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) 方法。它将被调用来获取每一行每一列合适的 cell 类型。

这个方法内容很多,让我们一步一步分解:

  1. 如果没有数据显示,不返回 Cell。
  2. 根据列显示的位置 (Name, Date or Size),设置它们的标识符、文本和图片。
  3. 通过调用 make(withIdentifier:owner:) 获取一个 Cell view。这个方法创建或者重用一个带有那个标识符的 Cell。然后用上一步的信息填充它并返回。

下一步,在 viewDidLoad 里添加以下代码:

tableView.delegate = self
tableView.dataSource = self

这里告诉表视图委托对象和数据源是 view controller。

最后一步是当新的文件夹被选择时更新表视图。

首先在 ViewController 里添加下面的方法:

func reloadFileList() {
  directoryItems = directory?.contentsOrderedBy(sortOrder, ascending: sortAscending)
  tableView.reloadData()
}

这个帮助方法刷新文件列表。

首先它调用 directorycontentsOrderedBy(_:ascending) 方法,返回排好序的文件数组。然后它调用表视图的 reloadData 方法刷新它。

注意,你只需要在新的文件夹被选择时调用它。

representedObject 的观察器 didSet 里替换这行代码:

print("Represented object: \(url)")

用这行替换:

directory = Directory(folderURL: url)
reloadFileList()

你刚创建了一个指向文件夹 URL 的 Directory 实例,然后它调用 reloadFileList() 方法刷新表视图的数据。

编译运行。

使用菜单 File > Open…Command+O 快捷键打开一个文件夹,然后魔法发生了。现在表中填满了你选择的文件夹的内容。调整列的大小查看文件和文件夹的所有信息。

image.png

做的好!

表视图交互

在这一节,你将做一些有关交互的工作来增强有 UI。

响应用户选择

当用户选择一个或多个文件时,应用程序应该更新底部的信息来显示文件夹中的文件数和选择了多少。

为了让 Table View 选择项改变时能被通知,需要在委托对象里实现 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
}

这个方法根据用户选择更新状态栏标签。

  1. Table View 的 selectedRowIndexes属性包含了被选择的条目索引。要知道选择了多少,只需要获取数组的数目就可以了。

  2. 基于条目数量生成信息字符串。

  3. 设置状态标签。

现在你只需要在用户选择时调用该方法就可以了。在 Table View 的委托扩展中添加以下代码:

func tableViewSelectionDidChange(_ notification: Notification) {
  updateStatus()
}

当选择改变时,Table View 调用该方法,然后更新状态栏。

编译运行。

image.png

自己试一试。选择一个或多个文件,观看状态栏改变信息文本来反映你的选择。

响应双击

在 macOS 中,双击通常意味着触发了一个操作,你的程序需要执行它。

例如,当你在处理文件时,你期望双击文件时打开它的默认应用程序,对于文件夹,你希望查看它的内容。

现在你将要实现双击的响应。

双击消息不是由 Table View 的委托对象发出的。相反,它们是作为一个操作传到 Table View 的目标 target。为了在 view controller 里接受这些消息,需要设置表视图的 targetdoubleAction 属性。

注意:在 Cocoa 里,Target-action 是一种大部分控件用来通知事件的模式。如果你对它不熟悉,你可以在苹果的 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)
  }
}

这是以上代码的一步一步分解:

  1. 如果 Table View 的选择时空的,它上面也不做就返回。同时注意,在 Table View 的空白区域双击会导致 tableView.selectedRow 返回 -1。

  2. 如果它是一个文件夹,representedObject 属性的值被设置为条目的 URL。然后 Table View 刷新显示文件夹的内容。

  3. 如果它是一个文件,通过调用 NSWorkspaceopenURL 方法在默认应用程序里打开它。

编译运行,看看你的手艺!

在任意文件上双击,看看它是怎么打开的。然后双击一个文件夹,看看 Table View 怎么刷新和显示文件夹内容的。

哇哦!等等,你刚刚是不是做了一个 Finder 的 DIY 版本?肯定是!

排序数据

任何人都喜欢好的排序,在下一节你将学习怎样根据用户的选择对 Table View 排序。

表的最好的特色之一是双击或单击一个特定的列来排序。点击将按升序排序,再次单击将按降序排序。

实现这个特殊的 UI 很容易,因为 NSTableView将大部分功能打包好了。

Sort descriptors 是你用来处理这一点的东西,它只是简单的指定了所需属性和排序顺序的 NSSortDescriptor 类的一个实例。

设置好描述符(descriptors)后,会发生这些:当在表头点击时将通过委托对象通知你那个属性将用来排序数据。

一旦你设置了 sort descriptors,Table View 将提供所有的 UI 来处理排序,例如可点击的表头、箭头和被选择的描述符的通知。但是根据信息来排序和刷新表格来响应排序是你自己的责任。

现在你将学习怎么做这些!

image.png

viewDidLoad 里添加以下代码来创建排序描述符 (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

这些代码做了这些事:

  1. 为每一列创建了一个排序描述符,(Name, Date or Size) 表明了可以根据哪一个属性来排序文件列表。

  2. 通过设置 sortDescriptorPrototype属性为每一列添加排序描述符。

当用在任意一列的表头点击时,Table View 将调用数据源的 tableView(_:sortDescriptorsDidChange:) 方法,在这个方法里将根据提供的描述符来排序数据。

在数据源扩展中添加以下代码:

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()
  }
}

这些代码完成以下事情:

  1. 获取用户点击的表头相关的第一个排序描述符。
  2. 为视同控制器的 sortOrder 和 sortAscending 属性赋值,然后调用 reloadFileList() 方法。你早些时候创建了这个方法来获得一个排序的文件数组,并通知表视图更新数据。

编译运行.

image.png

点击任意表头看你的表视图排序,再次点击同一个表头让它在升序和降序之间切换。

你用 Table View 创建了一个很棒的文件浏览器。祝贺你!

推荐阅读更多精彩内容