15
votes

Is it possible to navigate an NSTableView's editable cell around the NSTableView using arrow keys and enter/tab? For example, I want to make it feel more like a spreadsheet.

The users of this application are expected to edit quite a lot of cells (but not all of them), and I think it would be easier to do so if they didn't have to double-click on each cell.

4

4 Answers

10
votes

In Sequel Pro we used a different (and in my eyes simpler) method: We implemented control:textView:doCommandBySelector: in the delegate of the TableView. This method is hard to find -- it can be found in the NSControlTextEditingDelegate Protocol Reference. (Remember that NSTableView is a subclass of NSControl)

Long story short, here's what we came up with (we didn't override left/right arrow keys, as those are used to navigate within the cell. We use Tab to go left/right)

Please note that this is just a snippet from the Sequel Pro source code, and does not work as is

- (BOOL)control:(NSControl *)control textView:(NSTextView *)textView doCommandBySelector:(SEL)command
{
    NSUInteger row, column;

    row = [tableView editedRow];
    column = [tableView editedColumn];

    // Trap down arrow key
    if (  [textView methodForSelector:command] == [textView methodForSelector:@selector(moveDown:)] )
    {
        NSUInteger newRow = row+1;
        if (newRow>=numRows) return TRUE; //check if we're already at the end of the list
        if (column>= numColumns) return TRUE; //the column count could change

        [tableContentView selectRowIndexes:[NSIndexSet indexSetWithIndex:newRow] byExtendingSelection:NO];
        [tableContentView editColumn:column row:newRow withEvent:nil select:YES];
        return TRUE;
    }

    // Trap up arrow key
    else if (  [textView methodForSelector:command] == [textView methodForSelector:@selector(moveUp:)] )
    {
        if (row==0) return TRUE; //already at the beginning of the list
        NSUInteger newRow = row-1;

        if (newRow>=numRows) return TRUE;
        if (column>= numColumns) return TRUE;

        [tableContentView selectRowIndexes:[NSIndexSet indexSetWithIndex:newRow] byExtendingSelection:NO];
        [tableContentView editColumn:column row:newRow withEvent:nil select:YES];
        return TRUE;
    }
5
votes

Well it isn't easy but I managed to do it without having to use RRSpreadSheet or even another control. Here's what you have to do:

  1. Create a subclass of NSTextView, this will be the field editor. For this example the name MyFieldEditorClass will be used and myFieldEditor will refer to an instance of this class.

  2. Add a method to MyFieldEditorClass called "- (void) setLastKnownColumn:(unsigned)aCol andRow:(unsigned) aRow" or something similar, and have it save both the input parameter values somewhere.

  3. Add another method called "setTableView:" and have it save the NSTableView object somewhere, or unless there is another way to get the NSTableView object from the field editor, use that.

  4. Add another method called - (void) keyDown:(NSEvent *) event. This is actually overriding the NSResponder's keyDown:. The source code should be (be aware that StackOverflow's MarkDown is changing < and > to &lt; and &gt;):

    - (void) keyDown:(NSEvent *) event
    {
        unsigned newRow = row, newCol = column;
        switch ([event keyCode])
        {
            case 126: // Up
                if (row)
                newRow = row - 1;
                break;
    
            case 125: // Down
                if (row < [theTable numberOfRows] - 1)
                    newRow = row + 1;
                break;
    
            case 123: // Left
                if (column > 1)
                    newCol = column - 1;
                break;
    
            case 124: // Right
                if (column < [theTable numberOfColumns] - 1)
                    newCol = column + 1;
                break;
    
            default:
                [super keyDown:event];
                return;
        }
    
        [theTable selectRow:newRow byExtendingSelection:NO];
        [theTable editColumn:newCol row:newRow withEvent:nil select:YES];
        row = newRow;
        column = newCol;
    }
    
  5. Give the NSTableView in your nib a delegate, and in the delegate add the method:

    - (BOOL) tableView:(NSTableView *)aTableView shouldEditColumn:(NSTableColumn *) aCol row:aRow
    {
        if ([aTableView isEqual:TheTableViewYouWantToChangeBehaviour])
            [myFieldEditor setLastKnownColumn:[[aTableView tableColumns] indexOfObject:aCol] andRow:aRow];
        return YES;
    }
    
  6. Finally, give the Table View's main window a delegate and add the method:

    - (id) windowWillReturnFieldEditor:(NSWindow *) aWindow toObject:(id) anObject
    {
        if ([anObject isEqual:TheTableViewYouWantToChangeBehaviour])
        {
            if (!myFieldEditor)
            {
                myFieldEditor = [[MyFieldEditorClass alloc] init];
                [myFieldEditor setTableView:anObject];
            }
            return myFieldEditor;
        }
        else
        {
            return nil;
        }
    }
    

Run the program and give it a go!

1
votes

Rather than forcing NSTableView to do something it wasn't designed for, you may want to look at using something designed for this purpose. I've got an open source spreadsheet control which may do what you need, or you may at least be able to extend it to do what you need: MBTableGrid

1
votes

I wanted to reply to the answers here but the reply button seems to be missing so I'm forced to proved an answer when I really just want to ask a question about the replies.

Anyway, I've seen a few answers for overriding the -keyDown event of the table view that say to subclass the TableView but according to every Objective-C book I've read so far, and several Apple training videos, you should very rarely if ever subclass one of the core classes. In fact every single one of them makes the point that C programmers have a fascination with subclassing and that's not how Objective-C works; that Objective-C is all about helpers and delegates not subclassing.

So, should I just ignore any of the responses that say to subclass as this seems to be in direct contradiction to the precepts of Objective-C?

--- Edit ---

I found something that worked without subclassing the NSTableView. While I do move the inheritance up one notch on the chain from NSObject to NSResponder I'm not totally subclassing the NSTableView. I'm just adding the ability to override the keyDown event.

I made the class I was using as a delegate inherit from NSResponder instead of NSObject and set the nextResponder to that class in awakeFromNib. I was then able to trap key presses using the keydown event. I of course connected the IBOutlet and set the delegate in Interface Builder.

Here's my code with the minimum needed to show the trapping of the key:

Header file

//  AppController.h

#import <Cocoa/Cocoa.h>

@interface AppController : NSResponder {

    IBOutlet NSTableView *toDoListView;
    NSMutableArray *toDoArray;
}

-(int)numberOfRowsInTableView:(NSTableView *)aTableView;

-(id)tableView:(NSTableView *)tableView
objectValueForTableColumn:(NSTableColumn *)aTableColumn
           row:(int)rowIndex;

@end

Here's the m file.

//  AppController.m
#import "AppController.h"

@implementation AppController

-(id)init
{
    [super init];
    toDoArray = [[NSMutableArray alloc] init];
    return self;
}

-(void)dealloc
{
    [toDoArray release];
    toDoArray = nil;
    [super dealloc];
}

-(void)awakeFromNib
{
    [toDoListView setNextResponder:self];
}

-(int)numberOfRowsInTableView:(NSTableView *)aTableView
{
    return [toDoArray count];
}

-(id)tableView:(NSTableView *)tableView
    objectValueForTableColumn:(NSTableColumn *)aTableColumn
                          row:(int)rowIndex
{
    NSString *value = [toDoArray objectAtIndex:rowIndex];
    return value;
}

- (void)keyDown:(NSEvent *)theEvent
{
    //NSLog(@"key pressed: %@", theEvent);
    if (theEvent.keyCode == 51 || theEvent.keyCode == 117)
    {
        [toDoArray removeObjectAtIndex:[toDoListView selectedRow]];
        [toDoListView reloadData];
    }
}
@end