Respond to mouse events in text field in view-based table view
Asked Answered
T

6

29

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];
}
Triboluminescence answered 18/8, 2011 at 0:56 Comment(0)
O
15

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];
    }
}
Overwork answered 19/8, 2011 at 18:58 Comment(4)
Thanks! I'm making progress with this approach! It doesn't completely work, however. There are multiple text fields, so I need to check which (if any) field is clicked before acting. Forwarding the event to the subview to process results in duplicates, which makes sense, so I'm passing it to a mouseDownForTextFields method instead. I could connect all the get fields to an outlet collection, which I'd have to do for each view style (seven of them). Is there a way I can get them all programmatically, instead?Triboluminescence
I don't think so, outside of reflection. Does the Mac support outlet collections in Lion now? As of Snow Leopard, that was iOS-only.Overwork
Oh, you're right. Still iOS only. How can I use reflection? I guess I could try to override setTextField: to implement my own collection, or make text fields which register themselves with the outline view in awakeFromNib.Triboluminescence
I would advise against overriding setTextField:, which would feel like a hack and might have side effects. I haven't used reflection myself in Objective-C, but I read this interesting article: pilky.me/view/21 I would suggest looping through your view's -subviews array and check for the ones that are NSTextFields for hit testing with NSPointInRect()Overwork
L
17

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://mcmap.net/q/502160/-nstableview-select-row-and-respond-to-mouse-events-immediately-duplicate, which appears to be more specific but a little hacky compared to this.

Lawman answered 26/3, 2013 at 9:59 Comment(7)
Do you mean selecting None instead of Regular and not changing any code? That solution you linked looks great.Triboluminescence
Yeah! I guess the default implementation of validateProposedFirstResponder:forEvent: of NSTableView checks for the Highlight type and accordingly returns YES or NO.Lawman
I voted it up because it sounds helpful, but I haven't had a chance to try it in place of the other code yet.Triboluminescence
Ah, okay. Since you edited the answer it I thought you must have tried it :)Lawman
I tried that solution you mentioned. It works but then the mouse down event in the table view subclass doesn't get called.Ermentrude
I just had this problem too, and confirm that simply changing the highlight mode to None solved it.Beulahbeuthel
That's because the implementation passes to super if the highlight style is None.Portal
O
15

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];
    }
}
Overwork answered 19/8, 2011 at 18:58 Comment(4)
Thanks! I'm making progress with this approach! It doesn't completely work, however. There are multiple text fields, so I need to check which (if any) field is clicked before acting. Forwarding the event to the subview to process results in duplicates, which makes sense, so I'm passing it to a mouseDownForTextFields method instead. I could connect all the get fields to an outlet collection, which I'd have to do for each view style (seven of them). Is there a way I can get them all programmatically, instead?Triboluminescence
I don't think so, outside of reflection. Does the Mac support outlet collections in Lion now? As of Snow Leopard, that was iOS-only.Overwork
Oh, you're right. Still iOS only. How can I use reflection? I guess I could try to override setTextField: to implement my own collection, or make text fields which register themselves with the outline view in awakeFromNib.Triboluminescence
I would advise against overriding setTextField:, which would feel like a hack and might have side effects. I haven't used reflection myself in Objective-C, but I read this interesting article: pilky.me/view/21 I would suggest looping through your view's -subviews array and check for the ones that are NSTextFields for hit testing with NSPointInRect()Overwork
P
6

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.
        ...

     }
Portal answered 8/5, 2017 at 16:18 Comment(0)
E
3

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];
                });
        }
    }
}
Edgerton answered 1/1, 2015 at 1:54 Comment(7)
Nice! Why the dispatch_async?Triboluminescence
@noa I do that when I want the current execution thread to continue / complete before executing some ancillary bit of code. For example, sometimes UI functions expect to return before another UI-changing call is made (or at least experience seems to indicate that).Edgerton
Understood. Why does it seem to be required in this case?Triboluminescence
@noa In this case I added it because I want the table view row selection to complete before selecting the text. It may not be strictly necessary. But if the mouseDown is what is triggering activation of the table / outline view and then selecting the row, I would think that those actions should complete before activating the text field. For example, the mouseDown will trigger a change of first responder to the table view if it wasn't already. That could then override the text field being set as first responder. Again, mileage may vary. I'm being cautious in this instance.Edgerton
Using dispatch_async on anything that updates an interface is not good practice. Use dispatch_sync on the main queue, but nothing asynchronous. See, for example, #17490786Biyearly
@ElisevanLooij It really depends on whether you need a synchronous behavior or not, and what you're trying to do. In this case, a synchronous call would block the thread and not allow the mouseDown implementation to take complete. The goal here is to allow all of the UIKit actions to proceed and then perform the selectText. Anything prior happens before the necessary UI has updated. I think you may be confusing asynchronous with background. You can trigger an asynchronous dispatch but still on the main thread therefore all UI actions are fine.Edgerton
I meant Cocoa not UIKit.Edgerton
L
2

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];
}
Leverrier answered 17/11, 2012 at 13:26 Comment(2)
Good point, thanks for posting that! I may try it out for comparison.Triboluminescence
I'm not sure what you mean by editing in a table without selection, but anyway, the problem with overriding hitTest: instead of validateProposedFirstResponder:forEvent: is that you miss all the NSEvent information: double-click or single-click, mouseEvent or otherwise, etc.Biyearly
F
1

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)

            }

        }
    }

}
Folketing answered 24/3, 2020 at 19:10 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.