NSOutlineView on macOS Tutorial
Discover how to display and interact with hierarchical data on macOS with this NSOutlineView on macOS tutorial. By Jean-Pierre Distler.
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Contents
NSOutlineView on macOS Tutorial
25 mins
Introducing NSOutlineViewDataSource
So far, you’ve told the outline view that ViewController
is its data source — but ViewController
doesn’t yet know about its new job. It’s time to change this and get rid of that pesky error message.
Add the following extension below your class declaration of ViewController
:
extension ViewController: NSOutlineViewDataSource {
}
This makes ViewController
adopt the NSOutlineViewDataSource
protocol. Since we’re not using bindings in this tutorial, you must implement a few methods to fill the outline view. Let’s go through each method.
Your outline view needs to know how many items it should show. For this, use the method 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
}
This method will be called for every level of the hierarchy displayed in the outline view. Since you only have 2 levels in your outline view, the implementation is pretty straightforward:
- If
item
is aFeed
, it returns the number ofchildren
. - Otherwise, it returns the number of
feeds
.
One thing to note: item
is an optional, and will be nil
for the root objects of your data model. In this case, it will be nil
for Feed
; otherwise it will contain the parent of the object. For FeedItem
objects, item
will be a Feed
.
Onward! The outline view needs to know which child it should show for a given parent and index. The code for this is similiar to the previous code:
func outlineView(_ outlineView: NSOutlineView, child index: Int, ofItem item: Any?) -> Any {
if let feed = item as? Feed {
return feed.children[index]
}
return feeds[index]
}
This checks whether item
is a Feed
; if so, it returns the FeedItem
for the given index. Otherwise, it return a Feed
. Again, item
will be nil
for the root object.
One great feature of NSOutlineView
is that it can collapse items. First, however, you have to tell it which items can be collapsed or expanded. Add the following:
func outlineView(_ outlineView: NSOutlineView, isItemExpandable item: Any) -> Bool {
if let feed = item as? Feed {
return feed.children.count > 0
}
return false
}
In this application only Feeds
can be expanded and collapsed, and only if they have children. This checks whether item
is a Feed
and if so, returns whether the child count of Feed
is greater than 0. For every other item, it just returns false.
Run your application. Hooray! The error message is gone, and the outline view is populated. But wait — you only see 2 triangles indicating that you can expand the row. If you click one, more invisible entries appear.
Did you do something wrong? Nope — you just need one more method.
Introducing NSOutlineViewDelegate
The outline view asks its delegate for the view it should show for a specific entry. However, you haven’t implemented any delegate methods yet — time to add conformance to NSOutlineViewDelegate
.
Add another extension to your ViewController
in ViewController.swift:
extension ViewController: NSOutlineViewDelegate {
}
The next method is a bit more complex, since the outline view should show different views for Feeds
and FeedItems
. Let’s put it together piece by piece.
First, add the method body to the extension.
func outlineView(_ outlineView: NSOutlineView, viewFor tableColumn: NSTableColumn?, item: Any) -> NSView? {
var view: NSTableCellView?
// More code here
return view
}
Right now this method returns nil
for every item
. In the next step you start to return a view for a Feed
. Add this code above the // More code here
comment:
//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()
}
}
This code:
- Checks if
item
is aFeed
. - Gets a view for a
Feed
from the outline view. A normalNSTableViewCell
contains a text field. - Sets the text field’s text to the feed’s name and calls
sizeToFit()
. This causes the text field to recalculate its frame so the contents fit inside.
Run your project. While you can see cells for a Feed
, if you expand one you still see nothing.
This is because you’ve only provided views for the cells that represent a Feed
. To change this, move on to the next step! Still in ViewController.swift, add the following property below the feeds
property:
let dateFormatter = DateFormatter()
Change viewDidLoad()
by adding the following line after super.viewDidLoad()
:
dateFormatter.dateStyle = .short
This adds an NSDateformatter
that will be used to create a nice formatted date from the publishingDate
of a FeedItem
.
Return to outlineView(_:viewForTableColumn:item:)
and add an else-if clause to 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()
}
}
}
This is what you’re doing here:
- If
item
is aFeedItem
, you fill two columns: one for thetitle
and another one for thepublishingDate
. You can differentiate the columns with theiridentifier
. - If the
identifier
is dateColumn, you request a DateCell. - You use the date formatter to create a string from the
publishingDate
. - If it is not a dateColumn, you need a cell for a
FeedItem
. - You set the text to the
title
of theFeedItem
.
Run your project again to see feeds filled properly with articles.
There’s one problem left — the date column for a Feed
shows a static text. To fix this, change the content of the if let feed = item as? Feed
if statement to:
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()
}
}
To complete this app, after you select an entry the web view should show the corresponding article. How can you do that? Luckily, the following delegate method can be used to check whether something was selected or if the selection changed.
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))
}
}
}
This code:
- Checks if the notification object is an NSOutlineView. If not, return early.
- Gets the selected index and checks if the selected row contains a
FeedItem
or aFeed
. - If a
FeedItem
was selected, creates aNSURL
from theurl
property of theFeed
object. - Checks whether this succeeded.
- Finally, loads the page.
Before you test this out, return to the Info.plist file. Add a new Entry called App Transport Security Settings and make it a Dictionary if Xcode didn’t. Add one entry, Allow Arbitrary Loads of type Boolean, and set it to YES.
Note: Adding this entry to your plist causes your application to accept insecure connections to every host, which can be a security risk. Usually it is better to add Exception Domains to this entry or, even better, to use backends that use an encrypted connection.
Note: Adding this entry to your plist causes your application to accept insecure connections to every host, which can be a security risk. Usually it is better to add Exception Domains to this entry or, even better, to use backends that use an encrypted connection.
Now build your project and select a FeedItem
. Assuming you have a working internet connection, the article will load after a few seconds.