Tuesday 2 August 2011

NSViewControllers part 3 - NSCollectionView

Previously we looked at NSViewControllers and what they're used for in Appkit. One of the others places they are used is under the hood of NSCollectionView. NSCollectionView is a view that can display a collection of arbitrary views, hooked up to a model. NSCollectionView has a helper class, called NSCollectionViewItem, which it uses to create the necessary views for displaying. Does that sound familiar? Initially, NSCollectionViewItem was a subclass of NSObject, but when NSViewController was added in 10.6, NSCollectionViewItem had most of the same methods as NSViewController, so the class hierarchy was changed to make NSCollectionViewItem a subclass of NSViewController instead, and all of its methods were deprecated.

In writing the code for this article, I came across what appears to be a bug in XCode 4.1, which means that you cannot use XCode's interface designer to bind the data to the views, which is the correct way to do it. In this article, I'm going to be creating the child views manually as that appears to be the only way to get access to the controls in the view.

So let us begin at a look at what can be done with NSCollectionView.

Creating an NSCollectionView by hand


First we will need a model to display in the NSCollectionView. We'll create some simple data objects for the model, with a title string and an integer.

@interface Item: NSObject {
    NSString *title;
    NSInteger option;
}


@property (readwrite, copy) NSString *title;
@property (readwrite) NSInteger option;


- (id)initWithTitle:(NSString *)_title option:(NSInteger)_option;


@end


There's nothing surprising in the Item.m implementation of this object so if you want to see it, you can look at the project file at the end.

Now we have our data item, we need to create a model. In the application delegate, we create an NSMutableArray which will act as our model, and an NSArrayController which will be the controller in the MVC pattern. In this project, I'm actually going to create the array controller in the nib file, and assign it to an outlet in the application delegate. This will allow us to bind to it in the interface designer slightly easier.

The model was created in a very simple manner for testing purposes, in the init method of the application delegate


items = [[NSMutableArray alloc] init];
for (i = 1; i <= 20; i++) {
    [items addObject:[[Item alloc] initWithTitle:[NSString stringWithFormat:@"Item %d", i] option:(i - 1) % 4]];
}

I designed a simple interface and all it has is a NSCollectionView. Once you add an NSCollectionView to a window, XCode automatically adds an NSCollectionViewItem and an new NSView as well. These are going to be the view controller and the view that represents each item in our data model.



Setting up the bindings so that the NSCollectionView can find the data in the model. Firstly we need to tell the NSArrayController where to get its content. We bind the Array controller's contentArray property to the NSMutableArray property of the Application Delegate. I called this property items and so in the bindings panel of the NSArrayController I set it to bind to Application Delegate and the Model Key Path is set to items




Next we bind the content property of the NSCollectionView to the NSArrayController's arrangedObjects property. Now the NSCollectionView can see the contents of the NSMutableArray through the NSCollectionView.






NSCollectionViewItem is a subclass of NSViewController, and it has a property called representedObject. When each NSCollectionViewItem is created, this property is set to the object in the model that the NSCollectionViewItem represents. Under normal circumstances we could add controls to the NSView that XCode added for us, and then bind their values to the appropriate property on this representedObject property. For example, if we wanted to bind to the title property that our Item class has, we would select the NSCollectionViewItem as the binding source, and the model key value would be representedObject.title. However, this is what the bug in XCode prevents us from doing, so we need to carry this all out by hand. To do this, we must subclass the objects we want to use and override certain methods.


The first subclass we need is one for the NSCollectionView itself. NSCollectionView has a method 
-(NSCollectionViewItem *)newItemForRepresentedObject:(id)object which is called whenever the collection view needs to display a new object. So we create our subclass (MyCollectionView I called it) and in the interface designer we set the class of the NSCollectionView to be an instance of it in the Identity Inspector.






Then we need to override newItemForRepresentedObject: so that we can do the required binding. We allow the parent class to create the NSCollectionViewItem for us, after which we can do whatever we require with it.


- (NSCollectionViewItem *)newItemForRepresentedObject:(id)object
{
    // Let the parent class create the item
    NSCollectionViewItem *item = [super newItemForRepresentedObject:object];
    // Now we need the view so we can create bindings between it and the object.


    NSView *view = [item view];


    [view bind:@"title" toObject:object withKeyPath:@"title" options:nil];
    [view bind:@"option" toObject:object withKeyPath:@"option" options:nil];


    return item;
}


The bind:toObject:withKeyPath:options: message is binding the title property on the created view to the same property on the object that it represents. Obviously we need to create a subclass for the view, which we'll call ItemView.


As we couldn't create the view in XCode we need to create the view by hand in the initWithCoder: method. We also need to add properties for the title and option bindings that we want to display from our model item. We use the initWithCoder: method instead of initWithFrame: because the view is still being defined in the nib file, we're just filling in some other bits.



- (id)initWithCoder:(NSCoder *)aDecoder
{
    self = [super initWithCoder:aDecoder];
    
    NSRect frame = [self frame];
    float controlHeight = (frame.size.height - 30) / 2;
    
    segmentedControl = [[NSSegmentedControl alloc] initWithFrame:NSMakeRect(10.0, 10.0, frame.size.width - 20, controlHeight)];
    [segmentedControl setSegmentCount:4];
    
    NSInteger i;
    for (i = 0; i < 4; i++) {
        [segmentedControl setLabel:[NSString stringWithFormat:@"Option %d", i]
                        forSegment:i];
    }
    
    [self addSubview:segmentedControl];
    titleField = [[NSTextField alloc] initWithFrame:NSMakeRect(10.0, controlHeight + 15, frame.size.width - 20, controlHeight)];
    [titleField setEditable:NO];
    [titleField setBordered:NO];
    [titleField setDrawsBackground:NO];
    
    [self addSubview:titleField];
    
    return self;
}


We create a segmented control to display the option property and a text field to display the title property. Then we need to update the controls whenever the property changes which we can do in the set methods:



- (void)setTitle:(NSString *)_title
{
    if (_title == title) {
        return;
    }
    
    [title release];
    title = [_title copy];
    
    [titleField setStringValue:title];
}


- (void)setOption:(NSInteger)_option
{
    if (option == _option) {
        return;
    }
    
    option = _option;
    [segmentedControl setSelectedSegment:option];
}


These are just standard setter functions, except they update the controls.


Finally for this section, we need to tell XCode that the class of the NSView is actually our ItemView by setting it in the Custom Class section of the Identify Inspector. 






The size of the ItemView is also set in the nib file, so if you want to specify a size thats where to do it.




Handling Selection In NSCollectionView

NSCollectionViewItem had a selected property, but as of 10.7 this has been deprecated so any new code shouldn't use it. This makes handling selections slightly more awkward but it is still possible.

The first thing to do is to make the NSCollectionView selectable in the Attributes Inspector

and to bind the NSCollectionView's Selection Indexes to the selectionIndexes property on the array controller


This means that other controls can use the array controller to listen to changes in the selection. As an example, we'll add an NSTextField which will display the selected item's title without any code needing to be written.

Add the NSTextField to the main window. Then in the Bindings Inspector we need to bind the value property of the NSTextField to the Items Controller. The controller key is selection and the Model Path Key is title.



If you build this now and select an item, the title of the selected item will appear in the text field, but the item doesn't look like it is selected. This is because we need to tell the item that it is selected, and we need to draw the NSView differently when it is selected.

To our ItemView class we add a selected property and when the property is changed we tell our view to redraw itself.

- (void)setSelected:(BOOL)_selected
{
    if (_selected == selected) {
        return;
    }
    
    selected = _selected;
    [self setNeedsDisplay:YES];
}

We can override the drawRect: method of our view to draw the background in the selected colour when the item is selected.

- (void)drawRect:(NSRect)dirtyRect
{
    if (selected) {
        [[NSColor selectedControlColor] set];
        NSRectFill(dirtyRect);
    }
}

Finally, to complete this, we need to tell the items when they are or are not selected. Initially I thought about doing this with Key Value Observing (KVO) on the selectedIndexes property of the array controller. The problem with this is that allow the KVO notifications are called correctly when the selection changes the changes dictionary is always empty. So I approached this from a different way. I'm not sure this is as "correct" as the KVO method would have been but it still works.

To do it, I override the setSelectionIndexes: method of MyCollectionView, which is called every time the selection changes as well. NSCollectionView has the method itemAtIndex: which we use to get the item for the selection. We also need to store the selected row so that we know which row to unselect when it is changed.

- (void)setSelectionIndexes:(NSIndexSet *)indexes
{
    [super setSelectionIndexes:indexes];

    NSCollectionViewItem *item = [self itemAtIndex:currentSelection];
    ItemView *view = (ItemView *)[item view];
    [view setSelected:NO];
    
    item = [self itemAtIndex:[indexes firstIndex]];
    view = (ItemView *)[item view];
    [view setSelected:YES];
    
    currentSelection = [indexes firstIndex];
}

Now when we compile and run it we should see that the item is clearly selected.


I think thats a fairly comprehensive look at NSCollectionView and friends, there's bound to be other things and hopefully Apple will get the binding bug fixed in the next version of XCode so using it won't be such a hassle.

(Code for the project can be found at http://www.mediafire.com/?n29lgi9wl22dsgf)

References:


Links to the other parts of the NSViewController articles
Part 1:- http://comelearncocoawithme.blogspot.com/2011/07/nsviewcontrollers.html
Part 2:- http://comelearncocoawithme.blogspot.com/2011/07/nsviewcontrollers-part-2.html
Part 4:- http://comelearncocoawithme.blogspot.com/2011/08/nscollectionview-redux.html


4 comments:

  1. Good Information--I'm using Xcode 4.2 Lion, I had set what I thought was a good functioning collection view using bindings. My view item has 3 textfields and 2 chkboxes. I implemented an add buttome which would generate as many view items as desired. When I pressed the save button, I could see all of the data in my LSLog statement, however, I could not query state on the checkboxes for a given view item. It looked like the object pointers were nil. was my problem attributed to the bug, or was I missing something else (perhaps the item = [self itemAtIndex:[indexes firstIndex]])?

    ReplyDelete
  2. The bug has been fixed in xcode 4.2 so it'll be something else.
    Without seeing your code it sounds like you've not bound the checkboxes on the view to the objects they represent.

    ReplyDelete
    Replies
    1. Thanks for the response. Yeah as I said, everything works, but I want to implement a method in my viewItem class so that when one checkbox is checked it checks the state of the other box, and if necessary, turns the state off. The behavior should be none or one check. is there a way to use bindings to control the checkboxes state as a pair? I tried to create a matrix by selecting both checkboxes, but the nib automatically changes the chkboxes to buttoncells and it breaks the binding. If I try to create an IBOutlet and an NSCheckbox for each one in the view Item class and connect them to the boxes, I always get null. Its like its a different instance of the check boxes.

      Just to be clear, I am talking about the bindings method and not what you've show here. Any advice is appreciated.

      Delete
    2. You were right. It was something else. The only way I could figure this out was to embed the two check boxes in a matrix (assigning a different tag to each chkbox) and binding the matrix to the collection view item in place of the two checkboxes. Then I had to output my checkbox values by [[matrix selectedCell]tag], then I could control the state of the check boxes as well with my chkState method in the controllerViewItemClass using

      [matrix setState:(int) :forRow :forCol].
      That all finally worked

      Delete

Note: only a member of this blog may post a comment.