Search code examples
swiftmacoscocoacocoa-bindings

Binding NSTableView to NSArrayController programmatically


I'm trying to bind an NSTextView to an NSArrayController in code, and have followed a few questions already asked on the subject: here, here, and here. I'm building a MacOS app using Xcode 9.2 and Swift 4.

I have a SegmentedControl with 2 buttons: one to add a new object and the other to remove. I'm crashing when I try to bind the a NSTextField to a newly created object added to the array.

My code for the VeiwController:

private enum Columns {
  static let name = NSUserInterfaceItemIdentifier("NameColumn")
  static let age = NSUserInterfaceItemIdentifier("AgeColumn")
}

let arrayController: NSArrayController

let table: NSTableView

let buttons: NSSegmentedControl

// MARK: —Initialisation—

required init() {
  super.init(nibName: nil, bundle: nil)
  /// Code to create arrayController, table, and buttons 
  ///...

  /// Bindings.
  table.bind(.content,
             to: self.arrayController,
             withKeyPath: "arrangedObjects",
             options: nil)
  /// Delegates
  table.delegate = self
  buttons.target = self
  buttons.action = #selector(splitButtonClicked)
}

The function to add/remove objects from the arrayController. Person is a simple class with 2 properties (Name and Age):

@objc func splitButtonClicked() {
  switch buttons.selectedSegment {
  case 0:
    let person = Person()
    person.name = "Alfred"
    person.age = 21
    arrayController.addObject(person)
   case 1:
    arrayController.removeObject(arrayController.selectedObjects)
  default:
    print("Switch case error")
  }
}

The code to create a new table row returns a new NSTextField, but I'm crashing when I try to bind the text field and I'm not sure why:

func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? {
  guard let column = tableColumn else { return nil }
  let t = NSTextField(frame: NSRect(x: 0, y: 0, width: column.width, height: table.rowHeight))
  if column.identifier == Columns.name {
    /// Crashing on this line.
    t.bind(.value, to: t, withKeyPath: "objectValue.name", options: nil)
  } else if column.identifier == Columns.age {
    t.bind(.value, to: t, withKeyPath: "objectValue.age", options: nil)
  }
  return t
} 

Solution

  • These 2 lines:

    let t = NSTextField(...)
    t.bind(.value, to: t, withKeyPath: "objectValue.name", options: nil)
    

    You are binding the text field to itself, on a non-existing key path. If you expand the view hierarchy of a table view, it goes like this:

    Table View > Column > Cell View > Text Field
    

    What you need to provide is the Cell View, with the text field nested inside.

    fileprivate extension NSUserInterfaceItemIdentifier {
        static let cellView = NSUserInterfaceItemIdentifier("CellView")
        static let name = NSUserInterfaceItemIdentifier("NameColumn")
        static let age = NSUserInterfaceItemIdentifier("AgeColumn")
    }
    
    extension ViewController: NSTableViewDelegate, NSTableViewDataSource {
        private func makeCell(frame: CGRect) -> NSTableCellView {
            // Reuse an existing cell if possible
            if let existingCell = table.makeView(withIdentifier: .cellView, owner: nil) as? NSTableCellView {
                return existingCell
            }
    
            // Make a new cell
            let textField = NSTextField(frame: frame)
            textField.drawsBackground = false
            textField.isBordered = false
    
            let cell = NSTableCellView(frame: frame)
            cell.identifier = .cellView
            cell.textField = textField
            cell.addSubview(textField)
            return cell
        }
    
        func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? {
            guard let column = tableColumn else { return nil }
    
            let cell = makeCell(frame: NSRect(x: 0, y: 0, width: column.width, height: table.rowHeight))
    
            // Varying the binding based on what column is being requested
            switch column.identifier {
            case .name:
                cell.textField?.bind(.value, to: cell, withKeyPath: "objectValue.name", options: nil)
            case .age:
                cell.textField?.bind(.value, to: cell, withKeyPath: "objectValue.age", options: nil)
            default:
                print("Unrecognized column '\(column.identifier)'")
            }
            return cell
        }
    }