How can I use Key-Value Observing with Smart KeyPaths in Swift 4?

Tora picture Tora · Jun 29, 2017 · Viewed 7.8k times · Source

Could you help me how to manage to be notified when the contents of NSArrayController are modified, using Smart KeyPaths?

Inspired by

Key-Value Observing: https://developer.apple.com/library/content/documentation/Swift/Conceptual/BuildingCocoaApps/AdoptingCocoaDesignPatterns.html#//apple_ref/doc/uid/TP40014216-CH7-ID12

Smart KeyPaths: Better Key-Value Coding for Swift: https://github.com/apple/swift-evolution/blob/master/proposals/0161-key-paths.md

I mimicked the example code of the article.

class myArrayController: NSArrayController {
  required init?(coder: NSCoder) {
    super.init(coder: coder)

    observe(\.content, options: [.new]) { object, change in
      print("Observed a change to \(object.content.debugDescription)")
    }
  }
}

However, that is not working. Any changes made on the target object does not fire notification.

In contrast, the typical way listed below is working.

class myArrayController: NSArrayController {
  required init?(coder: NSCoder) {
    super.init(coder: coder)

    addObserver(self, forKeyPath: "content", options: .new, context: nil)
  }

  override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
    if keyPath == "content" {
      print("Observed a change to \((object as! myArrayController).content.debugDescription)")
    }
    else {
      super.observeValue(forKeyPath: keyPath, of: object, change: change, context: context)
    }
  }
}

The new way looks more elegant. Any your suggestions?

Environment: Xcode 9 Beta

  • macOS, Cocoa App, Swift 4
  • Create Document-Based Application
  • Use Core Data

  • myArrayController's Mode is Entity Name, prepared with Document.xcdatamodeld

  • myArrayController's Managed Object Context is bound to Model Key Path: representedObject.managedObjectContext
  • representedObject is assigned with the instance of Document.
  • NSTableView's Content, Selection Indexes, and Sort Descriptors are bound to the correspondences of the myArrayController.

More information on the environment: Binding managedObjectContext, Xcode 8.3.2, Storyboards, mac: https://forums.bignerdranch.com/t/binding-managedobjectcontext-xcode-8-3-2-storyboards-macos-swift/12284

EDITED:

Regarding the example case cited above, I have changed my mind to observe managedObjectContext, instead of content of NSArrayController.

class myViewController: NSViewController {

  override func viewWillAppear() {
    super.viewWillAppear()

    let n = NotificationCenter.default
    n.addObserver(self, selector: #selector(mocDidChange(notification:)),
                  name: NSNotification.Name.NSManagedObjectContextObjectsDidChange,
                  object: (representedObject as! Document).managedObjectContext)
    }
  }

  @objc func mocDidChange(notification n: Notification) {
    print("\nmocDidChange():\n\(n)")
  }

}

The reason is that this second approach is simpler than the first one. This code covers all of the desired requirements: additions and deletions of table rows, and modifications of table cells' value. The drawback is that every another table's modification and every yet another entities' modification within the App will cause notifications. Such a notification is not interesting, though. However, that is not a big deal.

In contrast, the first approach will require more complexity.

For additions and deletions, we would need either observing content of NSArrayController or implementing two functions

func tableView(_ tableView: NSTableView, didAdd rowView: NSTableRowView, forRow row: Int)
func tableView(_ tableView: NSTableView, didRemove rowView: NSTableRowView, forRow row: Int)

from NSTableViewDelegate. NSTableView's delegate is connected to NSViewController.

Slightly surprisingly, the both tableView() functions will be called so frequently. For instance, in the situation where there are ten rows in a table, sorting rows will result in ten didRemove calls followed by ten didAdd calls; adding one row will result in ten didRemove calls and then eleven didAdd calls. That is not so efficient.

For modifications, we would need

func control(_ control: NSControl, textShouldEndEditing fieldEditor: NSText) -> Bool

from NSControlTextEditingDelegate, a super of NSTableViewDelegate. Every NSTextField of each table column should be connected to NSViewController via its delegate.

Furthermore, unfortunately, this control() is called right after text edition is completed, but rather, before the actual value in the NSArrayController has been updated. That is, somewhat, useless. I have not yet found good solution with the first approach.

ANYWAY, the primary topic in this post is how to use Smart KeyPaths. :-)

EDITED 2:

I am going to use both

  1. observing a property content of NSArrayController ... the first one
  2. observing a Notification being posted by NSManagedObjectContext ... the second one

The 1 is for when a user changes master-details view, which does not make a change on NSManagedObjectContext.

The 2 is for when a user makes a change on it: addition, removal, updating, as well as undo, Command-Z, which is not accompanied by mouse events.

For now, the version of addObserver(self, forKeyPath: "content", ... will be used. Once the question of this post has been solved, I will switch to the version of observe(\.content, ...

Thanks.

EDITED 3:

The code 2. observing a Notification has been completely replaced with new one.

Answer

Aditya Vaidyam picture Aditya Vaidyam · Jul 14, 2017

As for your initial code, here's what it should look like:

class myArrayController: NSArrayController {
    private var mySub: Any? = nil

    required init?(coder: NSCoder) {
        super.init(coder: coder)

        self.mySub = self.observe(\.content, options: [.new]) { object, change in
            debugPrint("Observed a change to", object.content)
        }
    }
}

The observe(...) function returns a transient observer whose lifetime indicates how long you'll receive notifications for. If the returned observer is deinit'd, you will no longer receive notifications. In your case, you never retained the object so it died right after the method scope.

In addition, to manually stop observing, just set mySub to nil, which implicitly deinits the old observer object.