macOS NSTableView 教程

本文翻译自 macOS NSTableView Tutorial


本篇 macOS NSTableView 教程已经更新到 Xcode 8 和 Swift 3。

用 table view 创建酷炫的用户界面!
用 table view 创建酷炫的用户界面!

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 按钮。在弹出的窗口里,如下设置边缘约束:

  • TopBottomLeadingTrailing:0。

确保将 Update Frames 设置为 Items of New Constraints,然后点击 Add 4 Constraints

看看新创建的 table view 的结构。人如其名,它是典型的表格结构:

  • 由行和列组成。
  • 每行代表数据模型集中的单个项目。
  • 每列表示模型的某个属性。
  • 每列可以有标题行。
  • 标题行描述列中的数据。

如果你熟悉 iOS 上的 UITableView,那你会对它也感到熟悉,但在 macOS 上会深入很多。实际上,你可能会对组成 NSTableView 的对象层次结构中的不同 UI 对象的数目感到吃惊。

NSTableViewUITableView 相比,是更老、更复杂的控件,支持不同的用户使用情境,具体比如说用户在用鼠标或触摸板的时候。

UITableView 的主要区别是,它可能有多个列,以及一个可用于与 table view 交互的标题行,比如排序和选择。

一起的还有 NSScrollViewNSClipView ,它们分别表示滚动和剪切 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 DateSize

注意:更改列标题还有另一个方法。可以在 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

对第二列和第三列中的单元格视图重复相同的过程,将标识符分别命名为 DateCellIDSizeCellID

填充 Table View

注意:有两种方式可以填充 tableview,本教程中使用 datasource 和 delegate 协议的方式,也可以通过 Cocoa bindings 。这两种技术不是相互排斥的,如果喜欢的话可以一起使用。

Table view 目前根本不知道你想要显示什么数据、或是如何显示数据,但这确实需要规划一下!所以,我们会实现这两个协议来提供所需的信息:

  • NSTableViewDataSource:告诉 table view 它需要显示多少行。
  • NSTableViewDelegate:提供对应行列显示的单元格视图。

可视化过程就是 table view、delegate 和 data source 之间的配合。

  1. Table view 调用 data source 方法 numberOfRows(in:),它返回了表格将会显示的行数。
  2. Table view 为每个行列调用 delegate 方法 tableView(_:viewFor:row:)。delegate 创建相应位置的视图,用正确的数据填充它,然后返回给 table view。

这两个方法必须按顺序实现以在 table view 中显示数据。

Assistant editor 中打开 ViewController.swift,然后按住 Control,把 table view 拖到 ViewController 类实现中以添加一个 outlet。

确定 TypeNSTableViewConnectionOutlet。将 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 就会在每个行列都调用它以得到正确的单元格。

这个方法有很多内容,一步步拆开看:

  1. 如果没有数据要显示,就不要返回单元格。
  2. 基于列(Name、Date 或 Size)设置标识符、文字和图片。
  3. 调用 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。

  1. Table view 属性 selectedRowIndexes 包含了选中行的索引。要了解选中了多少项目,只需要获取数组 count 即可。
  2. 根据项目的数量,构建了信息化文本字符串。
  3. 设置 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 的 targetdoubleAction 属性。

注意:Target-action 是 Cocoa 中大多数控件通知事件使用的模式。如果你不熟悉这个模式,可以在苹果的 Cocoa Application Competencies for macOS 文档里的 Target-Action 部分中进行学习。

ViewControllerviewDidLoad() 中添加如下代码:

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. 如果项目是文件,就在默认程序中打开它,调用 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

这段代码做了这些事情:

  1. 为每列都创建了一个 sort descriptor,带有一个键(Name,Date 或 Size),该键指明可以对文件列表进行排序的属性。
  2. 设置 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()
  }
}

这段代码做了下面的事情:

  1. 检索用户点击的列标题相应的 sort descriptor。
  2. 分配视图控制器的 sortOrdersortAscending 属性,然后调用 reloadFileList()。刚刚设置它们以获取排序的文件数组,然后告诉 table view 要重载数据。

构建并运行。

随便单击一个标题,看看 table view 排序数据。在同一个标题再点一次,在升序和降序之间切换。

我们已经使用 table view 构建了一个好看的文件浏览器。好棒棒!

下一步?

可以在 这里 下载完整的项目。

这篇 macOS NSTableView 教程涉及了相当多内容,现在你应该对使用 table view 组织数据更有信心了。此外,我们还学习了:

  • table view 构建的基础知识,包括头、行、列和单元格。
  • 如何添加列以显示更多数据。
  • 如何识别不同的组件以供引用。
  • 如何在表格中加载数据。
  • 如何响应不同的用户交互。

还可以用 table view 做很多事情来为 app 构建优雅的 UI。如果你想学习,可以参考下面的资源:

如果对于本教程有任何问题或评论,随意在下方发表!

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,271评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,275评论 2 380
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,151评论 0 336
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,550评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,553评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,559评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,924评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,580评论 0 257
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,826评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,578评论 2 320
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,661评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,363评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,940评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,926评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,156评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,872评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,391评论 2 342

推荐阅读更多精彩内容

  • 原文 表视图是 macOS 应用程序中最普遍的控件之一,熟悉的例子如 Mail 的消息列表和 Spotlight ...
    z_k阅读 6,634评论 2 5
  • 翻译自“Collection View Programming Guide for iOS” 0 关于iOS集合视...
    lakerszhy阅读 3,811评论 1 22
  • { 24、Sqlite数据库 1、存储大数据量,增删改查,常见管理系统:Oracle、MSSQLServer、DB...
    CYC666阅读 926评论 0 1
  • 这是我和Kris秘密同居的第三个年头。其实也不是什么秘密,只是大家闭口不谈,怕这事情谈谈就变了味道。 ...
    长胖的小白阅读 113评论 0 0
  • 清晨 你是那么晶莹光亮 那么美丽动人 野花羡慕你 小草羡慕你 泥土羡慕你 轻轻一转身 天上的星星也笑了 因为你的光辉
    青云无言阅读 190评论 0 0