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.

Leave a rating/review
Save for later
Share
You are currently viewing page 2 of 3 of this article. Click here to view the first page.

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:

  1. If item is a Feed, it returns the number of children.
  2. 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.

Second_Run

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:

  1. Checks if item is a Feed.
  2. Gets a view for a Feed from the outline view. A normal NSTableViewCell contains a text field.
  3. 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.

Third_Run

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:

  1. If item is a FeedItem, you fill two columns: one for the title and another one for the publishingDate. You can differentiate the columns with their identifier.
  2. If the identifier is dateColumn, you request a DateCell.
  3. You use the date formatter to create a string from the publishingDate.
  4. If it is not a dateColumn, you need a cell for a FeedItem.
  5. You set the text to the title of the FeedItem.

Run your project again to see feeds filled properly with articles.

Fourth_Run

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:

  1. Checks if the notification object is an NSOutlineView. If not, return early.
  2. Gets the selected index and checks if the selected row contains a FeedItem or a Feed.
  3. If a FeedItem was selected, creates a NSURL from the url property of the Feed object.
  4. Checks whether this succeeded.
  5. 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.

Change_Info_Plist

Now build your project and select a FeedItem. Assuming you have a working internet connection, the article will load after a few seconds.