NSTableView: checking for mouse-driven selection changes on macOS 27

I have an NSTableView used as a source list and, alongside it, two editors. When the user selects anything in the table view, its content is opened in the editor that has the focus. When the user Opt-clicks an item in the table, though, the content is opened in the other editor, making it easy for the user to load something in the other editor without having to change the focus first.

This has worked for many years using NSTableView.selectiondDidChange / the NSTableViewDelegate as follows:

func tableViewSelectionDidChange(_ notification: Notification) {
  if let event = tableView.window?.currentEvent,
     event.type == .leftMouseUp || event.type == .leftMouseDown,
    // (Real app does some other checks here too.)
     event.modifierFlags.contains(.option) {
       openInOtherEditor()
       return
  }
  openInCurrentEditor()
}

However, on macOS 27, it seems that things need to be done differently because of the transition to gesture recognisers for event handling. According to the WWDC video "Modernise Your AppKit App", and to Tech Note TN3212, currentEvent can no longer be relied upon to provide the event that actually triggered an action in NSControl subclasses:

The transition to gesture recognizers on NSControl objects changes the timing of when AppKit delivers control action messages with respect to event processing. As a result, currentEvent no longer returns the event that triggered an action.

It's unclear whether this new limitation refers only to NSControl.action or to all mouse-driven actions, but from the context and what the rest of the Tech Note has to say, I assume it's the latter. (Especially since you are no longer supposed to override mouseDown(with:), and the Console warns about gestures being disabled if you do override mouseDown(with:) in an NSTableView subclass on macOS 27.)

currentEvent still seems to work fine in this situation in the first macOS 27 beta, but it sounds as though we cannot rely on this continuing to be the case.

If we should no longer be using currentEvent, then, what should we use instead to determine whether a selection change was triggered by a mouse click? The Tech Note and WWDC video have nothing to say about this. They simply say that instead of overriding mouseDown(with:), you should use the selection-did-change delegate methods, which is of no help here. (By contrast, checking the modifier flags is still straightforward; the Tech Note says to use NSEvent.modifierFlags instead of currentEvent.modifierFlags.)

Two solutions sprung to mind, but neither worked:

  1. Check tableView.clickedRow != -1 in the selectionDidChange delegate method/notification response. This doesn't work, however, because clickedRow has been reset to -1 by the time NSTableView.selectionDidChange is sent.
  2. Add an action to the table view and check clickedRow there. This doesn't work either, though, because although clickedRow is available in the action method, I would now have to load content in response to both an action and a selection change, and since the selection changes before the action is called, there is no way of telling my selection-did-change method not to load in the main editor if Option is held down in the action.

The only solution I have found is to override selectRowIndexes(_:byExtendingSelection:), check for clickedRow != -1 there, set a didChangeSelectionWithMouse flag to true if so, and check that in the selection-did-change delegate method. That works, but it's not the most elegant of solutions.

So:

  1. Am I misunderstanding the Tech Note? Can currentEvent still in fact be used safely in tableViewSelectionDidChange(_:) in macOS 27 and beyond?
  2. If not, what is the recommended way of checking that the table selection has been changed by a mouse click?

Many thanks!

Answered by Frameworks Engineer in 893966022

With gesture recognizers, NSApp.currentEvent is not reliable. Since you're just trying to get at the state of the modifier flags it should be sufficient to just change to the class function NSEvent.modifierFlags inside your table view delegate call.

With gesture recognizers, NSApp.currentEvent is not reliable. Since you're just trying to get at the state of the modifier flags it should be sufficient to just change to the class function NSEvent.modifierFlags inside your table view delegate call.

Since you're just trying to get at the state of the modifier flags

I appreciate the answer and also that in retrospect I should have made my actual question clear sooner in my post, but my question was about how to check if the selection was changed by a mouse click, not about how to get the state of modifier flags. (I mentioned in my post that getting the modifier flags is not a problem and covered by the Tech Note.)

So how do I check if a table or outline view selection change was triggered by a mouse click (as opposed to e.g. keyboard navigation) without relying on currentEvent?

In my example, opening content in the other editor should happen only if the selection has changed because the user is pressing Option while mouse-clicking on a row. (We wouldn't want this behaviour while using the arrow keys to select while holding Option, for instance, because Option already has a meaning in this case: selecting the first or last item.) Option-click has a long tradition on macOS of providing alternative actions like this, and it's a handy trick that has been available in my app for years.

Note that using NSEvent.pressedMouseButtons doesn't work here, either, because the value of that will be 0 by the time tableViewSelectionDidChange is called.

So withoutcurrentEvent, how do I check to see if tableViewSelectionDidChange was triggered by a mouse click?

Thank you.

Actually, referring to the ancient DragNDropOutlineView code linked to from the documentation for NSTableView.clickedRow, it seems that although clickedRow has been reset to -1 by the time tableViewDidChangeSelection(_:) is called, it is available and correct in tableView(_:shouldSelectRow:) and its more modern equivalent, tableView(_:selectionIndexesForProposedSelection:).

So a simple solution is to check for clickedRow in one of these delegate methods to see if the selection is changing owing to a click, and then save the information in a property that can be used in the didChange delegate method, like this:

private var didClick = false
    
func tableView(_ tableView: NSTableView, selectionIndexesForProposedSelection proposedSelectionIndexes: IndexSet) -> IndexSet {
  didClick = tableView.clickedRow != -1 && proposedSelectionIndexes.contains(tableView.clickedRow)
  return proposedSelectionIndexes
}
    
func tableViewSelectionDidChange(_ notification: Notification) {

  // We must reset this because `tableViewSelectionDidChange` could be called without the `proposedSelection` delegate method being called first.
  defer {
    didClick = false
  }

  if didClick && NSEvent.modifierFlags.contains(.option) {
    openInOtherEditor()
    return
  }
  openInCurrentEditor()
}

I don't think you're supposed to read clickedRow in -tableView:selectionIndexesForProposedSelection:. Unless something has changed in macOS 27 the documentation for clickedRow states:

The value of this property is meaningful in the target object’s implementation of the action and double-action methods.

NSTableView is a subclass of NSControl. You should be able to pick up a click change by setting the table view's target-action instead of trying to do it in the delegate methods. Doing it in the delegate method like that might work but IMO this is cleaner and probably safer:

-(void)windowDidLoad
{
 [super windowDidLoad];
 self.tableView.target = self;
 self.tableView.action = @selector(tableViewAction:);
}

-(void)tableViewAction:(NSTableView*)sender
{
    NSLog(@"Clicked Column: %li Clicked row: %li",
          sender.clickedColumn,
          sender.clickedRow);
}

That will trigger on click. NSTableView does not invoke the action on keyboard navigation (at least not on macOS 26).

I'm a bit worried that these AppKit changes might break a bunch of stuff. Hope not. I guess touch screen Macs are coming soon.

Thanks for the reply.

I don't think you're supposed to read clickedRow in -tableView:selectionIndexesForProposedSelection:. Unless something has changed in macOS 27 the documentation for clickedRow states:

This is true, but in testing, clickedRow does work both in an override of selectRowIndexes(_:byExtendingSelection:) and in the delegate tableView(_:selectionIndexesForProposedSelection:). Also, Apple's own sample code for DragNDropOutlineView, which is linked to in the documentation for clickedRow, demonstrates using clickedRow in tableView(_:shouldSelectRow:), which was the older version of tableView(_:selectionIndexesForProposedSelection:). So if Apple's sample code uses it this way, I figure it should be fine.

You should be able to pick up a click change by setting the table view's target-action instead of trying to do it in the delegate methods

I mentioned in my original post that using an action in this case unfortunately isn't a good solution. I need to load content in response to a selection change, and an action won't be fired when the selection is changed in ways other than clicking (it won't be fired when you use keyboard navigation, for instance, as you note). And since the selection-did-change delegate method is called before the action is called, I can't implement both, either, because the selection-did-change method would load content in the main editor before I could check in the action that it should load in the other editor. So content would wrongly get loaded in both, or I'd have to revert content in the main editor when the action was clicked, which would not be pretty.

The only way for this to work is for me to know if the selection changed (in selection-did-change) because of both a mouse click and an Option press - something that has worked for years.

Fortunately, like I say, clickedIndex does seem to work for this - at the moment, at least. (The ideal solution would just be for Apple to make clickedIndex valid during selection-did-change calls.)

I'm a bit worried that these AppKit changes might break a bunch of stuff. Hope not. I guess touch screen Macs are coming soon.

That's my assumption for why these changes are being made too. It's odd that such sweeping changes were barely mentioned at WWDC, getting only a brief mention in the AppKit video and a tech note, since these changes require quite a lot of work in custom controls, tables and text views, and have broken behaviour that has worked for years. (For instance, in NSTextView, selectedRange now only updates after menuForEvent: and its delegate method are called, rather than before, breaking custom context menus in text views. Hopefully that's a bug, though - I've reported it as such.)

NSTableView: checking for mouse-driven selection changes on macOS 27
 
 
Q