Using NSTreeController with NSOutlineView

4.1k views Asked by At

I'm trying (unsuccessfully) to build a TreeController-controlled NSOutlineView. I've gone through a bunch of tutorials, but they all pre-load the data before starting anything, and this won't work for me.

I have a simple class for a device:

import Cocoa
class Device: NSObject {
    let name : String
    var children = [Service]()
    var serviceNo = 1
    var count = 0

    init(name: String){
        self.name = name
    }

    func addService(serviceName: String){
        let serv = "\(serviceName) # \(serviceNo)"
        children.append(Service(name: serv))
        serviceNo += 1
        count = children.count
    }

    func isLeaf() -> Bool {
        return children.count < 1
    }
}

I also have an even more simple class for the 'Service':

import Cocoa
class Service: NSObject {

    let name: String

    init(name: String){
        self.name = name
    }
}

Finally, I have the ViewController:

class ViewController: NSViewController {

    var stepper = 0

    dynamic var devices = [Device]()
    override func viewDidLoad() {
        super.viewDidLoad()
    }

    override var representedObject: Any? {
        didSet {
        // Update the view, if already loaded.
        }
    }

    @IBAction func addDeviceAction(_ sender: Any) {
        let str = "New Device #\(stepper)"
        devices.append(Device(name: str))
        stepper += 1
        print("Added Device: \(devices[devices.count-1].name)")    
    }

    @IBAction func addService(_ sender: Any) {
        for i in 0..<devices.count {
            devices[i].addService(serviceName: "New Service")
       }
    }
}

Obviously I have 2 buttons, one that adds a 'device' and one that adds a 'service' to each device.

What I can't make happen is any of this data show up in the NSOutlineView. I've set the TreeController's Object Controller Property to Mode: Class and Class: Device, and without setting the Children, Count, or Leaf properties I get (predictably):

2017-01-04 17:20:19.337129 OutlineTest[12550:1536405] Warning: [object class: Device] childrenKeyPath cannot be nil. To eliminate this log message, set the childrenKeyPath attribute in Interface Builder

If I then set the Children property to 'children' things go very bad:

2017-01-04 17:23:11.150627 OutlineTest[12695:1548039] [General] [ addObserver:forKeyPath:options:context:] is not supported. Key path: children

All I'm trying to do is set up the NSOutlineView to take input from the NSTreeController so that when a new 'Device' is added to the devices[] array, it shows up in the Outline View.

If anyone could point me in the right direction here I'd be most grateful.

Much gratitude to Warren for the hugely helpful work. I've got it (mostly) working. A couple of things that I also needed to do, in addition to Warren's suggestions:

Set the datastore for the TreeController Set the datastore for the Tree Controller

Bind to the TreeView Bind the OutlineView to the TreeController

Bind the Column to the TreeController Bind the Column to the TreeController

Bind the Table View Cell to the Table Cell View Bind the TableView Cell to the Table Cell View (yes, really)

Once all that was done, I had to play around with the actual datastore a bit:

   var name = "Bluetooth Devices Root"
var deviceStore = [Device]()
@IBOutlet var treeController: NSTreeController!
@IBOutlet weak var outlineView: NSOutlineView!


override func viewDidLoad() {
    super.viewDidLoad()
    deviceStore.append(Device(name: "Bluetooth Devices"))
    self.treeController.content = self
}

override var representedObject: Any? {
    didSet {
    // Update the view, if already loaded.
    }
}

@IBAction func addDeviceAction(_ sender: Any) {
    if(deviceStore[0].name == "Bluetooth Devices"){
        deviceStore.remove(at: 0)
    }

Turns out the Root cannot be child-less at the beginning, at least as far as I can tell. Once I add a child, I can delete the place-holder value and the tree seems to work (mostly) as I want. One other thing is that I have to reload the data and redisplay the outline whenever the data changes:

 outlineView.reloadData()
 outlineView.setNeedsDisplay()

Without that, nothing. I still don't have the data updating correctly (see comments below Warren's answer) but I'm almost there.

1

There are 1 answers

4
Warren Burton On BEST ANSWER

To state the obvious, a NSTreeController manages a tree of objects all of which need to answer the following three questions/requests.

  • Are you a leaf i.e do you have no children? = leafKeyPath
  • If you are not a leaf, how many children do you have ? = countKeyPath
  • Give me your children! = childrenKeyPath

Its simple to set these up in IB or programatically. A fairly standard set of properties is respectively.

  • isLeaf
  • childCount
  • children

But its totally arbitrary and can be any set of properties that answer those questions.

I normally set up a protocol named something like TreeNode and make all my objects conform to it.

@objc protocol TreeNode:class { 
    var isLeaf:Bool { get }
    var childCount:Int { get }
    var children:[TreeNode] { get } 
}

For your Device object you answer 2 out 3 question with isLeaf and children but don't answer the childCount question.

Your Device's children are Service objects and they answer none of that which is some of the reason why you are getting the exceptions.

So to fix up your code a possible solution is ...

The Service object

class Service: NSObject, TreeNode {

    let name: String
    init(name: String){
        self.name = name
    }

    var isLeaf:Bool {
        return true
    }
    var childCount:Int {
        return 0
    }
    var children:[TreeNode] {
        return []
    }
}

The Device object

class Device: NSObject, TreeNode {
    let name : String
    var serviceStore = [Service]()

    init(name: String){
        self.name = name
    }

    var isLeaf:Bool {
        return serviceStore.isEmpty
    }
    var childCount:Int {
        return serviceStore.count
    }
    var children:[TreeNode] {
        return serviceStore
    }

}

And a horrible thing to do from a MVC perspective but convenient for this answer. The root object.

class ViewController: NSViewController, TreeNode {

    var deviceStore = [Device]()
    var name = "Henry" //whatever you want to name your root

    var isLeaf:Bool {
        return deviceStore.isEmpty
    }
    var childCount:Int {
        return deviceStore.count
    }
    var children:[TreeNode] {
        return deviceStore
    }

}

So all you need to do is set the content of your treeController. Lets assume you have an IBOutlet to it in your ViewController.

class ViewController: NSViewController, TreeNode {

    @IBOutlet var treeController:NSTreeController!
    @IBOutlet var outlineView:NSOutlineView!


    override func viewDidLoad() {
        super.viewDidLoad()
        treeController.content = self
    } 

Now each time you append a Device or add a Service just call reloadItem on the outlineView (that you also need an outlet to)

@IBAction func addDeviceAction(_ sender: Any) {
        let str = "New Device #\(stepper)"
        devices.append(Device(name: str))
        stepper += 1
        print("Added Device: \(devices[devices.count-1].name)")
        outlineView.reloadItem(self, reloadChildren: true)    
    }

Thats the basics and should get you started but the docs for NSOutlineView & NSTreeController have a lot more info.

EDIT

In addition to the stuff above you need to bind your outline view to your tree controller.

First ensure your Outline View is in view mode.

place outline view in view mode

Next bind the table column to arrangedObjects on the tree controller.

bind column to tree controller

Last bind the text cell to the relevant key path. In your case it's name. objectValue is the reference to your object in the cell.

bind data to cell