2017-12-26

Skeleton code for a view-based NSTableView

I stopped programming in Cocoa for a while, but a recent project is causing me to spend time in it again. One area that caused me some headaches is the move from NSCell-based table views to NSView-based table views. Writing code for the old tables was easy. You had to supply three functions: one to get the number of rows, one to get a cell value, and one to set a cell value. The new tables are more flexible, but the code is trickier, and the documentation is scattered. I think some parts of the documentation do not even exist. One thing that was not clear at all to me at first was how to save changes after a cell was edited. So here is a sketch of the code that I worked out for my current project.

My application includes an ordered array of key-value pairs, where both keys and values are NSStrings. Both the key and the value can be edited. The pairs are stored in a NSMutableArray (self.tags). My object implements the old data source method to provide the number of valid rows to the NSTableView:

- (NSInteger)numberOfRowsInTableView:(NSTableView *)aTableView {
    return [self.tags count];
}

The interface has a "+" button to add rows to the table, and the action method is simple:

-(IBAction) newTagPair:(id) sender {
    [self.tags addObject: [@[ @"NewTag", @""] mutableCopy]];
    [self.tagsTable reloadData];
}

In the first column of each row I include a button for deleting that row. The button calls this action method:

- (IBAction) removeTagRow: (id) sender {
    NSInteger row = [self.tagsTable rowForView: sender];
    [self.tagsTable removeRowsAtIndexes: [NSIndexSet indexSetWithIndex: row] withAnimation: NSTableViewAnimationSlideUp];
    [self.tags removeObjectAtIndex: row];
}

The -tableView:viewForTableColumn:row: method provides a view for each column. This is described well enough in Apple's Table View Programming Guide for Mac Listing 3-2, although it only shows an NSTextField. Here is an example of constructing the delete button in code:

- (NSView *)tableView:(NSTableView *)tableView viewForTableColumn:(NSTableColumn *)tableColumn row:(NSInteger)row {
    if ([@"X" isEqualToString: [tableColumn identifier]]) {
        // Reuse a prior cell view if one exists.
        NSButton *result = [tableView makeViewWithIdentifier:@"com.taffysoft.Lucena.tagsTableXButton" owner:self];
        if (result == nil) {
            result = [[NSButton alloc] initWithFrame: NSZeroRect];
            result.identifier = @"com.taffysoft.Lucena.tagsTableXButton";
            result.title = @"X";
            result.target = self;
            result.action = @selector(removeTagRow:);
        }
        return result;
    }

    // ... handle text columns ...
}

The second column of each row contains the Tag or key string. The third column contains the Value string. Note that you must set the NSTextField's stringValue in -tableView:viewForTableColumn:row:

- (NSView *)tableView:(NSTableView *)tableView viewForTableColumn:(NSTableColumn *)tableColumn row:(NSInteger)row {
    if ([@"X" isEqualToString: [tableColumn identifier]]) {
    	// ... handle removal buttons ...
    }

    // Reuse a prior cell view if one exists.
    NSTextField *result = [tableView makeViewWithIdentifier:@"com.taffysoft.Lucena.tableViewTextField" owner:self];
    if (result == nil) {
        result = [[NSTextField alloc] initWithFrame: NSZeroRect];
        result.identifier = @"com.taffysoft.Lucena.tableViewTextField";
        result.delegate = self;
        result.bezeled = NO;
        result.editable = YES;
        result.selectable = YES;
    }

    int col = [@"Tag" isEqualToString: [tableColumn identifier]] ? 1 : 2;
    result.stringValue = self.tags[row][col-1];
    return result;
}

NSCell-based table views had a simple method -tableView:setObjectValue:forTableColumn:row: for setting the contents of the cell at a given location. NSView-based views have no such method. You must provide view-specific methods for updating your data. What happens if you use an NSTextField for editing text? Good question. This is one of the blurry areas in Apple's documentation.

At the bottom of the NSTextField documentation you will find a section "NSText Delegate Method Implementations". You might think that you could implement -textDidEndEditing: to handle the end of editing. This does not work; the method is never called. This happens because the NSTextField that you provide does not do the actual work of editing text. That work is handled by the field editor, an NSTextView which is inserted into the GUI when needed. The five methods under "NSText Delegate Method Implementations" are NOT your delegate methods, but the delegate methods of the field editor. You can use a custom field editor, but I did not want that. So now what?

Read the description of the -textDidEndEditing: method. It says that it posts a notification that causes the receiver's delegate (where the receiver is your NSTextField) to receive a -controlTextDidEndEditing: message. This is where you need to save the result of the user's edits. The object field of the notification contains the view that was edited.

-(void) controlTextDidEndEditing:(NSNotification *)obj {
    NSInteger col = [self.tagsTable columnForView: obj.object];
    NSInteger row = [self.tagsTable rowForView: obj.object];
    NSString *val = [obj.object stringValue];
    
    if (row >= 0 && col >= 1) self.tags[row][col-1] = val;

    // ... movement ...
}

I also wanted to make a small change to how movement was handled. Tab keys move you to the next cell in a row, but will not take you to the next row. This behavior can be modified at the end of the -controlTextDidEndEditing: method, but it requires a bit of indirection. You want to use NSTableView's -editColumn:row:withEvent:select: method, but that method does not work inside -controlTextDidEndEditing:. You have to let control return to the NSText delegate method -textDidEndEditing: which performs its own movement handling, then modify the selection after a delay. This is a job for NSObject's -performSelectorOnMainThread:withObject:waitUntilDone:. The implementation below watches for NSTextMovement 17 (tab) and 18 (shift-tab) and then schedules a call to a simple helper method -jumpToTableViewCell:

-(void) jumpToTableViewCell: (NSDictionary *) location {
    [self.tagsTable editColumn: [location[@"col"] intValue]
                           row: [location[@"row"] intValue]
                     withEvent: nil
                        select: YES];
}

-(void) controlTextDidEndEditing:(NSNotification *)obj {
    // ... save text value ...

    int textMovement = [obj.userInfo[@"NSTextMovement"] intValue];
    
    // Calling -editColumn:row:withEvent:select: here does not work, so call it with a delay.
    if (17 == textMovement && col == 2 && row < [self.tags count]-1) {
        NSNumber *nextRow = [NSNumber numberWithLong: 1+row];
        NSDictionary *destination = @{ @"row" : nextRow, @"col" : @1 };
        [self performSelectorOnMainThread: @selector(jumpToTableViewCell:)
                               withObject: destination
                            waitUntilDone: NO];
    } else if (18 == textMovement && col == 1 && row > 0) {
        NSNumber *lastRow = [NSNumber numberWithLong: row-1];
        NSDictionary *destination = @{ @"row" : lastRow, @"col" : @2 };
        [self performSelectorOnMainThread: @selector(jumpToTableViewCell:)
                               withObject: destination
                            waitUntilDone: NO];
    }
}

If you do not care about the movement keys, there is an alternative method. The NSTableViewDelegate interface conforms to the NSControlTextEditingDelegate protocol, which includes the -control:textShouldEndEditing: method. You can retrieve the row index, column index, and string value from either argument and update your data there.

- (BOOL)control:(NSControl *)control textShouldEndEditing:(NSText *)fieldEditor {
    NSInteger col = [self.tagsTable columnForView: control];
    NSInteger row = [self.tagsTable rowForView: control];
    NSString *val = [control stringValue];
    if (row >= 0 && col >= 1) self.tags[row][col-1] = val;
    return YES;
}
// ---------- or ----------
- (BOOL)control:(NSControl *)control textShouldEndEditing:(NSText *)fieldEditor {
    NSInteger col = [self.tagsTable columnForView: fieldEditor];
    NSInteger row = [self.tagsTable rowForView: fieldEditor];
    NSString *val = [fieldEditor string];
    if (row >= 0 && col >= 1) self.tags[row][col-1] = val;
    return YES;
}

See also: Making buttons part of the key view loop within a view-based NSTableView