bdunagan

Brian Dunagan

February 25 2010
Create iTunes Link Arrows

iTunes has a great user interface affordance for adding actions to text: clickable arrows embedded right next to the text. (Perhaps Apple wanted to avoid an interface filled with blue, underlined text.) I haven’t found any formal name for them in Apple’s HIG, so I call them link arrows (or jump arrows). Sadly, this is my third post on recreating link arrows. My first post was a first pass at the problem, whereas my second post looked a bit better but not great.

This time, the UI looks right, and clicking works as expected. I wrote up a small sample app to demo the link arrows; see Google Code.



The key is tracking. In the previous post, I used NSCell::hitTestForEvent to detect whether a click “hit” the link arrow image on a mouse down event, but then I immediately acted on it, without waiting for a mouse up event. Jarring and wrong.

I need to track what happens after that initial NSLeftMouseDown event. For this, I use NSCell::trackMouse to track all relevant mouse events until the next NSLeftMouseUp event. Thanks to Apple’s PhotoSearch sample app and Rowan Beentje’s Sequel Pro code for this much needed direction.

There are a couple corner cases I handle in trackMouse:

  • mouse down outside => mouse up inside (no click)
  • mouse down inside => mouse up outside (no click)
  • mouse down inside => mouse drag out => mouse drag in => mouse up inside (click) </ul> These mouse event sequences are what people expect from buttons, and I want people to think of the link arrows as buttons, as iTunes treats them. Below is the main code from LinkArrowCell.m. The rest is just scaffolding.
    // snipped from LinkArrows/LinkArrowCell.m (MIT license)
    
    - (void)drawInteriorWithFrame:(NSRect)aRect inView:(NSView *)controlView {
    	NSRect textRect = NSMakeRect(aRect.origin.x, aRect.origin.y, aRect.size.width - kLinkArrowWidth - kLinkArrowWidthPadding, aRect.size.height);
    
    	// Draw text.
    	[super drawInteriorWithFrame:textRect inView:controlView];
    
    	// Draw link arrow.
    	if ([self shouldDisplayLink]) {
    		if (![self isHighlighted]) {
    			// The cell is not highlighted.
    			[linkArrow setImage:[NSImage imageNamed:@"link_arrow"]];
    			[linkArrow setAlternateImage:[NSImage imageNamed:@"link_arrow_click"]];
    		}
    		else if ([[[self controlView] window] isKeyWindow] && [[[self controlView] window] firstResponder] == [self controlView]) {
    			// The cell is highlighted, and the window is key.
    			[linkArrow setImage:[NSImage imageNamed:@"link_arrow_highlight"]];
    			[linkArrow setAlternateImage:[NSImage imageNamed:@"link_arrow_click_highlight"]];
    		}
    		else {
    			// The cell is highlighted, but the window is not key.
    			[linkArrow setImage:[NSImage imageNamed:@"link_arrow_click"]];
    			[linkArrow setAlternateImage:[NSImage imageNamed:@"link_arrow"]];
    		}
    
    		[linkArrow drawInteriorWithFrame:[LinkArrowCell linkRect:aRect] inView:controlView];
    	}
    }
    
    - (NSUInteger)hitTestForEvent:(NSEvent *)event inRect:(NSRect)cellFrame ofView:(NSView *)controlView {
    	// Figure out hit point in view.
    	NSRect linkRect = [LinkArrowCell linkRect:cellFrame];
    	NSPoint p = [[[NSApp  mainWindow] contentView] convertPoint:[event locationInWindow] toView:controlView];
    	if (p.x > linkRect.origin.x && p.x < (linkRect.origin.x + linkRect.size.width) && 
    		p.y > linkRect.origin.y && p.y < (linkRect.origin.y + linkRect.size.height)) {
    		// Point inside link arrow.
    		[linkArrow setState:NSOnState];
    		return NSCellHitContentArea | NSCellHitTrackableArea;
    	}
    	else {
    		// Point outside link arrow.
    		[linkArrow setState:NSOffState];
    		return [super hitTestForEvent:event inRect:cellFrame ofView:controlView];
    	}
    }
    
    - (BOOL)trackMouse:(NSEvent *)event inRect:(NSRect)cellFrame ofView:(NSView *)controlView untilMouseUp:(BOOL)untilMouseUp {
    	// Check if link arrow was hit.
    	int hitType = [self hitTestForEvent:[NSApp currentEvent] inRect:cellFrame ofView:controlView];
    	BOOL isButtonClicked = hitType == (NSCellHitContentArea | NSCellHitTrackableArea);
    	if (!isButtonClicked) return YES;
    
    	// Ignore events other than mouse down.
    	if ([event type] != NSLeftMouseDown) return YES;
    
    	// Grab all events until a mouse up event.
    	while ([event type] != NSLeftMouseUp) {
    		// Check if link arrow was hit.
    		hitType = [self hitTestForEvent:[NSApp currentEvent] inRect:cellFrame ofView:controlView];
    		isButtonClicked = hitType == (NSCellHitContentArea | NSCellHitTrackableArea);
    		// Update the cell's display.
    		[controlView setNeedsDisplayInRect:cellFrame];
    		// Pass event if not hit.
    		if (!isButtonClicked) {
    			[NSApp sendEvent:event];
    		}
    		// Grab next event.
    		event = [[controlView window] nextEventMatchingMask:(NSLeftMouseUpMask | NSLeftMouseDraggedMask | NSMouseEnteredMask | NSMouseExitedMask)];
    	}
    
    	// Perform click only if link arrow was hit.
    	if (isButtonClicked) {
    		[self performClick:nil];
    	}
    
    	return YES;
    }
    Before I went with the trackMouse approach, I looked at subclassing NSCell and overriding startTrackingAt, continueTracking, stopTracking. For some reason, my overridden methods never triggered. Very odd as Apple's NSCell docs bring up the approach, and many developers refer to it working on various email lists like cocoa-dev. Still not sure why this path didn't work, but trackMouse certainly does. Again, a sample project for this code is at Google Code.
Cocoa Tip: NSApp's currentEvent Codesigning on Different OS Versions
LinkedIn GitHub Email