NSButton with delayed NSMenu - Objective-C/Cocoa
Asked Answered
C

4

12

I want to create an NSButton that sends an action when it is clicked, but when it is pressed for 1 or two seconds it show a NSMenu. Exactly the same as this question here, but since that answer doesn't solve my problem, I decided to ask again.

As an example, go to Finder, open a new window, navigate through some folders and then click the back button: you go to the previous folder. Now click and hold the back button: a menu is displayed. I don't know how to do this with a NSPopUpButton.

Carman answered 8/2, 2012 at 15:18 Comment(0)
T
14

Use NSSegmentedControl.

Add a menu by sending setMenu:forSegment: to the control (connecting anything to the menu outlet in IB won't do the trick). Have an action connected to the control (this is important).

Should work exactly as you described.

Throve answered 20/3, 2012 at 4:43 Comment(1)
Too bad you can't set сustom height for NSSegmentedControl – I need that menu attached to a large button.Throve
E
6

Create a subclass of NSPopUpButton and override the mouseDown/mouseUp events.

Have the mouseDown event delay for a moment before calling super's implementation and only if the mouse is still being held down.

Have the mouseUp event set the selectedMenuItem to nil (and therefore selectedMenuItemIndex will be -1) before firing the button's target/action.

The only other issue is to handle rapid clicks, where the timer for one click might fire at the moment when the mouse is down for some future click. Instead of using an NSTimer and invalidating it, I chose to have a simple counter for mouseDown events and bail out if the counter has changed.

Here's the code I'm using in my subclass:

// MyClickAndHoldPopUpButton.h
@interface MyClickAndHoldPopUpButton : NSPopUpButton

@end

// MyClickAndHoldPopUpButton.m
@interface MyClickAndHoldPopUpButton ()

@property BOOL mouseIsDown;
@property BOOL menuWasShownForLastMouseDown;
@property int mouseDownUniquenessCounter;

@end

@implementation MyClickAndHoldPopUpButton

// highlight the button immediately but wait a moment before calling the super method (which will show our popup menu) if the mouse comes up
// in that moment, don't tell the super method about the mousedown at all.
- (void)mouseDown:(NSEvent *)theEvent
{
  self.mouseIsDown = YES;
  self.menuWasShownForLastMouseDown = NO;
  self.mouseDownUniquenessCounter++;
  int mouseDownUniquenessCounterCopy = self.mouseDownUniquenessCounter;

  [self highlight:YES];

  float delayInSeconds = [NSEvent doubleClickInterval];
  dispatch_time_t popTime = dispatch_time(DISPATCH_TIME_NOW, delayInSeconds * NSEC_PER_SEC);
  dispatch_after(popTime, dispatch_get_main_queue(), ^(void){
    if (self.mouseIsDown && mouseDownUniquenessCounterCopy == self.mouseDownUniquenessCounter) {
      self.menuWasShownForLastMouseDown = YES;
      [super mouseDown:theEvent];
    }
  });
}

// if the mouse was down for a short enough period to avoid showing a popup menu, fire our target/action with no selected menu item, then
// remove the button highlight.
- (void)mouseUp:(NSEvent *)theEvent
{
  self.mouseIsDown = NO;

  if (!self.menuWasShownForLastMouseDown) {
    [self selectItem:nil];

    [self sendAction:self.action to:self.target];
  }

  [self highlight:NO];
}

@end
Expressly answered 30/12, 2012 at 0:18 Comment(2)
Beautiful! This is exactly what I was looking for. Too bad there isn't a standard control in App Kit for this sort of thing (which is odd because Apple uses this UI convention in a lot of its own apps).Delectate
For delayInSeconds consider using NSEvent.doubleClickInterval instead of the constant 0.2. This will adjust the delay according to the user's mouse handling preferences. Faster, less delay, for users with short double-click times and slower, more delay, for users with longer double-click times.Lashoh
D
6

If anybody still needs this, here's my solution based on a plain NSButton, not a segmented control.

Subclass NSButton and implement a custom mouseDown that starts a timer within the current run loop. In mouseUp, check if the timer has not fired. In that case, cancel it and perform the default action.

This is a very simple approach, it works with any NSButton you can use in IB.

Code below:

- (void)mouseDown:(NSEvent *)theEvent {
    [self setHighlighted:YES];
    [self setNeedsDisplay:YES];

    _menuShown = NO;
    _timer = [NSTimer scheduledTimerWithTimeInterval:0.3 target:self selector:@selector(showContextMenu:) userInfo:nil repeats:NO];

    [[NSRunLoop currentRunLoop] addTimer:_timer forMode:NSDefaultRunLoopMode];
}

- (void)mouseUp:(NSEvent *)theEvent {
    [self setHighlighted:NO];
    [self setNeedsDisplay:YES];

    [_timer invalidate];
    _timer = nil;

    if(!_menuShown) {
        [NSApp sendAction:[self action] to:[self target] from:self];
    }

    _menuShown = NO;
}

- (void)showContextMenu:(NSTimer*)timer {
    if(!_timer) {
        return;
    }

    _timer = nil;
    _menuShown = YES;

    NSMenu *theMenu = [[NSMenu alloc] initWithTitle:@"Contextual Menu"];

    [[theMenu addItemWithTitle:@"Beep" action:@selector(beep:) keyEquivalent:@""] setTarget:self];
    [[theMenu addItemWithTitle:@"Honk" action:@selector(honk:) keyEquivalent:@""] setTarget:self];

    [theMenu popUpMenuPositioningItem:nil atLocation:NSMakePoint(self.bounds.size.width-8, self.bounds.size.height-1) inView:self];

    NSWindow* window = [self window];

    NSEvent* fakeMouseUp = [NSEvent mouseEventWithType:NSLeftMouseUp
                                              location:self.bounds.origin
                                         modifierFlags:0
                                             timestamp:[NSDate timeIntervalSinceReferenceDate]
                                          windowNumber:[window windowNumber]
                                               context:[NSGraphicsContext currentContext]
                                           eventNumber:0
                                            clickCount:1
                                              pressure:0.0];

    [window postEvent:fakeMouseUp atStart:YES];

    [self setState:NSOnState];
}

I've posted a working sample on my GitHub.

Disassembly answered 10/10, 2016 at 5:25 Comment(0)
I
3

Late to the party but here is a bit different approach, also subclassing NSButton:

///
/// @copyright © 2018 Vadim Shpakovski. All rights reserved.
///

import AppKit

/// Button with a delayed menu like Safari Go Back & Forward buttons.
public class DelayedMenuButton: NSButton {

  /// Click & Hold menu, appears after `NSEvent.doubleClickInterval` seconds.
  public var delayedMenu: NSMenu?
}

// MARK: -

extension DelayedMenuButton {

  public override func mouseDown(with event: NSEvent) {

    // Run default implementation if delayed menu is not assigned
    guard delayedMenu != nil, isEnabled else {
      super.mouseDown(with: event)
      return
    }

    /// Run the popup menu if the mouse is down during `doubleClickInterval` seconds
    let delayedItem = DispatchWorkItem { [weak self] in
      self?.showDelayedMenu()
    }
    DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(Int(NSEvent.doubleClickInterval * 1000)), execute: delayedItem)

    /// Action will be set to nil if the popup menu runs during `super.mouseDown`
    let defaultAction = self.action

    // Run standard tracking
    super.mouseDown(with: event)

    // Restore default action if popup menu assigned it to nil
    self.action = defaultAction

    // Cancel popup menu once tracking is over
    delayedItem.cancel()
  }
}

// MARK: - Private API

private extension DelayedMenuButton {

  /// Cancels current tracking and runs the popup menu
  func showDelayedMenu() {

    // Simulate mouse up to stop native tracking
    guard
      let delayedMenu = delayedMenu, delayedMenu.numberOfItems > 0, let window = window, let location = NSApp.currentEvent?.locationInWindow,
      let mouseUp = NSEvent.mouseEvent(
        with: .leftMouseUp, location: location, modifierFlags: [], timestamp: Date.timeIntervalSinceReferenceDate,
        windowNumber: window.windowNumber, context: NSGraphicsContext.current, eventNumber: 0, clickCount: 1, pressure: 0
      )
    else {
        return
    }

    // Cancel default action
    action = nil

    // Show the default menu
    delayedMenu.popUp(positioning: nil, at: .init(x: -4, y: bounds.height + 2), in: self)

    // Send mouse up when the menu is on screen
    window.postEvent(mouseUp, atStart: false)
  }
}
Irresoluble answered 3/8, 2018 at 11:59 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.