NSOutlineView on macOS Tutorial
source link: https://www.raywenderlich.com/1201-nsoutlineview-on-macos-tutorial
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
NSOutlineView on macOS Tutorial
Discover how to display and interact with hierarchical data on macOS with this NSOutlineView on macOS tutorial.
Version
Update 9/30/16: This tutorial has been updated for Xcode 8 and Swift 3.
When writing applications, you often want to show data that has a list-like structure. For example, you might want to display a list of recipes. This can be easily done with a NSTableView
. But what if you want to group the recipes by appetizer or main course? Now you have a problem, because table views have no sections. Your five-layer-dip recipe is right next to your linguine del mare recipe, which just won’t do!
Thankfully, NSOutlineView
provides a lot more functionality. NSOutlineView
is a common component in macOS applications, and is a subclass of NSTableView
. Like a table view, it uses rows and columns to display its content; unlike a table view, it uses a hierarchical data structure.
To see an outline view in action, open Xcode with an existing project and have a look at the project navigator. Click on the triangle next to the project name to expand it. You’ll see a structure like the image below: beneath the project are groups, and inside the groups are Swift or Objective-C files.
In this NSOutlineView on macOS tutorial, you will learn how to use an outline view to show your own hierarchical data. To do this, you’ll write a RSS Reader-like application that loads RSS Feeds from a file and shows them in an outline view.
Getting Started
The starter project can be downloaded here. Open the project and take a peek. Besides the files created by the template, there is a Feeds.plist, which is the file you’ll load the feeds from. You’ll take a closer look at it later, when creating the model classes.
Open Main.storyboard to see the prepared UI. On the left is a plain outline view, and beside it is a white area, which is the web view. Those are grouped using a horizontal stack view, which is pinned to the window edges. Stack views are the latest and greatest way to deal with Auto Layout, if you haven’t yet given them a try. You can learn all about them in Marin’s great tutorial about NSStackViews.
Your first task: complete the UI. To do this, double-click in the header to change the title. For the first column, change it to Feed; change the second to Date.
That was easy! Now select the outline view, in the document outline — you’ll find it under Bordered Scroll View – Outline View \ Clip View \ Outline View. In the Attributes Inspector, change Indentation to 5, enable Floats Group Rows and disable Reordering.
Inside the document outline on the left, click on the triangle beside Outline View to expand it. Do the same for Feed and Date. Select the Table View Cell below Date.
Change the Identifier to DateCell in Identity Inspector.
Now show the Size Inspector and change Width to 102. Repeat this step for the cell below Feed, changing the Identifier to FeedCell and Width to 320.
Expand the cell below feed and select the text field named Table View Cell.
Use the Pin and Align menus on the Auto Layout toolbar to add an Auto Layout constraint of 2 points leading, plus another constraint to center the text field vertically. You will see the constraints in Size Inspector:
Now select the table cell again (above the text field in the layout hierarchy). Duplicate it by pressing Cmd + C and Cmd + V, then change the Identifier of the duplicate to FeedItemCell. Now you have 3 different cells, one for each type of entry that will be shown in the outline view.
Select Date, and in the Identity Inspector change the Identifier to DateColumn; do the same for Feed and change it to TitleColumn:
The final step is to give the outline view a delegate and a data source. Select the outline view and right- or control-click on it. Drag a line from dataSource to the blue circle that represents your view controller; repeat this to set the delegate.
Run the project and you’ll see …
There’s an empty outline view and an error message in your console, saying you have an illegal data source. What’s wrong?
Before you can fill the outline view and get rid of the error message, you need a data model.
Data Model
The data model for an outline view is a bit different than the one for a table view. Like mentioned in the introduction, an outline view shows a hierarchical data model, and your model classes have to represent this hierarchy. Every hierarchy has a top level or root object. Here this will be a RSS Feed; the name of the feed is the root.
Press Cmd + N to create a new class. Inside the macOS section select Cocoa Class and click Next.
Name the class Feed
and make it a subclass of NSObject
. Then click Next and Create on the next screen.
Replace the automatically generated code with:
import Cocoa class Feed: NSObject { let name: String init(name: String) { self.name = name } }
This adds a name
property to your class and provides an init method that sets the property to a provided value. Your class will store its children in an array, but before you can do this, you need to create a class for those children. Using the same procedure as before, add a new file for the FeedItem
class. Open the newly created FeedItem.swift and replace the content with the following:
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 } }
This is another simple model class: FeedItem
has a url
that you will use to load the corresponding article into the web view; a title
; and a publishingDate
. The initializer takes a dictionary as its parameter. This could be received from a web service or, in this case, from a plist file.
Head back to Feed.swift and add the following property to Feed
:
var children = [FeedItem]()
This creates an empty array to store FeedItem objects.
Now add the following class method to Feed
to load the 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 }
The method gets a file name as its argument and returns an array of Feed
objects. This code:
- Creates an empty
Feed
array. - Tries to load an array of dictionaries from the file.
- If this worked, loops through the entries.
- The dictionary contains a key name that is used to inititalize
Feed
. - The key items contains another array of dictionaries.
- Loops through the dictionaries.
- Initializes a
FeedItem
. This item is appended to thechildren
array of the parentFeed
. - After the loop, every child for the
Feed
is added to thefeeds
array before the nextFeed
starts loading. - Returns the
feeds
. If everything worked as expected, this array will contain 2Feed
objects.
Open ViewController.swift, and below the IBOutlet section add a property to store feeds:
var feeds = [Feed]()
Find viewDidLoad()
and add the following:
if let filePath = Bundle.main.path(forResource: "Feeds", ofType: "plist") { feeds = Feed.feedList(filePath) print(feeds) }
Run the project; you should see something like this in your console:
[<Reader.Feed: 0x600000045010>, <Reader.Feed: 0x6000000450d0>]
You can see that you’ve successfully loaded two Feed
objects into the feeds
property — yay!
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.
Now build your project and select a FeedItem
. Assuming you have a working internet connection, the article will load after a few seconds.
Finishing Touches
Your example application is now working, but there are at least two common behaviors missing: double-clicking to expand or collapse a group, and the ability to remove an entry from the outline view.
Let’s start with the double-click feature. Open the Assistant Editor by pressing Alt + Cmd + Enter. Open Main.storyboard in the left part of the window, and ViewController.swift in the right part.
Right-click on the outline view inside the Document Outline on the left. Inside the appearing pop-up, find doubleAction and click the small circle to its right.
Drag from the circle inside ViewController.swift and add an IBAction named doubleClickedItem
. Make sure that the sender is of type NSOutlineView
and not AnyObject
.
Switch back to the Standard editor (Cmd + Enter) and open ViewController.swift. Add the following code to the action you just created.
@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) } } }
This code:
- Gets the clicked item.
- Checks whether this item is a
Feed
, which is the only item that can be expanded or collapsed. - If the item is a
Feed
, asks the outline view if the item is expanded or collapsed, and calls the appropriate method.
Build your project, then double-click a feed. It works!
The last behavior we want to implement is allowing the user to press backspace to delete the selected feed or article.
Still inside ViewController.swift, add the following method to your ViewController
. Make sure to add it to the normal declaration and not inside an extension, because the method has nothing to do with the delegate or datasource protocols.
override func keyDown(with theEvent: NSEvent) { interpretKeyEvents([theEvent]) }
This method is called every time a key is pressed, and asks the system which key was pressed. For some keys, the system will call a corresponding action. The method called for the backspace key is deleteBackward(_:)
.
Add the method below keyDown(_:)
:
override func deleteBackward(_ sender: Any?) { //1 let selectedRow = outlineView.selectedRow if selectedRow == -1 { return } //2 outlineView.beginUpdates() outlineView.endUpdates() }
- The first thing this does is see if there is something selected. If nothing is selected,
selectedRow
will have a value of -1 and you return from this method. - Otherwise, it tells the outline view that there will be updates on it and when these updates are done.
Now add the following between beginUpdates()
and 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) } } }
This code:
- Gets the selected item.
- Checks if it is a
Feed
or aFeedItem
. - If it is a
Feed
, searches the index of it inside thefeeds
array. - If found, removes it from the array.
- Removes the row for this entry from the outline view with a small animation.
To finish this method, add the code to handle FeedItems
as an else part to if let item = item as? Feed
:
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) } } }
- This code is similar to the code for a
Feed
. The only additional step is that here it iterates over all feeds, because you don’t know to whichFeed
theFeedItem
belongs. - For each
Feed
, the code checks if you can find aFeedItem
in itschildren
array. If so, it deletes it from the array and from the outline view.
Note: Not only can you delete a row, but you can also add and move rows. The steps are the same: add an item to your data model and call insertItemsAtIndexes(_:, inParent:, withAnimation:)
to insert items, or moveItemAtIndex(_:, inParent:, toIndex:, inParent:)
to move items. Make sure that your datasource is also changed accordingly.
Now your app is complete! Build and run to check out the new functionality you just added. Select a feed item and hit the delete key–it’ll disappear as expected. Check that the same is true for the feed as well.
Where To Go From here?
Congrats! You’ve created an RSS Feed Reader-type app with hierarchical functionality that allows the user to delete rows at will and to double-click to expand and collapse the lists.
You can download the final project here.
In this NSOutlineView on macOS tutorial you learned a lot about NSOutlineView
. You learned:
- How to hook up an
NSOutlineView
in Interface Builder. - How to populate it with data.
- How to expand/collapse items.
- How to remove entries.
- How to respond to user interactions.
There is lots of functionality that you didn’t get chance to cover here, like support for drag and drop or data models with a deeper hierarchy, so if you want to learn more about NSOutlineView
, take a look at the documentation. Since it is a subclass of NSTableView
, Ernesto García’s tutorial about table views is also worth a look.
I hope you enjoyed this NSOutlineView on macOS tutorial! If you have any questions or comments, feel free to join the forum discussion below.
raywenderlich.com Weekly
The raywenderlich.com newsletter is the easiest way to stay up-to-date on everything you need to know as a mobile developer.
Get a weekly digest of our tutorials and courses, and receive a free in-depth email course as a bonus!
Recommend
About Joyk
Aggregate valuable and interesting links.
Joyk means Joy of geeK