I've encountered a bit of a poser involving NSTreeController
and KVO. NSTreeController
's selectionIndexPaths
property is documented as being KVO-observable—and when I observe it directly, it works perfectly. However, if I list NSTreeController
's selectionIndexPath
as a dependency of some other property, and then try to observe that, the notifications are not fired when one would expect.
Here's the shortest sample code I could come up with to demonstrate what I mean:
import Cocoa
class ViewController: NSViewController {
// Our tree controller
@IBOutlet dynamic var treeController: NSTreeController!
// Some random property on my object; you'll see why it's here later
@objc dynamic var foo: String = "Foo"
// A quick-and-dirty class to give us something to populate our tree with
class Thingy: NSObject {
@objc let name: String
init(_ name: String) { self.name = name }
@objc var children: [Thingy] { return [] }
}
// The property that the tree controller's `Content Array` is bound to
@objc dynamic var thingies: [Thingy] = [Thingy("Foo"), Thingy("Bar")]
// Dependencies for selectionIndexPaths
@objc private static let keyPathsForValuesAffectingSelectionIndexPaths: Set<String> = [
#keyPath(treeController.selectionIndexPaths),
#keyPath(foo)
]
// This property should be dependent on the tree controller's selectionIndexPaths
// (and also on foo)
@objc dynamic var selectionIndexPaths: [IndexPath] {
return self.treeController.selectionIndexPaths
}
// Some properties to store our KVO observations
var observer1: NSKeyValueObservation? = nil
var observer2: NSKeyValueObservation? = nil
// And set up the observations
override func viewDidLoad() {
super.viewDidLoad()
self.observer1 = self.observe(\.selectionIndexPaths) { _, _ in
print("This is only logged when foo changes")
}
self.observer2 = self.observe(\.treeController.selectionIndexPaths) { _, _ in
print("This, however, is logged when the tree controller's selection changes")
}
}
// A button is wired to this; its purpose is to set off the
// KVO notifications for foo
@IBAction func changeFoo(_: Any?) {
self.foo = "Bar"
}
}
In addition, the following setup is done in the storyboard:
- Add a tree controller, and connect the view controller's
treeController
outlet to it. - Bind the tree controller's "Content Array" binding to
thingies
on the view controller. - Set the tree controller's "Children Key Path" to
children
. - Create an outline view, and bind its "Content" and "Selection Index Paths" bindings to
arrangedObjects
andselectionIndexPaths
respectively on the tree controller. - Create a button, and point it at the view controller's
changeFoo:
method.
If you'd like to try it yourself, I've uploaded a sample project here.
The behavior is as follows:
The notification for
observer2
is always fired whenever the outline view's (and thus the tree controller's) selection changes, as one would expect.However, the notification for
observer1
is not fired when the outline view's selection changes.However,
observer1
's notification is fired when the button is clicked, andfoo
is changed. This suggests that the property's dependencies are being considered, but just not for this one particular key path.Using the old-school method with an
observeValue(forKeyPath:bla:bla:bla:)
override instead of the swank Swift 4 closure-based system seems to behave the same way.
EDIT: Well, it's not Swift's fault! Same thing happens when I write this program in Objective-C:
@interface Thingy: NSObject
@property (nonatomic, copy) NSString *name;
- (instancetype)initWithName:(NSString *)name;
@end
@implementation Thingy
- (instancetype)initWithName:(NSString *)name {
self = [super init];
if (self == nil) {
return nil;
}
self->_name = name;
return self;
}
- (NSArray *)children { return @[]; }
@end
void *ctxt1 = &ctxt1;
void *ctxt2 = &ctxt2;
@interface ViewController()
@property (nonatomic, strong) IBOutlet NSTreeController *treeController;
@property (nonatomic, copy) NSString *foo;
@property (nonatomic, copy) NSArray *thingies;
@end
@implementation ViewController
+ (NSSet *)keyPathsForValuesAffectingSelectionIndexPaths {
return [NSSet setWithObjects:@"treeController.selectionIndexPaths", @"foo", nil];
}
- (NSArray *)selectionIndexPaths {
return self.treeController.selectionIndexPaths;
}
- (void)viewDidLoad {
[super viewDidLoad];
self.thingies = @[[[Thingy alloc] initWithName:@"Foo"], [[Thingy alloc] initWithName:@"Bar"]];
[self addObserver:self forKeyPath:@"selectionIndexPaths" options:0 context:ctxt1];
[self addObserver:self forKeyPath:@"treeController.selectionIndexPaths" options:0 context:ctxt2];
}
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context {
if (context == ctxt1) {
NSLog(@"This only gets logged when I click the button");
} else if (context == ctxt2) {
NSLog(@"This gets logged whenever the selection changes");
} else {
[super observeValueForKeyPath:keyPath ofObject:object change:change context:context];
}
}
- (IBAction)changeFoo:(__unused id)sender {
self.foo = @"Bar";
}
@end
I've been staring at this for a while, and I cannot figure out why directly observing treeController.selectionIndexPaths
works, but observing a property that depends on treeController.selectionIndexPaths
does not. And since I've generally felt like I had a pretty good handle on KVO and its workings, it is really bugging me that I can't explain this.
Does anyone know the reason for this discrepancy?
Thanks!