Skip to content

Latest commit

 

History

History
1670 lines (1654 loc) · 61.3 KB

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 同样值得一看。