Search code examples
swiftxcodemacoscocoacocoa-bindings

Using Core Data and Cocoa Bindings in multiple storyboard scenes


I have a macOS app – not document based – that is using Cocoa Bindings, Core Data, and storyboards. The data model is straightforward...

List
  Name
  Players; to many relationship to Player

Player
  Name
  Lists; to-many relationship to List

And the storyboard has the following layout...

Window Controller
  Split View Controller
    View Controller
      Table View
    View Controller
      Table View
    View Controller
      Label

What I'm trying to figure out is how to properly share the managed object context across the three view controllers, and keep the two table views and the label in sync. Using the answer in this question I have something that's almost functional, albeit slightly different because I have a many-to-many relationship so need an extra table view.

I have set up an intermediate controller object that has a reference to the managed object context, two arrays of indexes used to keep the various array controllers in sync, and the initial sort descriptors.

class Controller: NSObject {
  @objc var moc = ...
  @objc var listSelectionIndexes = IndexSet()
  @objc var playerSelectionIndexes = IndexSet()
  @objc var sortDescriptors = [NSSortDescriptor(key: "name", ascending: true)]
} 

I then have a subclass of NSSplitViewController that I use to inject this controller into the three view controllers...

class SplitViewController: NSSplitViewController {
    private let controller = Controller()

    override func awakeFromNib() {
        super.awakeFromNib()
        children.forEach {
            $0.representedObject = controller
        }
    }
}

In the left-most controller I have a single array controller that is set to preserve its selection and prepare its content, and has the following bindings...

[Entity: List]
Selection Indexes ~> View Controller.self.representedObject.listSelectionIndexes
Managed Object Context ~> View Controller.self.representedObject.moc

and the corresponding table view...

Content ~> Array Controller.arrangedObjects
Selection Indexes ~> Array Controller.selectionIndexes
Sort Descriptors ~> Array Controller.sortDescriptors

In the middle controller I have two array controllers that are both set to preserve their selection and prepare their content, and have the following bindings...

[Array Controller 1; Entity: List]
Selection Indexes ~> View Controller.self.representedObject.listSelectionIndexes
Managed Object Context ~> View Controller.self.representedObject.moc

[Array Controller 2; Entity: Player]
Content Set ~> Array Controller 1.selection.players
Selection Indexes ~> View Controller.self.representedObject.playerSelectionIndexes
Sort Descriptors ~> View Controller.self.representedObject.sortDescriptors
Managed Object Context ~> View Controller.self.representedObject.moc

and the corresponding table view bound to array controller 2...

Content ~> Array Controller 2.arrangedObjects
Selection Indexes ~> Array Controller 2.selectionIndexes
Sort Descriptors ~> Array Controller 2.sortDescriptors

Finally, the right-most view controller, I have a single array controller that is set to prepare its content but not preserve selection, and has the following bindings...

[Entity: Player]
Selection Indexes ~> View Controller.self.representedObject.playerSelectionIndexes
Sort Descriptors ~> View Controller.self.representedObject.sortDescriptors
Managed Object Context ~> View Controller.self.representedObject.moc

And the label has a single binding...

Value ~> Array Controller.selection.name

You can see in this screenshot that things are almost working... enter image description here I say almost because if I sort the middle table using its header, I lose the right-most selection, even though the middle selection is preserved... enter image description here Also, if I click on the already selected row it doesn't update the third pane – I have to click off and then on again to get it to update. Selecting any other row works as expected.

If I then enable Preserve Selection on the array controller in the right-most view controller a selection is preserved, but it's the wrong one because it looks like the sort order isn't being synced... enter image description here

How can I fix this issue re: the sort order and selection in the right-most view controller?

And a more general question, is this really the best approach? It seems like an awful lot of plumbing – almost feels like a hack – just to be able to keep data in sync across scenes using bindings, and I'm also concerned about the cost of using multiple array controllers all holding the same information, especially if there were thousands of records.


Solution

  • Instead of adding array controllers to reconstruct the selected list or player it's more straightforward to transfer the selected list and player to the other view controller. It requires some code to change the selected list and player when the selection in the table view changes.

    class Controller: NSObject {
        @objc dynamic var moc = …
        @objc dynamic weak var selectedList : List?
        @objc dynamic weak var selectedPlayer : Player?
    }
    

    Left-most List view controller:

    The view controller is the delegate of the table view.

    class ListViewController: NSViewController, NSTableViewDelegate {
    
        @IBOutlet var arrayController: NSArrayController!
    
        func tableViewSelectionDidChange(_ notification: Notification) {
            let controller = representedObject as! Controller
            if arrayController.selectedObjects.count == 1,
                let list = arrayController.selectedObjects[0] as? List {
                controller.selectedList = list
            }
            else {
                controller.selectedList = nil
                controller.selectedPlayer = nil // tableViewSelectionDidChange isn't called on the Player table view
            }
        }
    
    }
    

    Middle Player view controller:

    The view controller is the delegate of the table view.

    class PlayerViewController: NSViewController, NSTableViewDelegate {
        
        @IBOutlet var arrayController: NSArrayController!
    
        func tableViewSelectionDidChange(_ notification: Notification) {
            let controller = representedObject as! Controller
            if arrayController.selectedObjects.count == 1,
                let player = arrayController.selectedObjects[0] as? Player {
                controller.selectedPlayer = player
            }
            else {
                controller.selectedPlayer = nil
            }
        }
        
    }
    

    Bind the Content Set of the Player array controller to View Controller.representedObject.selectedList.players

    Right-most Detail view controller:

    Bind the Value of the text field to View Controller.representedObject.selectedPlayer.name