Search code examples
swiftmacoscocoansoutlineview

Building an NSOutline view with Check marks


I am looking to add checkboxes to NSOutlineview using the correct Apple recommended method - however its not clear from the documentation.

How do I add the behavour to allow users whereby if I click a parent checkbox, then it will select the children, and if I unclick it - it will deselect the children of that item?

edit: I have simplified my question and added image to make it clearer ( hopefully)

My Approach: I have been using the wonderful answer by Code Different to build an Outline view in my mac app. https://stackoverflow.com/a/45384599/559760 - I chose to populate the NSoutLine view using a manual process instead of using CocoaBindings.

I added in a stack view including a check box which seems to be the right approach:

Example with the checkbox

My solution involves creating an array to hold the selected items in the viewcontroller and then creating functions for adding and removing

var selectedItems: [Int]?

@objc func cellWasClicked(sender: NSButton) {
    let newCheckBoxState = sender.state
    let tag = sender.tag
    switch newCheckBoxState {
    case NSControl.StateValue.on:
        print("adding- \(sender.tag)")
    case NSControl.StateValue.off:
        print("removing- \(sender.tag)")
    default:
        print("unhandled button state \(newCheckBoxState)")
    }

I identify the checkbutton by the tag that was assigned to the checkbox


Solution

  • In the interest of future Googlers I will repeat things I've written in my other answer. The difference here is this has the extra requirement that a column is editable and I have refined the technique.


    The key to NSOutlineView is that you must give an identifier to each row, be it a string, a number or an object that uniquely identifies the row. NSOutlineView calls this the item. Based on this item, you will query your data model to populate the outline.

    In this answer we will setup an outline view with 2 columns: an editable Is Selected column and a non-editable Title column.


    Interface Builder setup

    • Select the first column and set its identifier to isSelected
    • Select the second column and set its identifier to title

    set column identifier

    • Select the cell in the first column and change its identifier to isSelectedCell
    • Select the cell in the second column and change its identifier to titleCell

    set cell identifier

    Consistency is important here. The cell's identifier should be equal to its column's identifier + Cell.


    The cell with a checkbox

    The default NSTableCellView contains a non-editable text field. We want a check box so we have to design our own cell.

    CheckboxCellView.swift

    import Cocoa
    
    /// A set of methods that `CheckboxCelView` use to communicate changes to another object
    protocol CheckboxCellViewDelegate {
        func checkboxCellView(_ cell: CheckboxCellView, didChangeState state: NSControl.StateValue)
    }
    
    class CheckboxCellView: NSTableCellView {
    
        /// The checkbox button
        @IBOutlet weak var checkboxButton: NSButton!
    
        /// The item that represent the row in the outline view
        /// We may potentially use this cell for multiple outline views so let's make it generic
        var item: Any?
    
        /// The delegate of the cell
        var delegate: CheckboxCellViewDelegate?
    
        override func awakeFromNib() {
            checkboxButton.target = self
            checkboxButton.action = #selector(self.didChangeState(_:))
        }
    
        /// Notify the delegate that the checkbox's state has changed
        @objc private func didChangeState(_ sender: NSObject) {
            delegate?.checkboxCellView(self, didChangeState: checkboxButton.state)
        }
    }
    

    Connecting the outlet

    • Delete the default text field in the isSelected column
    • Drag in a checkbox from Object Library
    • Select the NSTableCellView and change its class to CheckboxCellView
    • Turn on the Assistant Editor and connect the outlet

    set custom class and connect the outlet


    The View Controller

    And finally the code for the view controller:

    import Cocoa
    
    
    /// A class that represents a row in the outline view. Add as many properties as needed
    /// for the columns in your outline view.
    class OutlineViewRow {
        var title: String
        var isSelected: Bool
        var children: [OutlineViewRow]
    
        init(title: String, isSelected: Bool, children: [OutlineViewRow] = []) {
            self.title = title
            self.isSelected = isSelected
            self.children = children
        }
    
        func setIsSelected(_ isSelected: Bool, recursive: Bool) {
            self.isSelected = isSelected
            if recursive {
                self.children.forEach { $0.setIsSelected(isSelected, recursive: true) }
            }
        }
    }
    
    /// A enum that represents the list of columns in the outline view. Enum is preferred over
    /// string literals as they are checked at compile-time. Repeating the same strings over
    /// and over again are error-prone. However, you need to make the Column Identifier in
    /// Interface Builder with the raw value used here.
    enum OutlineViewColumn: String {
        case isSelected = "isSelected"
        case title = "title"
    
        init?(_ tableColumn: NSTableColumn) {
            self.init(rawValue: tableColumn.identifier.rawValue)
        }
    
        var cellIdentifier: NSUserInterfaceItemIdentifier {
            return NSUserInterfaceItemIdentifier(self.rawValue + "Cell")
        }
    }
    
    
    class ViewController: NSViewController {
        @IBOutlet weak var outlineView: NSOutlineView!
    
        /// The rows of the outline view
        let rows: [OutlineViewRow] = {
            var child1 = OutlineViewRow(title: "p1-child1", isSelected: true)
            var child2 = OutlineViewRow(title: "p1-child2", isSelected: true)
            var child3 = OutlineViewRow(title: "p1-child3", isSelected: true)
            let parent1 = OutlineViewRow(title: "parent1", isSelected: true, children: [child1, child2, child3])
    
            child1 = OutlineViewRow(title: "p2-child1", isSelected: true)
            child2 = OutlineViewRow(title: "p2-child2", isSelected: true)
            child3 = OutlineViewRow(title: "p2-child3", isSelected: true)
            let parent2 = OutlineViewRow(title: "parent2", isSelected: true, children: [child1, child2, child3])
    
            child1 = OutlineViewRow(title: "p3-child1", isSelected: true)
            child2 = OutlineViewRow(title: "p3-child2", isSelected: true)
            child3 = OutlineViewRow(title: "p3-child3", isSelected: true)
            let parent3 = OutlineViewRow(title: "parent3", isSelected: true, children: [child1, child2, child3])
    
            child3 = OutlineViewRow(title: "p4-child3", isSelected: true)
            child2 = OutlineViewRow(title: "p4-child2", isSelected: true, children: [child3])
            child1 = OutlineViewRow(title: "p4-child1", isSelected: true, children: [child2])
            let parent4 = OutlineViewRow(title: "parent4", isSelected: true, children: [child1])
    
            return [parent1, parent2, parent3, parent4]
        }()
    
        override func viewDidLoad() {
            super.viewDidLoad()
            outlineView.dataSource = self
            outlineView.delegate = self
        }
    }
    
    extension ViewController: NSOutlineViewDataSource, NSOutlineViewDelegate {
        /// Returns how many children a row has. `item == nil` means the root row (not visible)
        func outlineView(_ outlineView: NSOutlineView, numberOfChildrenOfItem item: Any?) -> Int {
            switch item {
            case nil: return rows.count
            case let row as OutlineViewRow: return row.children.count
            default: return 0
            }
        }
    
        /// Returns the object that represents the row. `NSOutlineView` calls this the `item`
        func outlineView(_ outlineView: NSOutlineView, child index: Int, ofItem item: Any?) -> Any {
            switch item {
            case nil: return rows[index]
            case let row as OutlineViewRow: return row.children[index]
            default: return NSNull()
            }
        }
    
        /// Returns whether the row can be expanded
        func outlineView(_ outlineView: NSOutlineView, isItemExpandable item: Any) -> Bool {
            switch item {
            case nil: return !rows.isEmpty
            case let row as OutlineViewRow: return !row.children.isEmpty
            default: return false
            }
        }
    
        /// Returns the view that display the content for each cell of the outline view
        func outlineView(_ outlineView: NSOutlineView, viewFor tableColumn: NSTableColumn?, item: Any) -> NSView? {
            guard let item = item as? OutlineViewRow, let column = OutlineViewColumn(tableColumn!) else { return nil }
    
            switch column {
            case .isSelected:
                let cell = outlineView.makeView(withIdentifier: column.cellIdentifier, owner: self) as! CheckboxCellView
                cell.checkboxButton.state = item.isSelected ? .on : .off
                cell.delegate = self
                cell.item = item
                return cell
    
            case .title:
                let cell = outlineView.makeView(withIdentifier: column.cellIdentifier, owner: self) as! NSTableCellView
                cell.textField?.stringValue = item.title
                return cell
            }
        }
    }
    
    extension ViewController: CheckboxCellViewDelegate {
        /// A delegate function where we can act on update from the checkbox in the "Is Selected" column
        func checkboxCellView(_ cell: CheckboxCellView, didChangeState state: NSControl.StateValue) {
            guard let item = cell.item as? OutlineViewRow else { return }
    
            // The row and its children are selected if state == .on
            item.setIsSelected(state == .on, recursive: true)
    
            // This is more efficient than calling reload on every child since collapsed children are
            // not reloaded. They will be reloaded when they become visible
            outlineView.reloadItem(item, reloadChildren: true)
        }
    }
    

    Result

    result