38

Why mouseExited/mouseEntered isn't called when mouse exits from NStrackingArea by scrolling or doing animation?

I create code like this:

Mouse entered and exited:

-(void)mouseEntered:(NSEvent *)theEvent {
    NSLog(@"Mouse entered");
}

-(void)mouseExited:(NSEvent *)theEvent
{
    NSLog(@"Mouse exited");
}

Tracking area:

-(void)updateTrackingAreas
{ 
    if(trackingArea != nil) {
        [self removeTrackingArea:trackingArea];
        [trackingArea release];
    }

    int opts = (NSTrackingMouseEnteredAndExited | NSTrackingActiveAlways);
    trackingArea = [ [NSTrackingArea alloc] initWithRect:[self bounds]
                                             options:opts
                                               owner:self
                                            userInfo:nil];
    [self addTrackingArea:trackingArea];
}

More details:

I have added NSViews as subviews in NSScrollView's view. Each NSView have his own tracking area and when I scroll my scrollView and leave tracking area "mouseExited" isn't called but without scrolling everything works fine. Problem is that when I scroll "updateTrackingAreas" is called and I think this makes problems.

* Same problem with just NSView without adding it as subview so that's not a problem.

Justin Boo
  • 10,132
  • 8
  • 50
  • 71
  • There is few things to take into consideration. What is the superclass ? Do you overwrite any superclass method without sending super ? Then, here is options I always pass to the trackingArea to be sure that the mouse is really always tracked: NSTrackingMouseEnteredAndExited|NSTrackingMouseMoved|NSTrackingActiveInKeyWindow – Dimillian Jan 31 '12 at 15:59
  • @Dimillian77 I changed to "NSTrackingMouseEnteredAndExited|NSTrackingMouseMoved|NSTrackingActiveInKeyWindow" but that did'nt help.. same problem. And without "NSTrackingActiveAlways" it didn't work at all.. I updated my question that it be more clearer. – Justin Boo Jan 31 '12 at 19:43
  • you need to call `[super updateTrackingAreas]`. And this code is inside the NSViews subclasses or the NSScrollView? – Marcelo Alves Feb 01 '12 at 10:05
  • @MarceloAlves I didn't call it, it is automatically called when I do scroll. – Justin Boo Feb 01 '12 at 10:15

2 Answers2

80

As you noted in the title of the question, mouseEntered and mouseExited are only called when the mouse moves. To see why this is the case, let's first look at the process of adding NSTrackingAreas for the first time.

As a simple example, let's create a view that normally draws a white background, but if the user hovers over the view, it draws a red background. This example uses ARC.

@interface ExampleView

- (void) createTrackingArea

@property (nonatomic, retain) backgroundColor;
@property (nonatomic, retain) trackingArea;

@end

@implementation ExampleView

@synthesize backgroundColor;
@synthesize trackingArea

- (id) awakeFromNib
{
    [self setBackgroundColor: [NSColor whiteColor]];
    [self createTrackingArea];
}

- (void) createTrackingArea
{
    int opts = (NSTrackingMouseEnteredAndExited | NSTrackingActiveAlways);
    trackingArea = [ [NSTrackingArea alloc] initWithRect:[self bounds]
                                             options:opts
                                               owner:self
                                            userInfo:nil];
    [self addTrackingArea:trackingArea];
}

- (void) drawRect: (NSRect) rect
{
    [[self backgroundColor] set];
    NSRectFill(rect);
}

- (void) mouseEntered: (NSEvent*) theEvent
{
    [self setBackgroundColor: [NSColor redColor]];
}

- (void) mouseEntered: (NSEvent*) theEvent
{
    [self setBackgroundColor: [NSColor whiteColor]];
}

@end

There are two problems with this code. First, when -awakeFromNib is called, if the mouse is already inside the view, -mouseEntered is not called. This means that the background will still be white, even though the mouse is over the view. This is actually mentioned in the NSView documentation for the assumeInside parameter of -addTrackingRect:owner:userData:assumeInside:

If YES, the first event will be generated when the cursor leaves aRect, regardless if the cursor is inside aRect when the tracking rectangle is added. If NO the first event will be generated when the cursor leaves aRect if the cursor is initially inside aRect, or when the cursor enters aRect if the cursor is initially outside aRect.

In both cases, if the mouse is inside the tracking area, no events will be generated until the mouse leaves the tracking area.

So to fix this, when we add the tracking area, we need to find out if the cursor is within in the tracking area. Our -createTrackingArea method thus becomes

- (void) createTrackingArea
{
    int opts = (NSTrackingMouseEnteredAndExited | NSTrackingActiveAlways);
    trackingArea = [ [NSTrackingArea alloc] initWithRect:[self bounds]
                                             options:opts
                                               owner:self
                                            userInfo:nil];
    [self addTrackingArea:trackingArea];

    NSPoint mouseLocation = [[self window] mouseLocationOutsideOfEventStream];
    mouseLocation = [self convertPoint: mouseLocation
                              fromView: nil];

    if (NSPointInRect(mouseLocation, [self bounds]))
    {
        [self mouseEntered: nil];
    }
    else
    {
        [self mouseExited: nil];
    }
}

The second problem is scrolling. When scrolling or moving a view, we need to recalculate the NSTrackingAreas in that view. This is done by removing the tracking areas and then adding them back in. As you noted, -updateTrackingAreas is called when you scroll the view. This is the place to remove and re-add the area.

- (void) updateTrackingAreas
{
    [self removeTrackingArea:trackingArea];
    [self createTrackingArea];
    [super updateTrackingAreas]; // Needed, according to the NSView documentation
}

And that should take care of your problem. Admittedly, needing to find the mouse location and then convert it to view coordinates every time you add a tracking area is something that gets old quickly, so I would recommend creating a category on NSView that handles this automatically. You won't always be able to call [self mouseEntered: nil] or [self mouseExited: nil], so you might want to make the category accept a couple blocks. One to run if the mouse is in the NSTrackingArea, and one to run if it is not.

dlinsin
  • 19,249
  • 13
  • 42
  • 53
Michael Buckley
  • 4,095
  • 1
  • 26
  • 23
  • 3
    Thank You for Your clear very good answer! I just updated some lines of Your code and it works perfectly! So now I can awarded You with my bounty and +1, thanks! – Justin Boo Feb 02 '12 at 09:56
  • Thanks a lot! Saved my day. The biggest mistake I did was, assuming that NSTrackingInVisibleRect will take care of it like it says at http://stackoverflow.com/a/4137243/804616 which doesn't seem to be good enough for NSOutlineView (haven't tried an isolated example to check if it acts as expected otherwise). – trss Jul 10 '12 at 15:33
  • Awesome!. Thanks for this info! I was about to create a rabbit hole solving this in a much more complex manner. – Urkle Feb 13 '14 at 13:52
  • 2
    -updateTrackingAreas doesn't seem to get called on all the descendants of the scroll view. (I'm trying to get this to work for a subview of a NSTableCellView.) Any ideas for how to work around that? – mdiep Mar 25 '14 at 20:55
  • 2
    @mdiep same problem here, this solution doesn't seems to work for view-based table views. Any ideas for a work around? – Daniel Farrell Aug 10 '14 at 23:37
  • When using Swfit 2.0 in Xcode 7, the extra part of code will cause problem on the 3rd time the view loads. At least in a popover. – Cai Jun 16 '15 at 12:06
  • Had no problem converting this into functional Swift code in a matter of minutes. – d00dle Dec 26 '15 at 13:20
  • 4
    If anyone wants this to work with view-based table views, they need to listen to the NSViewBoundsDidChangeNotification on the NSClipView if the NSScrollView. From there, you should update the tracking rect for every view in the table. – AndyTang Mar 28 '16 at 14:30
  • Isn't this code missing types on the `@property` lines? Also I had to change `trackingArea` to `self.trackingArea` – Liron Yahdav Jun 28 '22 at 17:30
4

@Michael offers a great answer, and solved my problem. But there is one thing,

if (CGRectContainsPoint([self bounds], mouseLocation))
{
    [self mouseEntered: nil];
}
else
{
    [self mouseExited: nil];
}

I found CGRectContainsPoint works in my box, not CGPointInRect,

fengd
  • 7,551
  • 3
  • 41
  • 44
  • 1
    You are correct that CGPointInRect is not a standard function. I meant to use "NSPointInRect", which is a part of Foundation. I would recommend using NSPointInRect when dealing with NSPoints, and CGRectContainsPoint when working with CGPoint, as NSRect and CGPoint are actually different structs. In any case, thank you for pointing out the error. I have updated my original answer to correct it. – Michael Buckley Oct 04 '12 at 21:34
  • 1
    @MichaelBuckley what's the difference between NSPoint and CGPoint, I assume they are the same, just one for cocoa and one for carbon? – fengd Oct 05 '12 at 07:03
  • 1
    Looking at it again, the two structs are identical. Thanks for correcting me again. They've both been in OS X since the first version, and the only difference is that NSPoint is defined in Foundation and CGPoint is defined in ApplicationServices. Neither are strictly Carbon, though Carbon uses CGPoint. I was actually thinking of NSRange and CFRange, which are potentially different. NSRange uses NSUinteger members, and CFRange uses CFIndex members. These two structs may be the same depending on architecture, but are not guaranteed to be the same. – Michael Buckley Oct 05 '12 at 16:58