NSOutlineView Part 2: Making the outlineView useful

Xcode 8, macOS 10.12, Swift 3.0

In Part 1 of this tutorial, we created an outlineView that, thanks to a SourceListItem protocol, was indefinitely flexible: you can have different classes on several levels, or indefinite levels (though it is probably wise to limit this programatically so you don’t nest too deep. It all depends on your data structure. There will eventually be a third part which will implement folders (custom grouping of items) and drag-and-drop reordering.)

1) Make items editable
a) The key here is to set the behaviour of your NSTextField instances in your outlineView to ‘editable’ in InterfaceBuilder.

If you build and run now, you can change the names of items in the usual Mac fashion – select row, then click on the text – but the moment you collapse and expand your item, the outlineView will query the datasource anew, and your changes vanish.

b) Create an IBAction for saving changes, and connect it to all three text fields (header, icon, plain):

@IBAction func editName(_ sender: NSTextField) {
if let selectedItem = sourceList.item(atRow: sourceList.selectedRow) as? SourceListItem{
selectedItem.name = sender.stringValue
}
}

The NSOutlineViewDelegate method has an editing-related method

func outlineView(_ outlineView: NSOutlineView, shouldEdit tableColumn: NSTableColumn?, item: Any) -> Bool {
// return true or false
}

which appears to be another remnant from cell-based tables, but it never got called on 10.11/10.12, and setting it to true or false made no difference at all to the editability of the text fields.

2) Working with selection:

a) single selection

func outlineViewSelectionDidChange(_ notification: Notification) {
if let selectedItem = sourceList.item(atRow: sourceList.selectedRow) as? SourceListItem{
print(selectedItem.name)
}
}

We’re not doing anything with the selected item right now, but I wanted to show how to get the selected item from the outlineView. Wouldn’t it be nice to have a ‘selectedItem’ property?

A couple of things to watch out for:
Since NSTableView has been around for a very long time, long before Swift optionals were a speck in Chris Lattner’s eye, it keeps with the time-honoured tradition of returning -1 if no item is selected. You will need to handle that.

selectedRow is always the last selected row. If you allow multiple selection, select an item, and shift-click on an item a range away, the selected item will be the end point of your range.

b) multiple selection

Create
var selectedItems: [SourceListItem] = []

and change the outlineViewSelectionDidChange method:

func outlineViewSelectionDidChange(_ notification: Notification) {
//first, handle logic for single selection and no selection (-1)

selectedItems = []

let multipleSelection = sourceList.selectedRowIndexes

for index in multipleSelection{
if let selectedItem = sourceList.item(atRow: index) as? SourceListItem{
selectedItems.append(selectedItem)
}
}
}

We reset the selectedItems array to empty once we have confirmed we’re dealing with a multiple selection; and then we put all of the selected items into it.

You can easily check that this code has the desired result with

for item in selectedItems{
print(item.name)
}

in another function.

3) Adding and removing objects.

Long and complex, involving model, viewController, and several helper enums

a) Proof of concept:
The simplest form of the ‘add/insert’ function is

@IBAction func addItem(_ sender: AnyObject) {
if let selectedItem = sourceList.item(atRow: sourceList.selectedRow) as? SourceListItem
selectedItem.children.append(Animal(name: "New Animal"))
sourceList.reloadItem(selectedItem, reloadChildren: true)
}
}

This is not production code: it only inserts instances of ‘Animal’, regardless of the position, and it manipulates the object’s children array directly.

Since this is a very long and complex post already, I will skip any further temporary steps – where I grab an object and directly insert or remove an item from its child array – and only document the final, refactored version of my code, which does not access properties directly, but uses public accessor methods (as usual, for the sake of clarity, I am not setting access levels in my sample code.)

There is no single algorithm that will work for every use case of NSOutlineView. Things to keep in mind are

– are there items/levels that will always be ‘leaf’ objects and never accept an insertion attempt?
Each item needs to know whether it should insert objects. Ideally, this gets checked in selectionHasChanged so that users are not given the option to insert new objects

– will users be able to create new top level items?
insertion needs to be handled for -1; in which case a new top level object will be inserted to dataSource.allKingdoms

– do you want a ‘group’/folder class of items?
If you allow multiple selection, one of the more common use cases is to insert the folder as child of the selected items’ parent, and the items themselves as children of the folder.

– does the outline view contain more than one class of item? In this case, ‘add/insert’ need to create the appropriate objects
Each class know what items it will accept for its ‘children’ array.

b) Deletions provide a different challenge of equal magnitude. Here the main question is

– does deleting an object delete all of its children?
If that isn’t the case, the new position of the children needs to be determined. (If you close a department, do you fire all its staff and managers, or do you find other positions for them? Both are possible.)

We’ll handle this case eventually in a third part of this tutorial. For now, we’ll delete an item and all of its children.

The model setup will get long and very, complex. My goal was to create a template solution that I can can adapt to other projects with minimal changes. This means that I occasionally have duplicate code (eg handling all cases in a switch statement rather than only the one I am interested in). I am also adapting a belts-and-braces approach in places: I disable the ‘remove’ button when no item is selected, but I am still handling ‘remove item’ for row -1.

4) helper enums

a) childClass: This is an enum that is extremely project-specific: you need to provide a case and an instantiated object for each type of item you want to appear in your source list.

enum ChildClass {
case kingdom
case animalClass
case animal

var newInstance: SourceListItem{
switch self {
case .kingdom:
return Kingdom(name: "New Kingdom")
case .animalClass:
return AnimalClass(name: "New AnimalClass")
case .animal:
return Animal(name: "New Animal")
}
}
}

b) ChildType: This determines what the ‘add’ behaviour of each class – and potentially, each instance – will be. Here, I am setting these once for each class; but a childType variable will allow you to assign different childClasses to objects of the same class (the AnimalClass ‘Fish’ should provide only ‘Fish’ rather than ‘Animal’ instances) as well as calculating nesting levels and stopping users from creating hierarchies that run too deep for the purposes of your application.

enum ChildType {
case none
case sibling(class: ChildClass)
case child(class: ChildClass)
}

5) amend the SourceListItem protocol:

protocol SourceListItem: class {
var name: String {get set }
var icon: NSImage? {get set}

var children: [SourceListItem] {get set}
var isExpandable: Bool { get }

var itemType: SourceListItemType {get set}
var childType: ChildType {get set}

func addSourceListItem(parent: SourceListItem?) -> SourceListItem?
func appendChild(item: SourceListItem)
func insertChild(item: SourceListItem, afterItem: SourceListItem)
func removeSourceListItem(item: SourceListItem)
}

6) Fulfill those promises and make all three classes – Kingdom, AnimalClass and Animal – conform to the protocol again

a) Add the childType variable. This is application architecture: you determine the behaviour of your class, whether will add anything, and whether it will add children or siblings. (This is the reason we pass the parent into the add function)

Kingdom: var childType: ChildType = .child(class: .animalClass)
AnimalClass: var childType: ChildType = .child(class: .animal)
Animal: var childType: ChildType = .sibling(class: .animal)

Our CompositeDataSource does not have to conform to the SourceListItem protocol – this would make no sense – but it still benefits from having its child class set:

var childClass: ChildClass = .kingdom

b) For the datasource, the add and remove functions are straightforward:
Add:
func addTopLevelItem() -> SourceListItem{
let newTopLevelItem = childClass.newInstance
allChildren.append(newTopLevelItem)
return newTopLevelItem
}

Returning a reference to the newly-added item makes it easier to select it later.

For my convenience, I am handling the question of whether the dataSource should add children or not in the main ViewController class with

var shouldAddTopLevel: Bool = true

A different (better?) implementation would move this responsibility to the datasource, which instead of varChildClass would have a varChildType, which could be set to .none or .child(class: SourceListItem), with an add function that acts accordingly.

Remove:
func removeTopLevelItem(item: SourceListItem){
allChildren = allChildren.filter() {$0 !== item}
}

When a kingdom gets deleted, everything in that kingdom gets deleted.

c) add the helper functions to all three classes

func insertChild(item: SourceListItem, afterItem: SourceListItem){
if let index = children.index(where: {$0 === afterItem }) {
children.insert(item, at: index + 1)
} else {
appendChild(item: item)
}
}

Array does have an index(of:) property. For this to work, your items need to be equatable (eg, inherit from NSObject). Since that isn’t the case here, we’re checking for identity. (For more about indices and arrays, see this post.)
There’s a slight defensiveness about this code – for this to have gotten this far, the index for our item in its parent should definitely exist, but I’d rather handle the failure gracefully even if I don’t expect it to ever happen than not at all.

func appendChild(item: SourceListItem){
children.append(item)
}

d) Add the add function to all three classes.

func addSourceListItem(parent: SourceListItem?) -> SourceListItem?{

switch childType {
case .none:
print("No children allowed - handle more gracefully")
return nil
case .child(let className):
let newItem = className.newInstance
appendChild(item: newItem)
return newItem
case .sibling(let siblingClass):
if let parent = parent{
let newItem = siblingClass.newInstance
parent.insertChild(item: newItem, afterItem: self)
return newItem
} else {
return nil
}
}
}

This function is identical in all three classes, even though in this example I am never changing the childType, and thus don’t need to handle the other two cases.

e) add the remove function to all three classes.

func removeSourceListItem(item: SourceListItem){
children = children.filter(){ $0 !== item }
}

This function removes the item in question and all of its children. For the current data structure, this makes sense because I should not be able to delete the plant kingdom and relocate all its contents to ‘Fungi’. For other uses – we remove the department but all of its members are still employed by us – there will need to be additional, custom implementations.

My suggestion would be to create a ‘FolderItem’ protocol inheriting from SourceListItem which then handles all of the logic involved with this, but that is a topic for another post.

7)

a) set up an ‘add’ button and a ‘remove’ button in IB, create outlets for both of them and link them to an addItem and removeItem IBAction respectively.

b) create a function to handle the button states.

func setButtonState(){
if sourceList.selectedRow == -1{
if shouldAddTopLevel == true {
removeBtn.isEnabled = false
addBtn.isEnabled = true
} else {
removeBtn.isEnabled = false
addBtn.isEnabled = false
}
} else if let selectedItem = sourceList.item(atRow: sourceList.selectedRow) as? SourceListItem{
switch selectedItem.childType {
case .none:
removeBtn.isEnabled = true
addBtn.isEnabled = false
default:
removeBtn.isEnabled = true
addBtn.isEnabled = true

} else {
removeBtn.isEnabled = true
addBtn.isEnabled = true
}
}

The logic here is a little ugly, but we need to handle three main cases:

– no items are selected (whether or not we insert a new top level item depends on app settings)
– the selected item does not allow insertion of either children or siblings
– everything else

(To test this, you should temporarily change the ‘childType’ property of either a class or an individual item in your app to .none; right now all of them allow either children or siblings).

Add setButtonState() to the end of viewDidLoad so we start with the correct configuration.

c) Since we’re not doing anything with multiple selection in this iteration of our app, the next function is very straightforward:

func outlineViewSelectionDidChange(_ notification: Notification) {
setButtonState()
}

Every time the selection changes, we enable or disable the appropriate buttons.

8) Implement Add and Remove in the ViewController

a) Adding items:

@IBAction func addItem(_ sender: AnyObject) {
if sourceList.selectedRow == -1, shouldAddTopLevel == true {

let newTopLevelItem = dataSource.addTopLevelItem()
sourceList.reloadItem(newTopLevelItem)
let newItemIndex = IndexSet(integer: sourceList.row(forItem: newTopLevelItem))
sourceList.selectRowIndexes(newItemIndex, byExtendingSelection: false)
sourceList.scrollRowToVisible(sourceList.row(forItem: newTopLevelItem))

return

We’ve configured the button so that it won’t _be_ enabled unless these conditions are true, but someone might still call this in code. Better safe than sorry.

The dance with the indexSet containing a single index is necessary since OutlineView does not have a ‘selectRowIndex’ or ‘selectItem’ method.

} else{

let parent = sourceList.parent(forItem: sourceList.item(atRow: sourceList.selectedRow)) as? SourceListItem
if let selectedItem = sourceList.item(atRow: sourceList.selectedRow) as? SourceListItem{

let newItem = selectedItem.addSourceListItem(parent: parent)

//this works automagically: even if parent is nil, the item still gets expanded.
sourceList.reloadItem(parent, reloadChildren: true)
sourceList.expandItem(selectedItem)

let newItemIndex = IndexSet(integer: sourceList.row(forItem: newItem))
sourceList.selectRowIndexes(newItemIndex, byExtendingSelection: false)
sourceList.scrollRowToVisible(sourceList.row(forItem: newItem))
}
}
}

parent needs to remain optional because kingdoms have new items, too. reloadItem(parent, reloadChildren: true) is safe to call since the first parameter is of type Any?

Like the insertion of a new kingdom above, this selects the new item (so you can rename it more easily) and scrolls to it.

b) removing items:

@IBAction func removeItem(_ sender: AnyObject) {
guard sourceList.selectedRow != -1 else {
print("Remove button should be disabled, so this should never be called.")
return
}
if let selectedItem = sourceList.item(atRow: sourceList.selectedRow) as? SourceListItem{
if selectedItem.itemType == .header {
dataSource.removeTopLevelItem(item: selectedItem)
sourceList.reloadData()
setButtonState()
} else {

if let parent = sourceList.parent(forItem: sourceList.item(atRow: sourceList.selectedRow)) as? SourceListItem {
parent.removeSourceListItem(item: selectedItem)
sourceList.reloadItem(parent)
setButtonState()
let newItemIndex = IndexSet(integer: sourceList.row(forItem: parent))
sourceList.selectRowIndexes(newItemIndex, byExtendingSelection: false)
}
}
}
}

ScreenShot2017-01-15at16.52.10-2017-01-9-08-50.png

And that’s a wrap for the second part of this tutorial. The source list is now useful – you can select things (and do something with that selection), you can edit the name of items, and you can add and insert objects according to application-specific rules (do you add children or siblings, and of which class?)

And more…

There are two other points I’d eventually like to cover in an third tutorial: folder functionality (selecting multiple items of the same kind and moving them into a ‘folder’, which can be deleted without deleting its children) and drag-and-drop reordering. Neither of them are straightforward, and unfortunately I do not have the time to delve into the topic right now.

Usage Example

You can either display a true tree, or use the structure I’ve used here as an example, where headers do not belong to the same class as their children. Source lists/outline views are good for quickly traversing tree structures, with the file system being the most prominent example. If your data is hierarchical by nature, a source list/outline view is the way to go.
Tables and collectionViews will only take you so far. If the data wants to be in a tree, flattening it by force will always be awkward. But for the example here – especially if the header categories displayed in the sidebar are not as inherently linked as my example – a tabbed interface that lets users concentrate on category at a time might be more appropriate. The question, I think, is whether users want/need to a) display data in quick succession, or b) whether they have the option of a split view which allows them to work on one item and have the second open as a reference: display an image and write its description in a separate split view; display an image and handle its metadata, keep open one document and translate it into a second, display a reference image and paint freehand in a second split view.
I find that this is a very useful way of working: I do not have to lose my focus while cycling through related items, and I do not see a better way of handling split views that can display a multitude of items.

Alternatives

You can use tabbed views or views chosen from a dropdown menu as easy navigation between views showing unrelated data – do users really need to be able to navigate through everything at the same time? If the data isn’t inherently hierarchical, you can display it in a grouped NSTableView, although the current implementation isn’t that straightforward, either.