A source list with bindings

I had what I thought was a simple problem: I wanted to create a source list (using an NSOutlineView), where each section displayed a different kind of Core Data object, plus a few static rows for good measure. It was harder than I thought, so here’s my solution.

I wanted to use bindings, so that the UI will always update as new objects are created. The problem is, I couldn’t bind to my Core Data entities, because the NSTreeController expects just one kind of object throughout its tree. Also, my groups and my static rows aren’t in the database.

What I needed were some proxy objects that represented sidebar items. That should be easy… except that they’ll need to support bindings. Here’s what I did.

First, set up a new outline view to be the source list. I’m using the new view-based outline view that came along in Lion. There’s a pre-defined “Source list” component in Xcode that’ll do the job:

Next, we need an NSTreeController. Set its class to “BrowserItem” and its keypath to “children” — we’ll create the class later.

Now we bind the tree controller’s content array to a property on your controller object (i.e. your window controller or view controller — depending how you’ve architected your app), that will hold the array of top-level sidebar items.

Also, you’ll need to set your controller object up as the delegate and data source of your outline view, and you’ll need to bind the text field inside the table cell to its table cell view, keypath “objectValue.title”.

(I’m slightly skimming over this bit, since I’m not trying to write a beginner tutorial, I’m just trying to let you know how I have things configured.)

Now, in your controller (for me it’s a BrowserWindowController), create the sidebarItems property (it’s an NSArray), and also set up an outlet for the outline view so you can refer to it.

Creating the Browser Item class

Make a new NSObject subclass, and give it some properties:

@property (nonatomic, strong) NSString *title;
@property (nonatomic, strong) NSArrayController *childItemsArrayController;
@property (nonatomic, strong) NSString *childItemTitleKeypath;
@property (nonatomic, strong) NSMutableArray *children;
- (void)bindChildItemsToArrayKeypath:(NSString*)keypath onObject:(id)object;
@property (nonatomic, assign) BOOL isGroup;
@property (nonatomic, strong) NSMapTable *modelToItemMapping;

What we’re doing is making a class that will represent all of your sidebar items, including watching for changes to your Core Data model and updating its children accordingly.

In your init method, set up a few properties:

- (id)init
{
	if (self = [super init])
	{
		self.children = [NSMutableArray array];
		self.modelToItemMapping = [NSMapTable weakToStrongObjectsMapTable];
		self.childItemsArrayController = [[NSArrayController alloc] init];
		
		[self.childItemsArrayController addObserver:self forKeyPath:@"arrangedObjects" options:NSKeyValueObservingOptionPrior context:NULL];
	}
	return self;
}

The children array is to represent the proxy sidebar items we make for each Core Data object.

The modelToItemMapping is a way of having one proxy object for each model item.

The childItemsArrayController is the thing that actually watches the model.

Next we need the method to bind it to the model:

- (void)bindChildItemsToArrayKeypath:(NSString*)keypath onObject:(id)object;
{
	[self.childItemsArrayController bind:@"contentArray" toObject:object withKeyPath:keypath options:nil];
	self.isGroup = YES;
}

That’s just a convenience method so that we don’t need to expose the childItemsArrayController outside our BrowserItem class.

Finally, we need one more method: the KVO method for when the arrangedObjects of the array controller change:

- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context
{
	if ([keyPath isEqualToString:@"arrangedObjects"])
	{
		[self willChangeValueForKey:@"children"];
		NSMutableArray *watchableChildren = [self mutableArrayValueForKey:@"children"];
		
		[watchableChildren removeAllObjects];
		for (id modelItem in self.childItemsArrayController.arrangedObjects)
		{
			BrowserItem *browserItem = [self.modelToItemMapping objectForKey:modelItem];
			
			if (!browserItem)
			{
				browserItem = [BrowserItem itemWithTitle:[modelItem valueForKey:self.childItemTitleKeypath]];
				[self.modelToItemMapping setObject:browserItem forKey:modelItem];
			}
			[watchableChildren addObject:browserItem];
		}
		
		[self didChangeValueForKey:@"children"];
	}
}

This is where the magic happens. Whenever the watched model property changes (and our array controller picks up on that through bindings), this method is called. We then go and make new browserItems to represent each item in the model (or reuse them if we already have one), storing them in the children array. Since we’re KVO compliant, that means that the NSTreeController can observe this and update the UI.

(The above method could be better: it could actually check for insertions and deletions in the array controller’s arrangedObjects, and use the correct KVC accessors to proxy them to our children array. Have that as an exercise to the reader!)

So, how do we use it? Back in our window controller, we need to set up the sidebarItems array. Call the following in init or awakeFromNib.

- (void)setUpSidebarItems
{
	BrowserItem *inbox = [BrowserItem itemWithTitle:@"Inbox" ];
	BrowserItem *allTickets = [BrowserItem itemWithTitle:@"All Tickets" ];
	BrowserItem *following = [BrowserItem itemWithTitle:@"My Followed Tickets" ];
	
	self.milestonesSidebarGroup = [BrowserItem itemWithTitle:@"MILESTONES"];
	self.milestonesSidebarGroup.childItemTitleKeypath = @"title";
	[self.milestonesSidebarGroup bindChildItemsToArrayKeypath:@"selectedSpace.sortedMilestones" onObject:self];
	
	self.componentsSidebarGroup = [BrowserItem itemWithTitle:@"COMPONENTS"];
	self.componentsSidebarGroup.childItemTitleKeypath = @"name";
	[self.componentsSidebarGroup bindChildItemsToArrayKeypath:@"selectedSpace.sortedComponents" onObject:self];
	
	self.usersSidebarGroup = [BrowserItem itemWithTitle:@"USERS"];
	self.usersSidebarGroup.childItemTitleKeypath = @"username";
	[self.usersSidebarGroup bindChildItemsToArrayKeypath:@"selectedSpace.sortedUsers" onObject:self];
	
	self.sidebarItems = @[inbox, allTickets, following, self.milestonesSidebarGroup, self.usersSidebarGroup, self.componentsSidebarGroup];
}

(Note that I haven’t given you the code for itemWithTitle, but you can guess what it does.) For each group you want to create, you make a BrowserItem, you bind it to some keypath that represents the array of objects you want to have in that section, and you tell it the keypath on each object that refers to its title.

There’s only one thing left to do before anything will display. If you’re using a view-based NSOutlineView, you’ll need to implement the following in order to tell it what cell classes to use.

- (NSView *)outlineView:(NSOutlineView *)outlineView viewForTableColumn:(NSTableColumn *)tableColumn item:(NSTreeNode*)item
{
	if ([item.representedObject isGroup])
	{
		return [outlineView makeViewWithIdentifier:@"HeaderCell" owner:self];
	}
	return [outlineView makeViewWithIdentifier:@"DataCell" owner:self];
}

- (BOOL)outlineView:(NSOutlineView *)outlineView isGroupItem:(NSTreeNode*)item
{
	return [item.representedObject isGroup];
}

- (BOOL)outlineView:(NSOutlineView *)outlineView shouldSelectItem:(NSTreeNode*)item
{
	return ![item.representedObject isGroup];
}

Without the first of those methods, you’ll end up with blank cells.

There’s plenty of scope for future improvements. I haven’t described anything to handle clicking on the cells yet, nor yet for any form of editing (drag-reordering or whatever). Also, the proxy items don’t observe their model object’s title keypath, so they won’t track changes to it. However, I haven’t seen another solution to this problem that is bindings compatible, doesn’t involve changing anything in your Core Data setup, and correctly tracks updates to the model.

Previous
Previous

Using both Enterprise and App Store iTunes accounts

Next
Next

Kickstarter and ship dates