2

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 and selectionIndexPaths 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, and foo 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!

Charles Srstka
  • 16,665
  • 3
  • 34
  • 60
  • Possible duplicate of [KVO: +keyPathsForValuesAffecting doesn't work with (subclass of) NSObjectController](https://stackoverflow.com/questions/15966158/kvo-keypathsforvaluesaffectingkey-doesnt-work-with-subclass-of-nsobjectco) – Willeke Nov 17 '17 at 14:07

0 Answers0