1

NSOutlineView on macOS Tutorial

 3 years ago
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.
Home macOS Tutorials

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 Apr 21 2016 · Article (25 mins) · Intermediate

2.5/5 6 Ratings

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:

  1. Creates an empty Feed array.
  2. Tries to load an array of dictionaries from the file.
  3. If this worked, loops through the entries.
  4. The dictionary contains a key name that is used to inititalize Feed.
  5. The key items contains another array of dictionaries.
  6. Loops through the dictionaries.
  7. Initializes a FeedItem. This item is appended to the children array of the parent Feed.
  8. After the loop, every child for the Feed is added to the feeds array before the next Feed starts loading.
  9. Returns the feeds. If everything worked as expected, this array will contain 2 Feed 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:

  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.

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.

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.

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.

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:

  1. Gets the clicked item.
  2. Checks whether this item is a Feed, which is the only item that can be expanded or collapsed.
  3. 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()
}
  1. 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.
  2. 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:

  1. Gets the selected item.
  2. Checks if it is a Feed or a FeedItem.
  3. If it is a Feed, searches the index of it inside the feeds array.
  4. If found, removes it from the array.
  5. 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)
    }
  }
}
  1. 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 which Feed the FeedItem belongs.
  2. For each Feed, the code checks if you can find a FeedItem in its children 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!

Average Rating

2.5/5

Add a rating for this content

Sign in to add a rating
6 ratings

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK