Skip to content

Latest commit

 

History

History
1670 lines (1654 loc) · 61.3 KB

NSOutlineView on macOS Tutorial.md

File metadata and controls

1670 lines (1654 loc) · 61.3 KB

macOS教程:NSOutlineView

更新于2016年9月30日: 本教程已适配Xcode 8及Swift 3。

NSOutlineView-feature

当编写app的时候,你会经常想以一个类似列表的结构来展示数据。例如,你想要展示一个食谱的列表,这可以很容易地使用 NSTableView 完成。但如果你想依据开胃菜或主要成分对食谱进行分组呢?现在你遇到了问题,因为table view不能进行分组。上图展示了设想中的分级食谱,只可惜我们还不会做!

幸好, NSOutlineView 提供了更多的功能。 NSOutlineView 是macOS中的一个常用组件,且它是 NSTableView 的子类。和table view一样,它也用行和列来展示内容;有所不同的是,它使用分层的数据结构。

来实际地看一下outline view吧,打开一个已存在的工程,查看你的project navigator。项目名称的旁边有一个小三角形,你可以用它来将项目展开。如下图所示:在项目的下发就是分组,各组的内部则是Swift或Objective-C文件。

Xcode's Outline View

在本教程中,你将学到如何使用outline view来展示你分级的数据。你将会编写一个RSS阅读器 - 从文件中加载出RSS的信息流,并将其展示到outline view上。

入门

这里 下载起始的项目。打开项目来看一下吧。除了由模板创建的文件外,还有一个 Feeds.plist ,你将从这里加载信息流。你需要在之后创建model类的时候进一步查看这个文件。

打开 Main.storyboard 来查看预备好的UI。左边是一个普通的outline view,旁边则是一个空白的区域,它是一个web view。上述内容使用了一个水平布局的stack view,它被固定到窗口的边缘。Stack view是处理自动布局最新和最后的方式,如果你到现在还未尝试过,你可以在 这里 进行学习。

Starter UI

你的第一个任务就是完成UI。双击header,来将第一列的标题修改为 Feed ;第二列的标题则修改为 Date

Change_Header

非常得容易!现在在document outline中选择outline view - 你可以在 Bordered Scroll View – Outline View / Clip View / Outline View 下找到它。在 Attributes Inspector 中,将 Indentation 修改为5,打开 Floats Group Rows 并关闭 Reordering

nsoutline-inspector-new

在左侧的document outline中,点击 Outline View 旁边的三角形来展开它。为 Feed Date 执行同样的操作。选择 Date 下的Table View Cell。

Date_Selection

Identity Inspector 中将 Identifier 修改为 DateCell

Change_Cell_Identifier

现在切换到 Size Inspector 并将 Width 修改为102。为Feed下的cell重复同样的步骤,将 Identifier 修改为 FeedCell Width 修改为。

展开feed下的cell并选择名为 Table View Cell 的text field。

Selected_Textfield

使用自动布局工具栏中的Pin和Align菜单,来添加一个2点leading的约束,以及另一个将text field垂直居中的约束。你可以在 Size Inspector 中查看被添加的约束:

Constraints

现在再次选择table cell(就在布局层级中text field的上方)。按下 Cmd + C Cmd + V 键对它进行复制,然后将副本的 Identifier 修改为 FeedItemCell 。现在你就有了3个不同的cell,每个类型的cell都会被展示在outline view中。

FinishedCells

选择 Date ,并在 Identity Inspector 中将Identifier修改为 DateColumn ;为 Feed 执行相同的操作将Identifier修改为 TitleColumn

TitleColumn

最后的一步是给outline view设置一个delegate和data source。选择outline view并右击或按住control点击它,从 dataSource 拖拽一个到代表你的view controller的 蓝色圆圈 上;重复类似的步骤来设置delegate。

Add_Delegate

运行项目,你将会看到...

First_Run

只有一个空空的outline view,和你控制台中的错误信息,说你的data source是非法的。What’s wrong?

在你填充outline view并解除错误信息之前,你需要一个data model。

Data Model

outline view的数据结构和table view中的有所不同。就像在介绍中提到的,outline view展示的是分层级的数据模型,你的模型类必须能够代表这个层级。每个层级都要有一个顶层或根对象。这里,它就RSS的信息流;feed的名称就是根。

Cmd + N 键来创建一个新的类。在 macOS 部分中选择 Cocoa Class 并点击 Next

New_File_Template

将这个类命名为 Feed ,且让它成为 NSObject 的子类。然后点击 Next ,下一页中则点击 Create

Create_Feed_Class_2

将自动生成的代码替换为:

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 对象组成的数组。上述代码:

  1. 创建了一个空的 Feed 数组。
  2. 尝试从文件中加载一个字典的数组。
  3. 如果加载成功的话,遍历数组中的每一个项目。
  4. 这个字典包含一个键 name ,用来初始化 Feed
  5. items 则包含了另一个字典的数组。
  6. 遍历字典。
  7. 初始化一个 FeedItem 。这个item被添加到了父 Feed children 数组中。
  8. 循环完成后,在 Feed 开始加载之前, Feed 的每个child都被添加到了 feeds 的数组中。
  9. 返回 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中 — 哇!

介绍NSOutlineViewDataSource

到目前为止,你已告知了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中只有两个层级,因此这个方法的实现相当得简单:

  1. 如果 item 是一个 Feed ,就返回 children 的数量。
  2. 否则,返回 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个小三角形,来指示你可以展开这一行。如果你点击它的话,就可以看到更多的行。

Second_Run

是你做错了什么吗?不是 - 你只需要在添加一个方法。

介绍NSOutlineViewDelegate

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

上述代码:

  1. 检查 item 是否是 Feed
  2. 从outline view中获取一个对应于 Feed 的view,包含一个text field的正常的 NSTableViewCell
  3. 设置text field中的文本为feed的名称并调用 sizeToFit() ,来重新计算它的frame以适应它内容的大小。

运行你的项目。你现在可以看到 Feed 的cell,但展开的行中仍然看不到任何内容。

Third_Run

这是因为你现在仅仅提供了代表 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()
}
}
}

上述代码:

  1. 如果 item 是一个 FeedItem ,你就需要填充两列:一列是 title ,另一列是 publishingDate 。你可以使用它们的 identifier 来区分列。
  2. 如果 identifier dateColumn ,就请求一个DateCell。
  3. 使用date formatter来根据 publishingDate 创建一个字符串。
  4. 如果不是 dateColumn 的话,你就需要一个对应于 FeedItem 的cell。
  5. 将文本设置为 FeedItem title

再次运行你的项目,相应的单元格已被正确地填充。

Fourth_Run

还剩一个问题 - 相应于 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))
}
}
}

上述代码:

  1. 检查通知的对象是否是NSOutlineView。如果不是的话,退出方法。
  2. 获取被选择的序号,并检查被选择的行是否包含 FeedItem Feed
  3. 如果选中的是 FeedItem ,就根据 Feed 对象的 url property创建一个 NSURL
  4. 检查url是否创建成功。
  5. 最后,加载页面。

在你进行测试之前,返回 Info.plist 文件。添加一项 App Transport Security Settings Dictionary (如果Xcode中还没有的情况下)。在其中添加一项 Allow Arbitrary Loads ,类型为 Boolean ,值为 YES

注意: 添加此条目到你的plist文件中,会使你的app能够接受任何不安全的主机,可能会造成一定的风险。通常添加 Exception Domains 为某个接口,而对其它接口则使用加密连接的后端,会更好一些。

Change_Info_Plist

现在运行项目,并选择一个 FeedItem 。如果你的网路已连接,对应的文章就会在几秒中之后被加载出来。

完成项目

你的示例app已经可以正常地工作了,但现在至少还有两种行为是缺失的:双击来展开或收起一个组,以及从outline view中移除一个组。

让我们从双击的特性开始。按 Alt + Cmd + Enter 键打开 Assistant Editor 。在窗口的左边打开 Main.storyboard ,右边打开 ViewController.swift

双击左边 Document Outline 中的outline view。在弹出的菜单中,找到 doubleAction 并点击它右侧的小圆圈。

AssistantEditor

拖拽小圆圈到 ViewController.swift 中,添加一个名为 doubleClickedItem IBAction 。确保sender的类型为 NSOutlineView 而不是 AnyObject

AddAction

切回到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)
}
}
}

上述代码:

  1. 获取被点击的item。
  2. 检查这个item是否为 Feed ,只有Feed的item可以被展开或收起。
  3. 如果这个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()
}
  1. 第一件事是检查现在有无被选中的项。如果没有任何项被选中, selectedRow 的值就会是-1,直接退出本方法。
  2. 否则,就告知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)
}
}
}

上述代码:

  1. 获取被选择的item。
  2. 检查它是否为 Feed FeedItem
  3. 如果是 Feed 的话,就在 feeds 数组中查找它的序号。
  4. 找到后,从数组中移除它。
  5. 从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)
}
}
}
  1. 这里的代码非常类似于 Feed 。唯一额外的步骤是它会迭代所有的feed,因为你不知道这个 FeedItem 属于哪个 Feed
  2. 对于每个 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 同样值得一看。