MATTREAGAN
iOS/Mac developer, designer,
game creator, biker guy
September 23rd, 2015
« Previous | Next »

NSView control-click quirks
by Matt Reagan
Yesterday I came across an interesting AppKit issue, reproducible in OS X 10.10 (but this has been around since at least 10.7). On the Mac, most users expect a control-click to behave identically to a right-click, and display a contextual menu. This is true in the Finder and most Cocoa apps. Within AppKit the primary hook for providing contextual menus in NSView is:
- (NSMenu *)menuForEvent:(NSEvent *)theEvent
Your view returns a menu, and it's automatically shown by NSView. The problem occurs when your view wraps one or more subviews and the click lands within a subview. (This is a common scenario, e.g. custom UI components that wrap multiple subviews but provide a top-level contextual menu).

You'd expect that NSView would display the contextual menu for your top-level container view for both right and control clicks, but it doesn't. A right click anywhere in the parent view (even if on a subview) shows the contextual menu as expected, whereas control-clicking within the view only shows the contextual menu if the click does not occur on one of the subviews.

Right-click: works as expected
Control-click: no effect on subviews


The default NSView handling for -rightMouseDown: passes the message to its superview if -menuForEvent: returns nil. This gives right-clicks a chance to work their way up the view hierarchy until they hit the top-level container view, which does have a menu and shows it as the default behavior. There's no parallel to this for a control-click, however. This is a problem since both of these actions should behave the same.

So what to do? There are a number of possible workarounds:

  1. Traverse the subview tree, explicitly calling -setMenu: for all subviews
  2. Subclass/override all subview classes as needed to explicitly return e.g. [self.superview menuForEvent:]
  3. Override -hitTest: in the top-level NSView
None of these approaches are great, for a number of reasons. Traversing and manipulating the entire subview tree is fragile, costly, and clunky. Overriding -hitTest: will typically cause conflicts with other mouse event handling for your subviews.

The best solution I've found is to display the menu for control-clicks yourself. While this isn't ideal, it incurs the fewest side-effects, and provides the expected behavior, without any significant changes to existing code: