fill the void

Posted
10 November 2008 @ 7pm

Tagged
development

5 Comments

Cocoa Tutorial: Link Arrows, Part 2

Unfortunately, while my first pass at link arrows functionally worked, it didn’t work very well.

First pass

If you take the code from the original post, hook up setTitle: in a willDisplayCell: method, and connect the link arrow cell’s selector to an action, you get this. The text for the link arrow cell is clearly different from the normal table cell, and clicking anywhere on the link arrow cell triggers the action. Bad.

Second pass

Let’s say you’re okay with triggering the action by clicking anywhere in the cell. You could try to use setAttributedTitle: instead of setTitle. However, then when you highlight a row, the text doesn’t turn white. Bad.

Third pass

Perhaps you realize that you can fix the color by simply getting the current color of the original attributed title. My code for this has already turned into the following:

NSMutableDictionary *dict = [NSMutableDictionary dictionary];
NSFont *font = [NSFont systemFontOfSize:13];
[dict setObject:font forKey:NSFontAttributeName];
NSRange range = NSMakeRange(0, 0);
NSColor *fontColor = [[aCell attributedTitle] attribute:NSForegroundColorAttributeName atIndex:0 effectiveRange:&range];
[dict setObject:fontColor forKey:NSForegroundColorAttributeName];
NSAttributedString *title = [[NSAttributedString alloc] initWithString:[currentFeedItem valueForKeyPath:@"properties.title"] attributes:dict];
[aCell setAttributedTitle:title];

The text color works, both when highlighted and defocused. But, there is still the problem of clicking anywhere triggering the action. Bad.

Subclassing NSTextFieldCell
I iterated through all of these before stepping back and rethinking my approach. The problem was leveraging NSButtonCell. While the cell type worked as a quick hack, it left a lot to be desired. It didn’t handle clicks correctly. And I couldn’t add an icon next to the text. Subclassing was necessary to get the cell to work just right. Instead of NSButtonCell, I subclassed NSTextFieldCell and composed in an NSButtonCell. That way, the title’s font/size/color just worked. To get the objects in the right place in the cell, I simply overrode the cell’s draw method and positioned them.

One difficulty was registering clicks. I wanted the user to be able to click on a link arrow to perform an action (like open a feed item in a browser). In 10.5, I found the method - (NSUInteger)hitTestForEvent:(NSEvent *)event inRect:(NSRect)cellFrame ofView:(NSView *)controlView. The resulting custom cell worked well and was extensible in case I wanted to add an icon next to the title. Check out the code below:

@implementation BDLinkArrowCell

- (id)initWithCoder:(NSCoder *)coder
{
	self = [super initWithCoder:coder];
	if (self)
	{
		// Set up link arrow.
		linkArrow = [[NSButtonCell alloc] init];
		[linkArrow setButtonType:NSSwitchButton];
		[linkArrow setBezelStyle:NSSmallSquareBezelStyle];
		[linkArrow setImagePosition:NSImageRight];
		[linkArrow setTitle:@""];
		[linkArrow setBordered:NO];
		[linkArrow setImage:[NSImage imageNamed:NSImageNameFollowLinkFreestandingTemplate]];
		[linkArrow setAlternateImage:[NSImage imageNamed:NSImageNameFollowLinkFreestandingTemplate]];
	}
	return self;
}

- (void)encodeWithCoder:(NSCoder *)coder
{
	[super encodeWithCoder:coder];
}

- (void)setLinkVisible:(BOOL)isVisible
{
	isLinkVisible = isVisible;
}

- (void)drawInteriorWithFrame:(NSRect)aRect inView:(NSView *)controlView
{
	NSRect textRect = NSMakeRect(aRect.origin.x, aRect.origin.y, aRect.size.width - 18, aRect.size.height);
	linkRect = NSMakeRect(aRect.origin.x + aRect.size.width - 12, aRect.origin.y, 12, aRect.size.height);

	// Draw text.
	[super drawInteriorWithFrame:textRect inView:controlView];

	// Draw link arrow.
	if (isLinkVisible)
		[linkArrow drawInteriorWithFrame:linkRect inView:controlView];
}

// 10.5+ method
- (NSUInteger)hitTestForEvent:(NSEvent *)event inRect:(NSRect)cellFrame ofView:(NSView *)controlView
{
	NSPoint p = [[[NSApp  mainWindow] contentView] convertPoint:[event locationInWindow] toView:controlView];
	if (p.x > linkRect.origin.x && p.x < (linkRect.origin.x + linkRect.size.width))
	{
		// Hit the link.
		[[NSApp delegate] openURLInBrowser:nil];
		return NSCellHitContentArea | NSCellHitEditableTextArea;
	}
	else
	{
		return NSCellHitNone;
	}
}

@end

5 Comments

Posted by
Robert Mullen
20 November 2008 @ 10am

Thanks for the code, I have been using this sort of thing in my app after following a different thread and running into the exact same issues you address here. I did make a minor change to your code though but it may be because I am handling things improperly in my controllers. I don’t hit a URL with the arrow click I actually use it to switch out views in a context sensitive manner. For that reason your code at line 51:

[[NSApp delegate] openURLInBrowser:nil];

doesn’t fit my app. I changed that line to:

[self performClick:nil];

in the subclass and then use setAction in the tableView:willDisplayCell:forTableColumn:row to point to the controller specific selector for the context. Works like a charm but may not be useful or properly designed. I am still pretty new to Cocoa and Objective-C so my architecture might be bogus.

Thanks again for sharing…


Posted by
bdunagan
21 November 2008 @ 7pm

Actually, your approach is more elegant and generic than mine, as you can set the cell’s action based on the controller context. Thanks for pointing that out. :)


Posted by
Rowan Beentje
18 July 2009 @ 10am

Thanks a lot for the code!

I used this as a base to write an implementation for Sequel Pro ( http://code.google.com/p/sequel-pro/source/browse/trunk/Source/SPTextAndLinkCell.m ) which largely achieves the same functionality, but may be helpful to others as an example of how to set different images for different states (highlighted, background etc) and how to retrieve the clicked cell row and column indexes.

However, the reason I’m commenting here is that because I note you left out the dealloc method. This is possibly because as soon as you added it, you started getting crashes? Table cells are cloned all over the place to preserve memory, but by default the copyWithZone will create a reference to internal objects; so if a copied cell is then deallocated, any retained objects (linkArrow in the code above) will be released and no longer usable by the original cell, causing crashes.

For anyone else running into this problem: override copyWithZone: to copy retained objects rather than simply referencing them, after which deallocation will work again. :)

(Apologies if this is all obvious – it really wasn’t to me!)


Posted by
Rowan Beentje
21 July 2009 @ 3pm

One more update: using only hitTestForEvent, you may see your actions fired multiple times for multiple mouse events during one click. For buttons which behave like OS X buttons – highlight states, and the ability to drag the mouse off the button while clicking to cancel the click – hitTestForEvent should return NSCellHitTrackableArea, and then trackMouse:inRect:ofView:untilMouseUp: can be used to handle the actual click action.

http://code.google.com/p/sequel-pro/source/browse/trunk/Source/SPTextAndLinkCell.m may prove useful to anyone else, as a corollary to your very useful examples above!


Posted by
bdunagan
31 July 2009 @ 12pm

Wow, very helpful comments, Rowan! Thanks for posting these. When I finally have a chance to add this feature to Retrospect, I’ll go with your way.

And fantastic job on Sequel Pro! It looks really professional.


Leave a Comment