The Shape of Everything
A website mostly about Mac stuff, written by Gus Mueller
» Acorn
» Twitter
» Maybe Pizza?
» Code
» Archive
December 3, 2010

Mike Rundle has a nice post on how to customize links in an NSTextView.

I thought I'd share a couple of other ways you can customize links in an NSTextView, and also how you can avoid subclassing NSTextView to grab clicked links (ie- let AppKit do some work for you).

In Mike's example, each text range that is clickable gets a custom cursor, text style, and a custom marker for the type of link (standard URLs, hashtags, and @username links). AppKit provides a standard link attribute (NSLinkAttributeName) but isn't used in the example because by default NSTextView will make your links blue and underlined - which isn't what Mike was going for. So instead of using NSLink, the example subclasses NSTextView and overrides mouseDown: to figure out where you clicked and figures out what kind of action to take. In general I've always found it is best to let AppKit do this type of work for you since messing around with text can be perilous- and if we can get rid of the subclass, well- less code usually means less bugs.

So, it turns out you can change the default attributes for links:

[statusView setLinkTextAttributes:[NSDictionary dictionaryWithObject:[NSCursor pointingHandCursor] forKey:NSCursorAttributeName]];

What we've done here is told our text view that the only attribute it should use on a link is a custom cursor. If we wanted all links to be black, we could add a NSForegroundColorAttributeName in the mix.

So in Mike's example, we could strip out our usage of NSCursorAttributeName in the various for loops, and then add NSLinkAttributeName with custom urls in the areas where we set attributes for the links (this would replace the usage of the custom @"HashtagMatch" attribute and friends).

We'd then assign the text view's delegate to our instance of TweetViewAppDelegate, and implement this method:

- (BOOL)textView:(NSTextView *)aTextView clickedOnLink:(id)aLink
    atIndex:(NSUInteger)charIndex {

    // we really need to put in code to make sure that our links are NSStrings.
    // but I'm lazy, and this is a simple example.
    if ([aLink hasPrefix:@"hash://"]) {
        [self openHash:[aLink substringFromIndex:[@"hash://" length]]];
        return YES;
    }
    else if ([aLink hasPrefix:@"username://"]) {
        [self openUsername:[aLink substringFromIndex:[@"username://" length]]];
        return YES;
    }

    return NO; // let AppKit take care of the link
}

Then we can safely get rid of our subclass. Hurray!

Next up- using NSLayoutManager's addTemporaryAttribute:value:forCharacterRange: to change the color and adding an underline for the link ranges! This works just like adding an attribute to an attributed string, only it isn't part of the string- so when you archive your attributed string the attributes aren't saved. Conceptually, think of it as sitting above the attributed string. It's handy, it's great, and we don't have to dirty up our string with custom attributes everywhere (and if you've got a desktop wiki and temporary links aren't something you want to save with the string, hey- this is awesome).

Unfortunately, addTemporaryAttribute: can't be used for Mike's example because the weight of the font is changed for the links. The layout manager's temp attributes can only be used for attributes which don't change the layout of the text, which is exactly what happens when you make text bold. Bummer. But it's there in case you ever need it for your project.

A couple of other things to explore:

Did you know NSTextView has automatic link detection? Check out setAutomaticLinkDetectionEnabled:, and while you're at it look at setAutomaticDataDetectionEnabled: in 10.6 and later.

If you've got an editable text view and you want to update your links as the user is typing, assign a delegate of the text view's textStorage and implement textStorageDidProcessEditing:. You can also do something similar with NSLayoutManager and layoutManagerDidInvalidateLayout:. Make sure to limit yourself to only the edited area using the text storage's editedRange.

I've changed up Mike's sample code, which you can download and check out. One other thing I've done to it is added [[statusView textStorage] beginEditing] and [[statusView textStorage] endEditing], which I'll let the docs describe:

Overridden by subclasses to buffer or optimize a series of changes to the receiver’s characters or attributes, until it receives a matching endEditing message, upon which it can consolidate changes and notify any observers that it has changed.

NSTextView is one of my favorite classes in AppKit. It's crazy powerful and wonderful and enables so much for developers. It's a shame we don't have something like this on iOS.

Do me a favor- if you find any of the above useful, make sure to file a bug with Apple saying we need something equivalent to NSTextView on iOS.