Get notified for selecting a row in NSTableView - macos

As of now I am using tableViewSelectionDidChange notification to detect when a user clicks on any row in NSTableView. But using this notification delegate I need to deselect the selected row [tableView deselectRow:[tableView selectedRow]] as it will not notify until another row gets selected.
The problem with this approach is that once I deselect a row tableViewSelectionDidChange will get notified again and now I need to check if selectedRow is -1 or not(Since no rows are selected after deselecting it will now return -1)
Is there an equivalent for tableView:didSelectRowAtIndexPath in NSTableView as in UITableView ? If not is there any better way to get notified when selecting the same row ?

I had the same issue and ended up here aswell.
I had a scenario where I had a playlist of songs. I wanted to have the state as "selected" in a song but be able to stop that song and play again selecting the tableview's cell.
I ended up using a solution I saw here in stackoverflow by Peter Lapisu.
Basically Peter suggests to extend the tableview create an extendedDelegate property that has the method - (void)tableView:(NSTableView *)tableView didClickedRow:(NSInteger)row;. Then just override the mouseDown event like this :
- (void)mouseDown:(NSEvent *)theEvent {
NSPoint globalLocation = [theEvent locationInWindow];
NSPoint localLocation = [self convertPoint:globalLocation fromView:nil];
NSInteger clickedRow = [self rowAtPoint:localLocation];
[super mouseDown:theEvent];
if (clickedRow != -1) {
[self.extendedDelegate tableView:self didClickedRow:clickedRow];
}
}

Related

How to detect rightMouseDown and **otherMouseDown** on NSTableView?

I want to detect rightMouseDown and otherMouseDown on NSTableView.
I search about it and found some answer use menuForEvent but, it invoked when right mouse pressed but I want to detect mouse click on nstableview.
There are a couple of ways:
If you subclass NSTableView, you can override NSResponder rightMouseDown: or otherMouseDown: functions.
If you do not want to subclass NSTableView, you could attach a local monitor. The catch is that this local monitor will look at every event of the specified in the application, so you will have to do some checking to make sure the mouse was inside the table view when the right or other mouse down event happened.
[NSEvent addLocalMonitorForEventsMatchingMask:NSEventMaskRightMouseDown|NSEventMaskOtherMouseDown handler:^NSEvent * _Nullable(NSEvent * _Nonnull theEvent) {
if ([theEvent window] == [tableView window]) {
NSPoint event_location = [theEvent locationInWindow];
NSPoint local_point = [[tableView superview] convertPoint:event_location fromView:nil];
if (NSPointInRect(local_point, [tableView frame])) {
}
}
return theEvent;
}];
Note that this method returns an id object that you will need to pass into removeMonitor when necessary (just like NSNotificationCenter addObserverForName:).

How to select a row in an NSTableView when clicking on an NSTextView inside an NSTableCellView?

I have a view-based single-column NSTableView. Inside my NSTableCellView subclass I have an NSTextView which is selectable, but not editable.
When the user clicks on the NSTableCellView directly, the row highlights properly. But when the user clicks on the NSTextView inside that NSTableCellView, the row does not highlight.
How do I get the click on the NSTextView to pass to the NSTableCellView so that the row highlights?
Class hierarchy looks like:
NSScrollView > NSTableView > NSTableColumn > NSTableCellView > NSTextView
Here's what I ended up doing. I made a subclass of NSTextView and overrode mouseDown: as follows...
- (void)mouseDown:(NSEvent *)theEvent
{
// Notify delegate that this text view was clicked and then
// handled the click natively as well.
[[self myTextViewDelegate] didClickMyTextView:self];
[super mouseDown:theEvent];
}
I'm reusing NSTextView's standard delegate...
- (id<MyTextViewDelegate>)myTextViewDelegate
{
// See the following for info on formal protocols:
// stackoverflow.com/questions/4635845/how-to-add-a-method-to-an-existing-protocol-in-cocoa
if ([self.delegate conformsToProtocol:#protocol(MyTextViewDelegate)]) {
return (id<MyTextViewDelegate>)self.delegate;
}
return nil;
}
And in the header...
#protocol MyTextViewDelegate <NSTextViewDelegate>
- (void)didClickMyTextView:(id)sender;
#end
In the delegate, I implement didClickMyTextView: to select the row.
- (void)didClickMyTextView:(id)sender
{
// User clicked a text view. Select its underlying row.
[self.tableView selectRowIndexes:[NSIndexSet indexSetWithIndex:[self.tableView rowForView:sender]] byExtendingSelection:NO];
}
or, use a NSTextField, and then,
textfield.bezeled = NO;
textfield.drawsBackground = NO;
textfield.editable = NO;
textfield.selectable = YES;
[textfield setRefusesFirstResponder: YES];
I think you have essentially the same problem I had here: pass event on.
See the accepted answer.
Following the same pattern, you would subclass NSTextView and override - (void)mouseUp:(NSEvent *)theEvent to pass the event on to the superView, which I'm assuming is the tableView:
- (void)mouseUp:(NSEvent *)theEvent {
[superView mouseUp:theEvent];
}

Can a button in a table view row be indexed somehow?

I have a table view with 3 items, one of which I have behind a button. When the button is selected, I want to hide that button, revealing the item behind it. I am displaying the table row using a table view cell. When I select the one button to hide, scrolling through the table hides more buttons. The hiding of the button seems to hide a button based on some location within the viewable rows of the current view. I'm trying to hide the button on a specific row.
I can write to the NSLog whenever I hit the code to hide a button and I will only get there once, but as I scroll through the table, the hidden attribute for the button applies to other rows that come into view. If I select the button on row 53 I want only the button in row 53 hidden, not buttons on other rows in the 120 row table.
Has anyone ever done what I am trying to do? Any help I can get to figure out what is happening would be appreciated. Thanks.
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
static NSString *ElementCellIdentifier = #"ElementCellIdentifier";
UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:ElementCellIdentifier];
if (cell == nil) {
NSArray *nib = [[NSBundle mainBundle] loadNibNamed:#"ElementRowCell"
owner:self options:nil];
if ([nib count] > 0) {
cell = self.tvCell;
} else {
NSLog(#"failed to load ElementRowCell nib file!");
}
}
NSUInteger row = [indexPath row];
UILabel *atomic_number = (UILabel *)[cell.contentView viewWithTag:1];
atomic_number.text = [NSString stringWithFormat:#"%d",elements_table[row].atomic_number];
UILabel *element_name = (UILabel *)[cell.contentView viewWithTag:2];
element_name.text = [NSString stringWithCString:elements_table[row].element_name];
UILabel *element_symbol = (UILabel *)[cell.contentView viewWithTag:3];
element_symbol.text = [NSString stringWithCString:elements_table[row].element_symbol];
return cell;
}
- (IBAction)buttonPressed:(id)sender {
NSLog(#"Getting to buttonPressed from row button");
UIButton *pressedButton = (UIButton *)sender;
NSIndexPath *indexPath = [self.mainTableView indexPathForCell: (UITableViewCell *)[sender superview]];
pressedButton.hidden = TRUE;
}
Sorry.
Basically what's happening is you are hiding the instance of the button in that specific table view cell. The problem is when it gets dequeue'd for another row nothing is restoring it's state. And if you were to just restore it's state to visible then the rows you clicked would be forgotten. You will need to save the rows that have been clicked already to be able to properly restore state in tableView:cellForRowAtIndexPath:.
How I would handle this is declare an NSMutableSet *selectedIndexPaths;. And use this to store the rows I have selected. Then when the button is clicked add that indexPath to the set like so.
- (IBAction)buttonPressed:(UIButton *)button{
if (![button isKindOfClass:[UIButton class]]) return;
UIView *finder = button.superview;
while ((![finder isKindOfClass:[UITableViewCell class]]) && finder != nil) {
finder = finder.superview;
}
if (finder == nil) return;
UITableViewCell *myCell = (UITableViewCell *)finder;
NSIndexPath *indexPath = [self.mainTableView indexPathForCell:myCell];
[selectedIndexPaths addObject:indexPath];
button.hidden = TRUE;
NSLog(#"IndexPathRow %d",indexPath.row);
}
Now to properly restore state when scrolling in tableView:cellForRowAtIndexPath: use an if statement to set the button's hidden property, like so:
buttonPropertyName.hidden = ([selectedIndexPaths containsObject:indexPath]);

Respond to mouse events in text field in view-based table view

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];
}
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.
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];
}
}
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)
}
}
}
}

NSTableView & NSOutlineView editing on tab key

My app has an NSOutlineView and an NSTableView, and I'm having the same problem with both. With a row in either selected, pressing the tab key puts the first column into edit mode instead of making the next key view first responder. To get to the next key view, you need to tab through all of the columns.
Also, shift-tabbing into either view results in the last column going into edit mode, necessitating more shift-tabs to get into its previous key view.
In case it matters, I'm using the autocalculated key view loop, not my own, with my NSWindow set to autorecalculatesKeyViewLoop = YES. I would like tabbing between the columns once the user elects to edit a column, but I don't think it's standard behavior for the tab key to trigger edit mode.
Update
Thanks to the helpful responses below, I worked it out. Basically, I override -keyDown in my custom table view class, which handles tabbing and shift-tabbing out of the table view. It was tougher to solve shift-tabbing into the table view, however. I set a boolean property to YES in the custom table view's -acceptsFirstResponder if it's accepting control from another view.
The delegate's -tableView:shouldEditTableColumn:row checks for that when the current event is a shift-tab keyDown event. -tableView:shouldEditTableColumn:row is called and it's not a shift-tab event, it sets the table view's property back to NO so it can still be edited as usual.
I've pasted the full solution below.
/* CustomTableView.h */
#interface CustomTableView : NSTableView {}
#property (assign) BOOL justFocused;
#end
/* CustomTableView.m */
#implementation CustomTableView
#synthesize justFocused;
- (BOOL)acceptsFirstResponder {
if ([[self window] firstResponder] != self) {
justFocused = YES;
}
return YES;
}
- (void)keyDown:(NSEvent *)theEvent
{
// Handle the Tab key
if ([[theEvent characters] characterAtIndex:0] == NSTabCharacter) {
if (([theEvent modifierFlags] & NSShiftKeyMask) != NSShiftKeyMask) {
[[self window] selectKeyViewFollowingView:self];
} else {
[[self window] selectKeyViewPrecedingView:self];
}
}
else {
[super keyDown:theEvent];
}
}
#end
/* TableViewDelegate.m */
. . .
- (BOOL)tableView:(NSTableView *)tableView shouldEditTableColumn:(NSTableColumn *)tableColumn
row:(NSInteger)row
{
NSEvent *event = [NSApp currentEvent];
BOOL shiftTabbedIn = ([event type] == NSKeyDown
&& [[event characters] characterAtIndex:0] == NSBackTabCharacter);
if (shiftTabbedIn && ((CustomTableView *)tableView).justFocused == YES) {
return NO;
} else {
((CustomTableView *)tableView).justFocused = NO;
}
return YES;
}
. . .
This is the default behavior. If there's no row selected, the table view as a whole has focus, and the Tab key switches to the next key view. If there is a row selected, the table view begins editing or moves to the next cell if already editing.
From AppKit Release Notes:
Tables now support inter-cell
navigation as follows:
Tabbing forward to a table focuses the entire table.
Hitting Space will attempt to 'performClick:' on a NSButtonCell in
the selected row, if there is only one
instance in that row.
Tabbing again focuses the first "focusable" (1) cell, if there is one.
If the newly focused cell can be edited, editing will begin.
Hitting Space calls 'performClick:' on the cell and sets the datasource
value afterwards, if changed. (2)
If a text cell is editing, hitting Enter will commit editing and focus
will be returned to the tableview, and
Tab/Shift-tab will commit the editing
and then perform the new tab-loop
behavior.
Tabbing will only tab through a single row
Once the last cell in a row is reached, tab will take the focus to
the next focusable control.
Back tabbing into a table will select the last focusable cell.
If you want to change this behavior, the delegate method tableView:shouldEditTableColumn:row: may be helpful. You may also have to subclass NSTableView if you really want to affect only the behavior of the Tab key.
The solution using keyDown didn't work for me. Perhaps because it is for cell-based table view.
My solution for a view-based table view, in Swift, looks like this:
extension MyTableView: NSTextFieldDelegate {
func controlTextDidEndEditing(_ obj: Notification) {
guard
let view = obj.object as? NSView,
let textMovementInt = obj.userInfo?["NSTextMovement"] as? Int,
let textMovement = NSTextMovement(rawValue: textMovementInt) else { return }
let columnIndex = column(for: view)
let rowIndex = row(for: view)
let newRowIndex: Int
switch textMovement {
case .tab:
newRowIndex = rowIndex + 1
if newRowIndex >= numberOfRows { return }
case .backtab:
newRowIndex = rowIndex - 1
if newRowIndex < 0 { return }
default: return
}
DispatchQueue.main.async {
self.editColumn(columnIndex, row: newRowIndex, with: nil, select: true)
}
}
}
You also need to set the cell.textField.delegate so that the implementation works.
My blog post on this tricky workaround: https://samwize.com/2018/11/13/how-to-tab-to-next-row-in-nstableview-view-based-solution/
I've had to deal with this before as well. My solution was to subclass NSTableView or NSOutlineView and override keyDown: to catch the tab key presses there, then act on them.
How convenient! I was just looking at this myself yesterday, and it's good to see some confirmation of the approach I took - keyDown: handling.
However, I have one small possible refinement to your approach: I worked out that the method triggering editing on shift-tabbing back to the table was the becomeFirstResponder call. So what I did on a NSTableView subclass was:
Add a synthesized property to control whether tab-editing behaviour was disabled
On keydown, check the first character (also check for [[theEvent characters] length] to avoid exceptions for dead keys!) for tab; if tab editing is disabled, move on to the next/previous view, as per your code sample.
Override becomeFirstResponder:
- (BOOL)becomeFirstResponder {
if (tabEditingDisabled) {
[self display];
return YES;
}
return [super becomeFirstResponder];
}
This keeps all the code in the tableview subclass, keeping the delegate cleaner :)
The only danger is I don't know what else NSTableView does in becomeFirstResponder; I didn't notice anything breaking, but...
This worked for me:
- (BOOL)tableView:(NSTableView *)tableView shouldEditTableColumn:(NSTableColumn *)tableColumn row:(NSInteger)row {
NSEvent *e = [NSApp currentEvent];
if (e.type == NSKeyDown && e.keyCode == 48) return NO;
return YES;
}

Resources