原文地址 翻译:DeveloperLx
更新于2016年9月30日: 本教程已适配Xcode 8及Swift 3。
当编写app的时候,你会经常想以一个类似列表的结构来展示数据。例如,你想要展示一个食谱的列表,这可以很容易地使用
NSTableView
完成。但如果你想依据开胃菜或主要成分对食谱进行分组呢?现在你遇到了问题,因为table view不能进行分组。上图展示了设想中的分级食谱,只可惜我们还不会做!
幸好,
NSOutlineView
提供了更多的功能。
NSOutlineView
是macOS中的一个常用组件,且它是
NSTableView
的子类。和table view一样,它也用行和列来展示内容;有所不同的是,它使用分层的数据结构。
来实际地看一下outline view吧,打开一个已存在的工程,查看你的project navigator。项目名称的旁边有一个小三角形,你可以用它来将项目展开。如下图所示:在项目的下发就是分组,各组的内部则是Swift或Objective-C文件。
在本教程中,你将学到如何使用outline view来展示你分级的数据。你将会编写一个RSS阅读器 - 从文件中加载出RSS的信息流,并将其展示到outline view上。
从 这里 下载起始的项目。打开项目来看一下吧。除了由模板创建的文件外,还有一个 Feeds.plist ,你将从这里加载信息流。你需要在之后创建model类的时候进一步查看这个文件。
打开 Main.storyboard 来查看预备好的UI。左边是一个普通的outline view,旁边则是一个空白的区域,它是一个web view。上述内容使用了一个水平布局的stack view,它被固定到窗口的边缘。Stack view是处理自动布局最新和最后的方式,如果你到现在还未尝试过,你可以在 这里 进行学习。
你的第一个任务就是完成UI。双击header,来将第一列的标题修改为 Feed ;第二列的标题则修改为 Date 。
非常得容易!现在在document outline中选择outline view - 你可以在 Bordered Scroll View – Outline View / Clip View / Outline View 下找到它。在 Attributes Inspector 中,将 Indentation 修改为5,打开 Floats Group Rows 并关闭 Reordering 。
在左侧的document outline中,点击 Outline View 旁边的三角形来展开它。为 Feed 和 Date 执行同样的操作。选择 Date 下的Table View Cell。
在 Identity Inspector 中将 Identifier 修改为 DateCell 。
现在切换到 Size Inspector 并将 Width 修改为102。为Feed下的cell重复同样的步骤,将 Identifier 修改为 FeedCell , Width 修改为。
展开feed下的cell并选择名为 Table View Cell 的text field。
使用自动布局工具栏中的Pin和Align菜单,来添加一个2点leading的约束,以及另一个将text field垂直居中的约束。你可以在 Size Inspector 中查看被添加的约束:
现在再次选择table cell(就在布局层级中text field的上方)。按下 Cmd + C 和 Cmd + V 键对它进行复制,然后将副本的 Identifier 修改为 FeedItemCell 。现在你就有了3个不同的cell,每个类型的cell都会被展示在outline view中。
选择 Date ,并在 Identity Inspector 中将Identifier修改为 DateColumn ;为 Feed 执行相同的操作将Identifier修改为 TitleColumn :
最后的一步是给outline view设置一个delegate和data source。选择outline view并右击或按住control点击它,从 dataSource 拖拽一个到代表你的view controller的 蓝色圆圈 上;重复类似的步骤来设置delegate。
运行项目,你将会看到...
只有一个空空的outline view,和你控制台中的错误信息,说你的data source是非法的。What’s wrong?
在你填充outline view并解除错误信息之前,你需要一个data model。
outline view的数据结构和table view中的有所不同。就像在介绍中提到的,outline view展示的是分层级的数据模型,你的模型类必须能够代表这个层级。每个层级都要有一个顶层或根对象。这里,它就RSS的信息流;feed的名称就是根。
按 Cmd + N 键来创建一个新的类。在 macOS 部分中选择 Cocoa Class 并点击 Next 。
将这个类命名为
Feed
,且让它成为
NSObject
的子类。然后点击
Next
,下一页中则点击
Create
。
将自动生成的代码替换为:
import Cocoa
class Feed: NSObject {
let name: String
init(name: String) {
self.name = name
}
}
上述代码为你的类添加了一个名为
name
的property,以及一个方便设备此property的初始化分发。你的类会将它的“孩子”储存到一个数组中,但在你这么做之前,你需要创建一个类作为这个“孩子”。和之前的步骤一样,添加一个新名为
FeedItem
的类。打开新建的
FeedItem.swift
文件,并将其内容替换为:
import Cocoa
class FeedItem: NSObject {
let url: String
let title: String
let publishingDate: Date
init(dictionary: NSDictionary) {
self.url = dictionary.object(forKey: "url") as! String
self.title = dictionary.object(forKey: "title") as! String
self.publishingDate = dictionary.object(forKey: "date") as! Date
}
}
这是另一个简答的模型类:
FeedItem
,它包含一个
url
用来储存将加载到web view的网页的地址,一个
title
,及一个
publishingDate
。它的构造器使用一个字典作为它的参数。它既可以从网络服务器中接收内容,也可以像本例中一样,从plist文件中获取。
找到
Feed.swift
并添加下列的property到
Feed
中:
var children = FeedItem
这样就创建了一个空数组来储存 FeedItem 对象。
现在添加下列的类方法到
Feed
中来加载plist文件:
class func feedList(_ fileName: String) -> [Feed] {
//1
var feeds = Feed
//2
if let feedList = NSArray(contentsOfFile: fileName) as? [NSDictionary] {
//3
for feedItems in feedList {
//4
let feed = Feed(name: feedItems.object(forKey: "name") as! String)
//5
let items = feedItems.object(forKey: "items") as! [NSDictionary]
//6
for dict in items {
//7
let item = FeedItem(dictionary: dict)
feed.children.append(item)
}
//8
feeds.append(feed)
}
}
//9
return feeds
}
这个方法以一个文件名作为它的参数,然后返回了一个由
Feed
对象组成的数组。上述代码:
-
创建了一个空的
Feed
数组。 - 尝试从文件中加载一个字典的数组。
- 如果加载成功的话,遍历数组中的每一个项目。
-
这个字典包含一个键
name
,用来初始化
Feed
。 - 键 items 则包含了另一个字典的数组。
- 遍历字典。
-
初始化一个
FeedItem
。这个item被添加到了父Feed
的children
数组中。 -
循环完成后,在
Feed
开始加载之前,Feed
的每个child都被添加到了feeds
的数组中。 -
返回
feeds
。如果每件事都如同期望中的工作方式,这个数组将包含两个Feed
对象。
打开 ViewController.swift ,并在IBOutlet部分的下面添加一个property来储存feeds:
var feeds = Feed
找到
viewDidLoad()
并添加下列的代码:
if let filePath = Bundle.main.path(forResource: "Feeds", ofType: "plist") {
feeds = Feed.feedList(filePath)
print(feeds)
}
运行项目;你应当会在控制台中类似如下的内容:
[<Reader.Feed: 0x600000045010>, <Reader.Feed: 0x6000000450d0>]
可以看到你已经成功地加载了两个
Feed
对象到
feeds
property中 — 哇!
到目前为止,你已告知了outline view
ViewController
是它的data source — 但
ViewController
至今不知道如何完成它的新工作。现在是时候来改变这点去解决错误信息了。
在你的
ViewController
的类声明下添加如下的
extension
:
extension ViewController: NSOutlineViewDataSource {
}
这就让
ViewController
采取了
NSOutlineViewDataSource
协议。由于在本教程中,我们不会使用binding,因此你必须实现几个方法来填充outline view。让我们来看看每个方法。
你的outline view需要知道该展示多少个item。因此,使用方法
outlineView(_: numberOfChildrenOfItem:) -> Int
。
func outlineView(_ outlineView: NSOutlineView, numberOfChildrenOfItem item: Any?) -> Int {
//1
if let feed = item as? Feed {
return feed.children.count
}
//2
return feeds.count
}
展示在outline view中的每个层级都会调用这个方法。由于你的outline view中只有两个层级,因此这个方法的实现相当得简单:
-
如果
item
是一个Feed
,就返回children
的数量。 -
否则,返回
feeds
的数量。
值得注意的是:
item
是可选类型,对于你data model的根对象,它就是
nil
。在本例中,它对于
Feed
就会返回
nil
;否则它就会包含对象的父对象。对于
FeedItem
对象,
item
就会是一个
Feed
。
继续!outline view需要知道对于给定的parent和index,应该展示那个child。这里的代码和之前的很类似:
func outlineView(_ outlineView: NSOutlineView, child index: Int, ofItem item: Any?) -> Any {
if let feed = item as? Feed {
return feed.children[index]
}
return feeds[index]
}
首先检查
item
是否是一个
Feed
;如果是的话,就为给定的index返回相应的
FeedItem
。否则,就返回
Feed
。同样,对于根对象,
item
将是
nil
。
NSOutlineView
的一个很棒的特性就是他可以折叠item。然而,你必须首先告诉它那些item可以折叠或是展开。添加下列的代码:
func outlineView(_ outlineView: NSOutlineView, isItemExpandable item: Any) -> Bool {
if let feed = item as? Feed {
return feed.children.count > 0
}
return false
}
在本app中,只有
Feeds
可以展开或收起,因为只有它有children。因此首先检查
item
是否是
Feed
,如果是的话,就判断它的child数量是否大于0,大于的话就返回true,否则返回false。对于其它的item,都返回false。
运行你的app。万岁!错误的消息已经消失了,outline view已经被填充好了。但是稍等 - 你现在只能看到2个小三角形,来指示你可以展开这一行。如果你点击它的话,就可以看到更多的行。
是你做错了什么吗?不是 - 你只需要在添加一个方法。
outline view会询问它的delegate,该为每个特定的条目展示什么样的view。然而,你至今还没有添加任何delegate方法的实现 - 现在就是该去添加的时候了。
在
ViewController.swift
中添加另一个
ViewController
的extension:
extension ViewController: NSOutlineViewDelegate {
}
下面的这个方法会有一点复杂,因为outline view应当为每个
Feed
和
FeedItems
展示不同的view。让我们一块一块地说。
首先,添加方法体到extension中。
func outlineView(_ outlineView: NSOutlineView, viewFor tableColumn: NSTableColumn?, item: Any) -> NSView? {
var view: NSTableCellView?
// More code here
return view
}
现在对于每个
item
我们都返回了nil。下一步你要为
Feed
来返回view。在
// More code here
注释的上方添加如下的代码:
//1
if let feed = item as? Feed {
//2
view = outlineView.make(withIdentifier: "FeedCell", owner: self) as? NSTableCellView
if let textField = view?.textField {
//3
textField.stringValue = feed.name
textField.sizeToFit()
}
}
上述代码:
-
检查
item
是否是Feed
。 -
从outline view中获取一个对应于
Feed
的view,包含一个text field的正常的NSTableViewCell
。 -
设置text field中的文本为feed的名称并调用
sizeToFit()
,来重新计算它的frame以适应它内容的大小。
运行你的项目。你现在可以看到
Feed
的cell,但展开的行中仍然看不到任何内容。
这是因为你现在仅仅提供了代表
Feed
的view。继续前进到下一步吧!依然在
ViewController.swift
中,在
feeds
这个property下添加下列的property:
let dateFormatter = DateFormatter()
在
viewDidLoad()
中
super.viewDidLoad()
后添加下列的代码:
dateFormatter.dateStyle = .short
上述代码添加了一个
NSDateformatter
,用来依据
FeedItem
中的
publishingDate
创建一个很好的格式化日期。
回到
outlineView(_:viewForTableColumn:item:)
并添加一个
else-if
子句
if let feed = item as? Feed
:
else if let feedItem = item as? FeedItem {
//1
if tableColumn?.identifier == "DateColumn" {
//2
view = outlineView.make(withIdentifier: "DateCell", owner: self) as? NSTableCellView
if let textField = view?.textField {
//3
textField.stringValue = dateFormatter.string(from: feedItem.publishingDate)
textField.sizeToFit()
}
} else {
//4
view = outlineView.make(withIdentifier: "FeedItemCell", owner: self) as? NSTableCellView
if let textField = view?.textField {
//5
textField.stringValue = feedItem.title
textField.sizeToFit()
}
}
}
上述代码:
-
如果
item
是一个FeedItem
,你就需要填充两列:一列是title
,另一列是publishingDate
。你可以使用它们的identifier
来区分列。 -
如果
identifier
是 dateColumn ,就请求一个DateCell。 -
使用date formatter来根据
publishingDate
创建一个字符串。 -
如果不是
dateColumn
的话,你就需要一个对应于
FeedItem
的cell。 -
将文本设置为
FeedItem
的title
。
再次运行你的项目,相应的单元格已被正确地填充。
还剩一个问题 - 相应于
Feed
的date这列展示了一个静态的文本。要修复这里,可将if语句
if let feed = item as? Feed
的内容修改为:
if tableColumn?.identifier == "DateColumn" {
view = outlineView.make(withIdentifier: "DateCell", owner: self) as? NSTableCellView
if let textField = view?.textField {
textField.stringValue = ""
textField.sizeToFit()
}
} else {
view = outlineView.make(withIdentifier: "FeedCell", owner: self) as? NSTableCellView
if let textField = view?.textField {
textField.stringValue = feed.name
textField.sizeToFit()
}
}
要完成这app,你还需要在选择一个条目后,在web view中展示出相应的文字。你应该怎么做?幸运的是,下列的方法可以跟踪outline view选择的变化。
func outlineViewSelectionDidChange(_ notification: Notification) {
//1
guard let outlineView = notification.object as? NSOutlineView else {
return
}
//2
let selectedIndex = outlineView.selectedRow
if let feedItem = outlineView.item(atRow: selectedIndex) as? FeedItem {
//3
let url = URL(string: feedItem.url)
//4
if let url = url {
//5
self.webView.mainFrame.load(URLRequest(url: url))
}
}
}
上述代码:
- 检查通知的对象是否是NSOutlineView。如果不是的话,退出方法。
-
获取被选择的序号,并检查被选择的行是否包含
FeedItem
或Feed
。 -
如果选中的是
FeedItem
,就根据Feed
对象的url
property创建一个NSURL
。 - 检查url是否创建成功。
- 最后,加载页面。
在你进行测试之前,返回 Info.plist 文件。添加一项 App Transport Security Settings 的 Dictionary (如果Xcode中还没有的情况下)。在其中添加一项 Allow Arbitrary Loads ,类型为 Boolean ,值为 YES 。
注意: 添加此条目到你的plist文件中,会使你的app能够接受任何不安全的主机,可能会造成一定的风险。通常添加 Exception Domains 为某个接口,而对其它接口则使用加密连接的后端,会更好一些。
现在运行项目,并选择一个
FeedItem
。如果你的网路已连接,对应的文章就会在几秒中之后被加载出来。
你的示例app已经可以正常地工作了,但现在至少还有两种行为是缺失的:双击来展开或收起一个组,以及从outline view中移除一个组。
让我们从双击的特性开始。按 Alt + Cmd + Enter 键打开 Assistant Editor 。在窗口的左边打开 Main.storyboard ,右边打开 ViewController.swift 。
双击左边 Document Outline 中的outline view。在弹出的菜单中,找到 doubleAction 并点击它右侧的小圆圈。
拖拽小圆圈到
ViewController.swift
中,添加一个名为
doubleClickedItem
的
IBAction
。确保sender的类型为
NSOutlineView
而不是
AnyObject
。
切回到Standard editor中(按 Cmd + Enter 键),打开 ViewController.swift 。添加下列代码到你刚创建的动作方法中。
@IBAction func doubleClickedItem(_ sender: NSOutlineView) {
//1
let item = sender.item(atRow: sender.clickedRow)
//2
if item is Feed {
//3
if sender.isItemExpanded(item) {
sender.collapseItem(item)
} else {
sender.expandItem(item)
}
}
}
上述代码:
- 获取被点击的item。
-
检查这个item是否为
Feed
,只有Feed的item可以被展开或收起。 -
如果这个item是
Feed
的话,就询问outline view这个item是展开的还是收起的,然后调用恰当地方法完成操作。
运行你的项目,然后双击一个feed。成功了!
你想实现的最后一个特性,就是让用户可以按下退格键删除被选择的feed或文章。
还是在
ViewController.swift
中,为
ViewController
添加下列的方法。确保将它添加到原始的类声明中而不是extension中,因为这个方法和delegate或datasource的协议无关。
override func keyDown(with theEvent: NSEvent) {
interpretKeyEvents([theEvent])
}
这个方法会在每次某个键被按下时被调用,并询问系统是哪个键被按下了。对于一些键,系统会执行相应的动作。将被退格键调用的方法是
deleteBackward(_:)
。
添加下面的方法
keyDown(_:)
:
override func deleteBackward(_ sender: Any?) {
//1
let selectedRow = outlineView.selectedRow
if selectedRow == -1 {
return
}
//2
outlineView.beginUpdates()
outlineView.endUpdates()
}
-
第一件事是检查现在有无被选中的项。如果没有任何项被选中,
selectedRow
的值就会是-1,直接退出本方法。 - 否则,就告知outline view现在需要更新一些内容,以及什么时候会更新完毕。
现在在
beginUpdates()
和
endUpdates()
间添加下列的代码:
//3
if let item = outlineView.item(atRow: selectedRow) {
//4
if let item = item as? Feed {
//5
if let index = self.feeds.index( where: {$0.name == item.name} ) {
//6
self.feeds.remove(at: index)
//7
outlineView.removeItems(at: IndexSet(integer: selectedRow), inParent: nil, withAnimation: .slideLeft)
}
}
}
上述代码:
- 获取被选择的item。
-
检查它是否为
Feed
或FeedItem
。 -
如果是
Feed
的话,就在feeds
数组中查找它的序号。 - 找到后,从数组中移除它。
- 从outline view中移除相应的行,要带有一个小小的动画。
为了完成这个方法,添加下列的代码来处理
FeedItems
,将它作为
if let item = item as? Feed
的else部分:
else if let item = item as? FeedItem {
//8
for feed in self.feeds {
//9
if let index = feed.children.index( where: {$0.title == item.title} ) {
feed.children.remove(at: index)
outlineView.removeItems(at: IndexSet(integer: index), inParent: feed, withAnimation: .slideLeft)
}
}
}
-
这里的代码非常类似于
Feed
。唯一额外的步骤是它会迭代所有的feed,因为你不知道这个FeedItem
属于哪个Feed
。 -
对于每个
Feed
,检查它的children
数组中是否可以找到FeedItem
。如果可以的话,就将它从数组和outline view中删除。
注意:
不仅你可以删除一行,但你还可以添加和移动行。步骤是相同的:添加一个item到你的data model中并调用
insertItemsAtIndexes(_:, inParent:, withAnimation:)
来插入item,或通过
moveItemAtIndex(_:, inParent:, toIndex:, inParent:)
来移动item。确保你的datasource做出相应的改变。
现在你的app全部完成了!运行项目来测试你刚添加的新功能。选择一个feed的item并按下删除键 - 它会如你期望中的一样消失掉。测试对于feed也会发生同样的动作。
恭喜!你已创建好了一个RSS Feed的阅读器 - 一个带有层级功能的app,它允许用户可以删除行,还可以双击来展开或收起列表。
你可以在 这里 下载最终完成后的项目。
在本教程中,你学到了大量有关
NSOutlineView
的内容:
-
如何与Interface Builder中的
NSOutlineView
进行交互。 - 如何填充outline view中的数据。
- 如何展开/收起item。
- 如何移除条目。
- 如何响应用户的交互。
还有很多未能覆盖到的功能,如拖拽深层级结构的data model。因此,如果你想要了解更多关于
NSOutlineView
的内容,可以去查阅
官方文档
。由于NSOutlineView是
NSTableView
的子类,Ernesto García的教程
table views
同样值得一看。