Do you need help on a specific subject? Use the contact form (Request a blog entry) on the right hand side.

2016-03-16

How to use the NSOutlineView in Swift | An example.

Using NSOutlineView from Swift is not really difficult when we take some care in designing the data class. If you expect NSOutlineView to be similar to NSTableView the you will quickly discover that NSOutlineView is a whole different beast.

NSTableView works with row/column or row/section identifiers while NSOutlineView works with 'item' identifiers.

Item identifiers are of the type AnyObject. Which is to say that they cannot be String's, Int's or similar things. Structs are out. If we do make the mistake of using -for example- String's then the effects are rather peculiar and the elements shown by the view will be 'all over the place'.

So use real object identifiers. And make sure that they are different for each element that you want to show. Don't use identifiers that are 'static'. (Unless of course you already know exactly what you are doing).

A picture, this is what I want to achieve:



By clicking on a value that value will become editable.

The Domain is implemented as a class and has the following structure:

class Domain {
    var name: String = "domain-name.extension"
    var wwwIncluded: Bool = true
    var root: String = "root-folder"
    var forwardUrl: String = ""
    var enabled: Bool = false
}

Notice that all members are struct's. This is important.

I also used a domains managing class called Domains:

class Domains: SequenceType {
    private var domains: Dictionary<String, Domain> = [:]
    var count: Int {...}
    func contains(domainName: String) -> Bool {...}
    func domainForName(name: String) -> Domain? {...}
    func add(domain: Domain) -> Bool {...}
    func remove(name: String) -> Bool {...}
    func update(name: String, withDomain new: Domain) -> Bool {...}
    func updateWithDomains(domains: Domains) {...}
   
    struct DomainGenerator: GeneratorType {...}
    typealias Generator = DomainGenerator
    func generate() -> Generator {...}
}

The view controller has to implement the following operations from the NSOutlineViewDataSource and NSOutlineViewDelegate protocols:

class MainWindowViewController: NSViewController {
    var domains = Domains()
    @IBOutlet weak var domainNameColumn: NSTableColumn!
    @IBOutlet weak var domainValueColumn: NSTableColumn!
    @IBOutlet weak var domainOutlineView: NSOutlineView!
}

extension MainWindowViewController: NSOutlineViewDataSource, NSOutlineViewDelegate {
    
    func outlineView(outlineView: NSOutlineView, numberOfChildrenOfItem item: AnyObject?) -> Int {...}
    
    func outlineView(outlineView: NSOutlineView, child index: Int, ofItem item: AnyObject?) -> AnyObject {...}

    func outlineView(outlineView: NSOutlineView, isItemExpandable item: AnyObject) -> Bool {...}
    
    // Using "Cell Based" content mode (specify this in IB)
    func outlineView(outlineView: NSOutlineView, objectValueForTableColumn tableColumn: NSTableColumn?, byItem item: AnyObject?) -> AnyObject? {...}
    
    func outlineView(outlineView: NSOutlineView, shouldEditTableColumn tableColumn: NSTableColumn?, item: AnyObject) -> Bool {...}
    
    func outlineView(outlineView: NSOutlineView, setObjectValue object: AnyObject?, forTableColumn tableColumn: NSTableColumn?, byItem item: AnyObject?) {...}
    
}

The first operation outlineView:numberOfChildrenOfItem must return the number of children for the given item. If the item is nil, then it must return the number of top-level items. If the item is non-nil, it must return the number of sub items for that item. Note that the 'item' parameter is only an identifier ('id' in Obj-C parlour) the item itself does not need to implement anything specific.

Given that, the implementation of outlineView:numberOfChildrenOfItem can be simpel:

    func outlineView(outlineView: NSOutlineView, numberOfChildrenOfItem item: AnyObject?) -> Int {
        if item == nil { return domains.count }
        return Domain.nofContainedItems

    }

When 'item' is nil, it returns the number of domains. Since I am aiming to keep the controller free of any specific Domain or Domains knowledge, it is best to define this constant in the Domain class:

class Domain {
    static let nofContainedItems: Int = 4
    var name: String {..}

The next operation outlineView:child:ofItem should return an ITEM IDENTIFIER used in subsequent operations to determine which information should be how displayed or edited. This identifier will be the ITEM in subsequent operations.
There is NO NEED for this item to refer to the actual item that will be displayed!
Note that we should NEVER change the item identifier as long as the corresponding item is visible in the outline view!

I used bold capitals because this is crucial to understanding how the NSOutlineView works. Get this right, and everything else is easy. Frankly, I hope that Apple will change this in future releases and adopt a protocol for this. The current approach makes sense for Obj-C, but it simply makes no sense in Swift.

This is the implementation I used for outlineView:child:ofItem :

    func outlineView(outlineView: NSOutlineView, child index: Int, ofItem item: AnyObject?) -> AnyObject {
        if item == nil {
            for (i, d) in domains.enumerate() {
                if i == index { return d }
            }
            return domains // should never happen
        }
        for d in domains {
            if item === d {
                if let result = d.itemForIndex(index) { return result }
                // should never pass here
            }
        }
        return "Index out of range error"

    }

If the 'item' is nil, the 'index' will give the index of the top level. For me that is simply the Domain class object. This is the reason I made the Domain a class and not a Struct!
If the item is not nil, it will refer to a Domain at the top level. Hence it is looked up in the present Domains and it returns the identifier of a sub-item of the domain. Note that an operation has been implemented on the domain to return the actual value of the identifier. This way we can avoid including knowledge about the internals of the domain inside the controller. The implementation of the d.itemForIndex(index) is as follows:

extension Domain {
    
    func itemForIndex(index: Int) -> AnyObject? {
        switch index {
        case 0: return wwwIncludedItemTitle
        case 1: return enabledItemTitle
        case 2: return rootItemTitle
        case 3: return forwardUrlItemTitle
        default: return nil
        }

    }
}

Note that it makes sense to implement this as an extension because it has nothing to do with the actual data in the domain.
Also note that there is no reference to any of the previously defined members of the domain. Instead new objects have been introduced:

class Domain {
        
    let nameItemTitle: NSString = "Domain"
    let wwwIncludedItemTitle: NSString = "Also map 'www' prefix:"
    let rootItemTitle: NSString = "Root folder:"
    let forwardUrlItemTitle: NSString = "Foreward to URL:"
    let enabledItemTitle: NSString = "Enable Domain:"

The reason for this is that the members themselves can change over the lifetime of the domain  because they are structs, not objects. But the identifier used by the NSOutlineView must remain the same over the lifetime over the GUI elements.

Since we also need some label text to display for each member, we can just as well use the same object for both. Normally you would probably want to make these definitions 'static'. Resist that urge here, remember that the identifier of the sub-items must be different for each domain. Yes, it will take up a little extra storage, but so what? don't start optimizing until its really necessary. Also note that the compiler is really clever, so introducing an in-between object will most likely be optimized away and you will wonder afterwards why the NSOutlineView does not work. So don't make these static!

With this infrastructure in place, the rest becomes easy:

To find out if an item is expandable:

    func outlineView(outlineView: NSOutlineView, isItemExpandable item: AnyObject) -> Bool {
        guard let ditem = item as? Domain else { return false }
        return domains.contains(ditem)
    }

I.e. only the domains them self are expandable, not the sub-items.

The all-important content provider:

    func outlineView(outlineView: NSOutlineView, objectValueForTableColumn tableColumn: NSTableColumn?, byItem item: AnyObject?) -> AnyObject? {
        if tableColumn === domainNameColumn {
            for d in domains {
                if let title = d.titleForItem(item) { return title }
            }
        } else if tableColumn === domainValueColumn {
            for d in domains {
                if item === d { return "" }
                if let value = d.valueForItem(item) { return value }
            }
        }
        return nil
    }

This operation returns the values that are actually displayed in the NSOutlineView. Note that we need to be able to differentiate between columns and do so by having an IBOutlet for each column. This way we can find out if we need to display the label or the actual value.
The d.titleForItem(item) and d.valueForItem(item) operations are also extensions on the Domain class:

    func titleForItem(item: AnyObject?) -> NSString? {
        if item === self { return name as NSString }
        if item === wwwIncludedItemTitle { return wwwIncludedItemTitle }
        if item === enabledItemTitle { return enabledItemTitle }
        if item === rootItemTitle { return rootItemTitle }
        if item === forwardUrlItemTitle { return forwardUrlItemTitle }
        return nil
    }
    
    func valueForItem(item: AnyObject?) -> NSString? {
        if item === self { return name as NSString }
        if item === wwwIncludedItemTitle { return wwwIncluded.description as NSString }
        if item === enabledItemTitle { return enabled.description as NSString }
        if item === rootItemTitle { return root as NSString }
        if item === forwardUrlItemTitle {
            if forwardUrl.isEmpty { return "-" }
            return forwardUrl as NSString
        }
        return nil
    }

By now you should have no problem implementing outlineView:shouldEditTableColumn:item and  outlineView:setObjectValue:forTableColumn:byItem yourself. They follow the same pattern as above.

Conclusion

NSOutlineView is not the easiest class to use, but once the trick with the item identifiers is understood it also does not pose a big problem anymore. The example above also shows how to isolate the know how of the data internals from the GUI. This makes updating the data structures much easier as all changes are made to the data structures locally (or in the extension) and the GUI will automatically follow the data as it evolves.

Happy coding...

Did this help?, then please help out a small independent.
If you decide that you want to make a small donation, you can do so by clicking this
link: a cup of coffee ($2) or use the popup on the right hand side for different amounts.
Payments will be processed by PayPal, receiver will be sales at balancingrock dot nl
Bitcoins will be gladly accepted at: 1GacSREBxPy1yskLMc9de2nofNv2SNdwqH

We don't get the world we wish for... we get the world we pay for.

No comments:

Post a Comment