19

I have a custom NSView (it's one of many and they all live inside an NSCollectionView — I don't think that's relevant, but who knows). When I click the view, I want it to change its selection state (and redraw itself accordingly); when I double-click the view, I want it to pop up a larger preview window for the object that was just double-clicked.

My first looked like this:

- (void)mouseUp: (NSEvent *)theEvent {
    if ([theEvent clickCount] == 1) [model setIsSelected: ![model isSelected]];
    else if ([theEvent clickCount] == 2) if ([model hasBeenDownloaded]) [mainWindowController showPreviewWindowForPicture:model];
}

which mostly worked fine. Except, when I double-click the view, the selection state changes and the window pops up. This is not exactly what I want.

It seems like I have two options. I can either revert the selection state when responding to a double-click (undoing the errant single-click) or I can finagle some sort of NSTimer solution to build in a delay before responding to the single click. In other words, I can make sure that a second click is not forthcoming before changing the selection state.

This seemed more elegant, so it was the approach I took at first. The only real guidance I found from Google was on an unnamed site with a hyphen in its name. This approach mostly works with one big caveat.

The outstanding question is "How long should my NSTimer wait?". The unnamed site suggests using the Carbon function GetDblTime(). Aside from being unusable in 64-bit apps, the only documentation I can find for it says that it's returning clock-ticks. And I don't know how to convert those into seconds for NSTimer.

So what's the "correct" answer here? Fumble around with GetDblTime()? "Undo" the selection on a double-click? I can't figure out the Cocoa-idiomatic approach.

Constantino Tsarouhas
  • 6,846
  • 6
  • 43
  • 54
James Williams
  • 1,861
  • 1
  • 15
  • 21
  • I'd just add [model setIsSelected:NO] to the block after you check [model hasBeenDownloaded]. – NSResponder Nov 03 '09 at 03:26
  • Yeah, that's what I'm doing right now. This is my first big Cocoa app so I was trying to figure out the best practice for the situation. Google was less than helpful, so I figured a Stack Overflow question would be suitable. – James Williams Nov 03 '09 at 12:34

5 Answers5

42

Delaying the changing of the selection state is (from what I've seen) the recommended way of doing this.

It's pretty simple to implement:

- (void)mouseUp:(NSEvent *)theEvent
{
    if([theEvent clickCount] == 1) {
        [model performSelector:@selector(toggleSelectedState) afterDelay:[NSEvent doubleClickInterval]];
    }
    else if([theEvent clickCount] == 2)
    {
        if([model hasBeenDownloaded])
        {
                [NSRunLoop cancelPreviousPerformRequestsWithTarget: model]; 
                [mainWindowController showPreviewWindowForPicture:model];
        }
    }
}

(Notice that in 10.6, the double click interval is accessible as a class method on NSEvent)

Dave DeLong
  • 242,470
  • 58
  • 448
  • 498
  • I had not noticed the extension to NSEvent, that is really good to know. – Louis Gerbarg Nov 03 '09 at 07:30
  • Let's pretend that we're targeting 10.5. Is there an alternative to [NSEvent doubleClickInterval]? – James Williams Nov 03 '09 at 12:30
  • 6
    The above code did not quite work for me: the single click selector was never canceled. I got it to work by using: [NSRunLoop cancelPreviousPerformRequestsWithTarget: model]; instead of [NSRunLoop currentRunLoop] – Mark Jul 06 '11 at 15:49
  • @DaveDeLong: Could you perhaps add the comment from Mark as an update to your answer? – Sandro Meier Aug 11 '13 at 14:29
8

If your single-click and double-click operations are really separate and unrelated, you need to use a timer on the first click and wait to see if a double-click is going to happen. That is true on any platform.

But that introduces an awkward delay in your single-click operation that users typically don't like. So you don't see that approach used very often.

A better approach is to have your single-click and double-click operations be related and complementary. For example, if you single-click an icon in Finder it is selected (immediately), and if you double-click an icon it is selected and opened (immediately). That is the behavior you should aim for.

In other words, the consequences of a single-click should be related to your double-click command. That way, you can deal with the effects of the single-click in your double-click handler without having to resort to using a timer.

Darren
  • 25,520
  • 5
  • 61
  • 71
  • So I've examined this and I think the issue is that selection in my app is sticky (to avoid having to use keyboard tricks to select more than one item) which is unusual. Click an item: it's selected. Click another item: now both are selected. Double click the first item: it opens in a window and is unselected. I'll re-evaluate to see if this is the best UI. Thanks for the food-for-thought. – James Williams Nov 03 '09 at 21:15
  • +1 for *"introduces an awkward delay in your single-click"* analysis. That is true on any platform since one must wait to see whether another click arrived within the predefined time window (usually 500ms). – Withheld Mar 29 '13 at 19:20
2

Personally, I think you need to ask yourself why you want this non-standard behaviour.

Can you point to any other application which treats the first click in a double-click as being different from a single-click? I can't think of any...

Jeff Laing
  • 275
  • 1
  • 1
  • Standard or non-standard behavior aside, I actually have a very good reason for this. I'm writing an app specifically to help my mom email pictures of her grandson. She gets confused very easily by computer stuff, so I'm trying to subvert the inevitable "I try to double click but it does something else! HELP!" call. :) – James Williams Nov 03 '09 at 12:31
  • Darren's convinced me that my entire UI might need to be re-thought (see my comment on his answer). So I'll take another look at it. Thanks for the food-for-thought. – James Williams Nov 03 '09 at 21:15
  • (Commenting b/c this comes up on the first page of results): I create a new point on a bezier curve with a single click, and end the curve on a double-click, so I need to separate the two. In 2018, the solution is to use a gesture recognizer to handle the double-click and delay the single-click handling in mouseDown. See https://stackoverflow.com/a/51851026/1922825 – green_knight Aug 14 '18 at 23:35
2

Add two properties to your custom view.

// CustomView.h
@interface CustomView : NSView {
  @protected
    id      m_target;
    SEL     m_doubleAction;
}
@property (readwrite) id target;
@property (readwrite) SEL doubleAction;

@end

Overwrite the mouseUp: method in your custom view.

// CustomView.m
#pragma mark - MouseEvents

- (void)mouseUp:(NSEvent*)event {
    if (event.clickCount == 2) {
        if (m_target && m_doubleAction && [m_target respondsToSelector:m_doubleAction]) {
            [m_target performSelector:m_doubleAction];
        }
    }
}

Register your controller as the target with an doubleAction.

// CustomController.m
- (id)init {
    self = [super init];
    if (self) {
        // Register self for double click events.
        [(CustomView*)m_myView setTarget:self];
        [(CustomView*)m_myView setDoubleAction:@selector(doubleClicked:)];
    }
    return self;
}

Implement what should be done when a double click happens.

// CustomController.m
- (void)doubleClicked:(id)sender {
  // DO SOMETHING.
}
JJD
  • 50,076
  • 60
  • 203
  • 339
  • 2
    Please mind, that clicking into a field and dragging the mouse out of the field while the mouse is still pressed is interpreted as `clickCount = 0`. Moreover, it might be smarter in some cases to use `clickCount > 1` to recognize a double/triple click. – JJD Apr 25 '12 at 22:21
1

@Dave DeLong's solution in Swift 4.2 (Xcode 10, macOS 10.13), amended for use with event.location(in: view)

var singleClickPoint: CGPoint?

override func mouseDown(with event: NSEvent) {
singleClickPoint = event.location(in: self)
perform(#selector(GameScene.singleClickAction), with: nil, afterDelay: NSEvent.doubleClickInterval)
 if event.clickCount == 2 {
    RunLoop.cancelPreviousPerformRequests(withTarget: self)
    singleClickPoint = nil
//do whatever you want on double-click
}
}

@objc func singleClickAction(){
guard let singleClickPoint = singleClickPoint else {return}
//do whatever you want on single-click
}

The reason I'm not using singleClickAction(at point: CGPoint) and calling it with: event.location(in: self) is that any point I pass in - including CGPoint.zero - ends up arriving in the singleClick Action as (0.0, 9.223372036854776e+18). I will be filing a radar for that, but for now, bypassing perform is the way to go. (Other objects seem to work just fine, but CGPoints do not.)

green_knight
  • 1,319
  • 14
  • 26