Question

I have text fields inside a custom view inside an NSOutlineView. Editing one of these cells requires a single click, a pause, and another single click. The first single click selects the table view row, and the second single click draws the cursor in the field. Double-clicking the cell, which lets you edit in a cell-based table view, only selects the row.

The behavior I want: one click to change the selection and edit.

What do I need to override to obtain this behavior?

I've read some other posts:

  • The NSTextField flyweight pattern wouldn't seem to apply to view-based table views, where the cell views are all instantiated from nibs.
  • I tried subclassing NSTextField like this solution describes, but my overridden mouseDown method is not called. Overridden awakeFromNib and viewWillDraw (mentioned in this post) are called. Of course mouseDown is called if I put the text field somewhere outside a table view.

By comparison, a NSSegmentedControl in my cell view changes its value without first selecting the row.


Here's the working solution adapted from the accepted response:

In outline view subclass:

-(void)mouseDown:(NSEvent *)theEvent {
    [super mouseDown:theEvent];

    // Forward the click to the row's cell view
    NSPoint selfPoint = [self convertPoint:theEvent.locationInWindow fromView:nil];
    NSInteger row = [self rowAtPoint:selfPoint];
    if (row>=0) [(CellViewSubclass *)[self viewAtColumn:0 row:row makeIfNecessary:NO]
            mouseDownForTextFields:theEvent];
}

In table cell view subclass:

// Respond to clicks within text fields only, because other clicks will be duplicates of events passed to mouseDown
- (void)mouseDownForTextFields:(NSEvent *)theEvent {
    // If shift or command are being held, we're selecting rows, so ignore
    if ((NSCommandKeyMask | NSShiftKeyMask) & [theEvent modifierFlags]) return;
    NSPoint selfPoint = [self convertPoint:theEvent.locationInWindow fromView:nil];
    for (NSView *subview in [self subviews])
        if ([subview isKindOfClass:[NSTextField class]])
            if (NSPointInRect(selfPoint, [subview frame]))
                [[self window] makeFirstResponder:subview];
}
Was it helpful?

Solution

I'll try to return the favor... Subclass NSOutlineView and override -mouseDown: like so:

- (void)mouseDown:(NSEvent *)theEvent {
    [super mouseDown:theEvent];

    // Only take effect for double clicks; remove to allow for single clicks
    if (theEvent.clickCount < 2) {
        return;
    }

    // Get the row on which the user clicked
    NSPoint localPoint = [self convertPoint:theEvent.locationInWindow
                                   fromView:nil];
    NSInteger row = [self rowAtPoint:localPoint];

    // If the user didn't click on a row, we're done
    if (row < 0) {
        return;
    }

    // Get the view clicked on
    NSTableCellView *view = [self viewAtColumn:0 row:row makeIfNecessary:NO];

    // If the field can be edited, pop the editor into edit mode
    if (view.textField.isEditable) {
        [[view window] makeFirstResponder:view.textField];
    }
}

OTHER TIPS

Had the same problem. After much struggle, it magically worked when I selected None as against the default Regular (other option is Source List) for the Highlight option of the table view in IB!

Another option is the solution at https://stackoverflow.com/a/13579469/804616, which appears to be more specific but a little hacky compared to this.

You really want to override validateProposedFirstResponder and allow a particular first responder to be made (or not) depending on your logic. The implementation in NSTableView is (sort of) like this (I'm re-writing it to be pseudo code):

- (BOOL)validateProposedFirstResponder:(NSResponder *)responder forEvent:(NSEvent *)event {

// We want to not do anything for the following conditions:
// 1. We aren't view based (sometimes people have subviews in tables when they aren't view based)
// 2. The responder to valididate is ourselves (we send this up the chain, in case we are in another tableview)
// 3. We don't have a selection highlight style; in that case, we just let things go through, since the user can't appear to select anything anyways.
if (!isViewBased || responder == self || [self selectionHighlightStyle] == NSTableViewSelectionHighlightStyleNone) {
    return [super validateProposedFirstResponder:responder forEvent:event];
}

if (![responder isKindOfClass:[NSControl class]]) {
    // Let any non-control become first responder whenever it wants
    result = YES;
    // Exclude NSTableCellView. 
    if ([responder isKindOfClass:[NSTableCellView class]]) {
        result = NO;
    }

} else if ([responder isKindOfClass:[NSButton class]]) {
    // Let all buttons go through; this would be caught later on in our hit testing, but we also do it here to make it cleaner and easier to read what we want. We want buttons to track at anytime without any restrictions. They are always valid to become the first responder. Text editing isn't.
    result = YES;
} else if (event == nil) {
    // If we don't have any event, then we will consider it valid only if it is already the first responder
    NSResponder *currentResponder = self.window.firstResponder;
    if (currentResponder != nil && [currentResponder isKindOfClass:[NSView class]] && [(NSView *)currentResponder isDescendantOf:(NSView *)responder]) {
        result = YES;
    }
} else {
    if ([event type] == NSEventTypeLeftMouseDown || [event type] == NSEventTypeRightMouseDown) {
        // If it was a double click, and we have a double action, then send that to the table
        if ([self doubleAction] != NULL && [event clickCount] > 1) {
           [cancel the first responder delay];
        }
   ...
          The code here checks to see if the text field 
        cell had text hit. If it did, it attempts to edit it on a delay. 
        Editing is simply making that NSTextField the first responder.
        ...

     }

I wrote the following to support the case for when you have a more complex NSTableViewCell with multiple text fields or where the text field doesn't occupy the whole cell. There a trick in here for flipping y values because when you switch between the NSOutlineView or NSTableView and it's NSTableCellViews the coordinate system gets flipped.

- (void)mouseDown:(NSEvent *)theEvent
{
    [super mouseDown: theEvent];

    NSPoint thePoint = [self.window.contentView convertPoint: theEvent.locationInWindow
                                                      toView: self];
    NSInteger row = [self rowAtPoint: thePoint];
    if (row != -1) {
        NSView *view = [self viewAtColumn: 0
                                      row: row
                          makeIfNecessary: NO];

        thePoint = [view convertPoint: thePoint
                             fromView: self];
        if ([view isFlipped] != [self isFlipped])
            thePoint.y = RectGetHeight(view.bounds) - thePoint.y;

        view = [view hitTest: thePoint];
        if ([view isKindOfClass: [NSTextField class]]) {
            NSTextField *textField = (NSTextField *)view;
            if (textField.isEnabled && textField.window.firstResponder != textField)

                dispatch_async(dispatch_get_main_queue(), ^{
                    [textField selectText: nil];
                });
        }
    }
}

Just want to point out that if all that you want is editing only (i.e. in a table without selection), overriding -hitTest: seems to be simpler and a more Cocoa-like:

- (NSView *)hitTest:(NSPoint)aPoint
{
    NSInteger column = [self columnAtPoint: aPoint];
    NSInteger row = [self rowAtPoint: aPoint];

    // Give cell view a chance to override table hit testing
    if (row != -1 && column != -1) {
        NSView *cell = [self viewAtColumn:column row:row makeIfNecessary:NO];

        // Use cell frame, since convertPoint: doesn't always seem to work.
        NSRect frame = [self frameOfCellAtColumn:column row:row];
        NSView *hit = [cell hitTest: NSMakePoint(aPoint.x + frame.origin.x, aPoint.y + frame.origin.y)];

        if (hit)
            return hit;
    }

    // Default implementation
    return [super hitTest: aPoint];
}

Here is a swift 4.2 version of @Dov answer:

 override func mouseDown(with event: NSEvent) {
    super.mouseDown(with: event)

    if (event.clickCount < 2) {
        return;
    }
    // Get the row on which the user clicked
    let localPoint = self.convert(event.locationInWindow, from: nil)


    let row = self.row(at: localPoint)

    // If the user didn't click on a row, we're done
    if (row < 0) {
        return
    }

    DispatchQueue.main.async {[weak self] in
        guard let self = self else {return}
        // Get the view clicked on
        if let clickedCell = self.view(atColumn: 0, row: row, makeIfNecessary: false) as? YourOutlineViewCellClass{

            let pointInCell = clickedCell.convert(localPoint, from: self)

            if (clickedCell.txtField.isEditable && clickedCell.txtField.hitTest(pointInCell) != nil){

                clickedCell.window?.makeFirstResponder(clickedCell.txtField)

            }

        }
    }

}
Licensed under: CC-BY-SA with attribution
Not affiliated with StackOverflow
scroll top