2
votes

I'm seeing some quirky behaviour with Cocoa's KVC/KVO and bindings. I have an NSArrayController object, with its 'content' bound to an NSMutableArray, and I have a controller registered as an observer of the arrangedObjects property on the NSArrayController. With this setup, I expect to receive a KVO notification every time the array is modified. However, it appears that the KVO notification is only sent once; the very first time the array is modified.

I set up a brand new "Cocoa Application" project in Xcode to illustrate the problem. Here is my code:

BindingTesterAppDelegate.h

#import <Cocoa/Cocoa.h>

@interface BindingTesterAppDelegate : NSObject <NSApplicationDelegate>
{
    NSWindow * window;
    NSArrayController * arrayController;
    NSMutableArray * mutableArray;
}
@property (assign) IBOutlet NSWindow * window;
@property (retain) NSArrayController * arrayController;
@property (retain) NSMutableArray * mutableArray;
- (void)changeArray:(id)sender;
@end

BindingTesterAppDelegate.m

#import "BindingTesterAppDelegate.h"

@implementation BindingTesterAppDelegate

@synthesize window;
@synthesize arrayController;
@synthesize mutableArray;

- (void)applicationDidFinishLaunching:(NSNotification *)notification
{
    NSLog(@"load");

    // create the array controller and the mutable array:
    [self setArrayController:[[[NSArrayController alloc] init] autorelease]];
    [self setMutableArray:[NSMutableArray arrayWithCapacity:0]];

    // bind the arrayController to the array
    [arrayController bind:@"content" // see update
                 toObject:self
              withKeyPath:@"mutableArray"
                  options:0];

    // set up an observer for arrangedObjects
    [arrayController addObserver:self
                      forKeyPath:@"arrangedObjects"
                         options:0
                         context:nil];

    // add a button to trigger events
    NSButton * button = [[NSButton alloc]
                         initWithFrame:NSMakeRect(10, 10, 100, 30)];
    [[window contentView] addSubview:button];
    [button setTitle:@"change array"];
    [button setTarget:self];
    [button setAction:@selector(changeArray:)];
    [button release];

    NSLog(@"run");
}

- (void)changeArray:(id)sender
{
    // modify the array (being sure to post KVO notifications):
    [self willChangeValueForKey:@"mutableArray"];
    [mutableArray addObject:[NSString stringWithString:@"something"]];
    NSLog(@"changed the array: count = %d", [mutableArray count]);
    [self didChangeValueForKey:@"mutableArray"];
}

- (void)observeValueForKeyPath:(NSString *)keyPath
                      ofObject:(id)object
                        change:(NSDictionary *)change
                       context:(void *)context
{
    NSLog(@"%@ changed!", keyPath);
}

- (void)applicationWillTerminate:(NSNotification *)notification
{
    NSLog(@"stop");
    [self setMutableArray:nil];
    [self setArrayController:nil];
    NSLog(@"done");
}

@end

And here is the output:

load
run
changed the array: count = 1
arrangedObjects changed!
changed the array: count = 2
changed the array: count = 3
changed the array: count = 4
changed the array: count = 5
stop
arrangedObjects changed!
done

As you can see, the KVO notification is only sent the first time (and once more when the application exits). Why would this be the case?

update:

Thanks to orque for pointing out that I should be binding to the contentArray of my NSArrayController, not just its content. The above posted code works, as soon as this change is made:

// bind the arrayController to the array
[arrayController bind:@"contentArray" // <-- the change was made here
             toObject:self
          withKeyPath:@"mutableArray"
              options:0];
3

3 Answers

7
votes

First, you should bind to the contentArray (not content):

    [arrayController bind:@"contentArray"
             toObject:self
          withKeyPath:@"mutableArray"
              options:0];

Then, the straightforward way is to just use the arrayController to modify the array:

- (void)changeArray:(id)sender
{
    // modify the array (being sure to post KVO notifications):
    [arrayController addObject:@"something"];
    NSLog(@"changed the array: count = %d", [mutableArray count]);
}

(in a real scenario you'll likely just want the button action to call -addObject:)

Using -[NSMutableArray addObject] will not automatically notify the controller. I see that you tried to work around this by manually using willChange/didChange on the mutableArray. This won't work because the array itself hasn't changed. That is, if the KVO system queries mutableArray before and after the change it will still have the same address.

If you want to use -[NSMutableArray addObject], you could willChange/didChange on arrangedObjects:

- (void)changeArray:(id)sender
{
    // modify the array (being sure to post KVO notifications):
    [arrayController willChangeValueForKey:@"arrangedObjects"];
    [mutableArray addObject:@"something"];
    NSLog(@"changed the array: count = %d", [mutableArray count]);
    [arrayController didChangeValueForKey:@"arrangedObjects"];
}

There may be a cheaper key that would give the same effect. If you have a choice I would recommend just working through the controller and leaving the notifications up to the underlying system.

5
votes

A much better way than explicitly posting whole-value KVO notifications is to implement array accessors and use them. Then KVO posts the notifications for free.

That way, instead of this:

[self willChangeValueForKey:@"things"];
[_things addObject:[NSString stringWithString:@"something"]];
[self didChangeValueForKey:@"things"];

You would do this:

[self insertObject:[NSString stringWithString:@"something"] inThingsAtIndex:[self countOfThings]];

Not only will KVO post the change notification for you, but it will be a more specific notification, being an array-insertion change rather than a whole-array change.

I usually add an addThingsObject: method that does the above, so that I can do:

[self addThingsObject:[NSString stringWithString:@"something"]];

Note that add<Key>Object: is not currently a KVC-recognized selector format for array properties (only set properties), whereas insertObject:in<Key>AtIndex: is, so your implementation of the former (if you choose to do that) must use the latter.

0
votes

Oh, I was looking for a long time for this solution ! Thanks to all ! After getting the idea & playing around , I found another very fancy way:

Suppose I have an object CubeFrames like this:

@interface CubeFrames : NSObject {
NSInteger   number;
NSInteger   loops;
}

My Array contains Objects of Cubeframes, they are managed via (MVC) by an objectController and displayed in a tableView. Bindings are done the common way: "Content Array" of the objectController is bound to my array. Important: set "Class Name" of objectController to class CubeFrames

If I add observers like this in my Appdelegate:

-(void)awakeFromNib {

//
// register ovbserver for array changes :
// the observer will observe  each item of the array when it changes:
//      + adding a cubFrames object
//      + deleting a cubFrames object
//      + changing values of loops or number in the tableview
[dataArrayCtrl addObserver:self forKeyPath:@"arrangedObjects.loops" options:0 context:nil];
[dataArrayCtrl addObserver:self forKeyPath:@"arrangedObjects.number" options:0 context:nil];
}

- (void)observeValueForKeyPath:(NSString *)keyPath
                  ofObject:(id)object
                    change:(NSDictionary *)change
                   context:(void *)context
{
    NSLog(@"%@ changed!", keyPath);
}

Now, indeed, I catch all the changes : adding and deleting rows, change on loops or number :-)