In the previous blog post of this series, I have demonstrated how to modify the way UILabel draws hyperlinks. In this article I am showing how to implement function to make the hyperlinks tappable.
We previously implemented a custom stack of NSLayoutManager, NSTextContainer and NSTextStorage. This was necessary because – unfortunately – UILabel does not give us access to its internal instances of these. Having our own layout manager in place is the key ingredient to making the hyperlinks tappable.
When the user brings his finger down on our customised UILabel we need to first determine which character index of the attributed string he landed on. Then we can look up the NSLinkAttributeName for this index. Finally we can change the appearance of this link based on the touch.
As in the previous article, I thank Matthew Styles for working this out for this KILabel implementation. I re-implented my solution from scratch, making many different – I like think more elegant – design choices, so please bear with me and don’t rush off cloning the KILabel project.
Determining String Index of Touch
The layout manager instance we created provides all the necessary methods to determine the character index into the attributed string.
- (NSUInteger)_stringIndexAtLocation:(CGPoint)location { // Do nothing if we have no text if (self.textStorage.string.length == 0) { return NSNotFound; } // Work out the offset of the text in the view CGPoint textOffset; NSRange glyphRange = [self.layoutManager glyphRangeForTextContainer:self.textContainer]; textOffset = [self _textOffsetForGlyphRange:glyphRange]; // Get the touch location and use text offset to convert to text cotainer coords location.x -= textOffset.x; location.y -= textOffset.y; NSUInteger glyphIndex = [self.layoutManager glyphIndexForPoint:location inTextContainer:self.textContainer]; // If the touch is in white space after the last glyph on the line we don't // count it as a hit on the text NSRange lineRange; CGRect lineRect = [self.layoutManager lineFragmentUsedRectForGlyphAtIndex:glyphIndex effectiveRange:&lineRange]; if (!CGRectContainsPoint(lineRect, location)) { return NSNotFound; } return [self.layoutManager characterIndexForGlyphAtIndex:glyphIndex]; } |
In order to retrieve the NSURL for a given character index, I implemented a category method for NSAttributedString. Ok, it’s a one-liner, but a method of the same signature exists for OS X:
- (NSURL *)URLAtIndex:(NSUInteger)location effectiveRange:(NSRangePointer)effectiveRange { return [self attribute:NSLinkAttributeName atIndex:location effectiveRange:effectiveRange]; } |
To show a gray background as long as the link is touched we simply set a background color for the effective range. When the finger is lifted we reset it. The following helper method – the setter for the selectedRange property – takes care of setting and removing said background color attribute.
- (void)setSelectedRange:(NSRange)range { // Remove the current selection if the selection is changing if (self.selectedRange.length && !NSEqualRanges(self.selectedRange, range)) { [self.textStorage removeAttribute:NSBackgroundColorAttributeName range:self.selectedRange]; } // Apply the new selection to the text if (range.length) { [self.textStorage addAttribute:NSBackgroundColorAttributeName value:self.selectedLinkBackgroundColor range:range]; } // Save the new range _selectedRange = range; [self setNeedsDisplay]; } |
This is all it takes in terms of display because the drawing already takes care of displaying the boxes. Note the additional property selectedLinkBackgroundColor which allows customization.
Handling Gestures
The action to be executed on a user’s tapping on a hyperlink goes into a linkTapHandler property. This block property has one parameter for passing the tapped URL. The _commonSetup method gets called from both initWithFrame: and initWithCoder: to set up the gesture recogniser and default tap handler.
- (void)_commonSetup { // Make sure user interaction is enabled so we can accept touches self.userInteractionEnabled = YES; // Default background colour looks good on a white background self.selectedLinkBackgroundColor = [UIColor colorWithWhite:0.95 alpha:1.0]; // Attach a default detection handler to help with debugging self.linkTapHandler = ^(NSURL *URL) { NSLog(@"Default handler for %@", URL); }; TouchGestureRecognizer *touch = [[TouchGestureRecognizer alloc] initWithTarget:self action:@selector(_handleTouch:)]; touch.delegate = self; [self addGestureRecognizer:touch]; } |
The TouchGestureRecognizer is a very simple recogniser which simply wraps the touchesBegan, -Moved, -Ended and -Cancelled.
@implementation TouchGestureRecognizer - (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event { self.state = UIGestureRecognizerStateBegan; } - (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event { self.state = UIGestureRecognizerStateFailed; } - (void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event { self.state = UIGestureRecognizerStateEnded; } - (void)touchesCancelled:(NSSet *)touches withEvent:(UIEvent *)event { self.state = UIGestureRecognizerStateCancelled; } @end |
The gesture recognizer handling method ties the methods together which we have previously implemented.
- (void)_handleTouch:(TouchGestureRecognizer *)gesture { // Get the info for the touched link if there is one CGPoint touchLocation = [gesture locationInView:self]; NSInteger index = [self _stringIndexAtLocation:touchLocation]; NSRange effectiveRange; NSURL *touchedURL = nil; if (index != NSNotFound) { touchedURL = [self.attributedText URLAtIndex:index effectiveRange:&effectiveRange]; } switch (gesture.state) { case UIGestureRecognizerStateBegan: { if (touchedURL) { self.selectedRange = effectiveRange; } else { // no URL, cancel gesture gesture.enabled = NO; gesture.enabled = YES; } break; } case UIGestureRecognizerStateEnded: { if (touchedURL && _linkTapHandler) { _linkTapHandler(touchedURL); } } default: { self.selectedRange = NSMakeRange(0, 0); break; } } } |
We want to consider the gesture as failed if the touch does not start on a hyperlink. This is important when using our UILabel implementation in a table view. We event want to go one step further. Our touch gesture recognizer should always recognize at the same time as other gesture recognizers.
To prevent any adverse blocking effects on scroll gesture recognizers, we want only touches be delivered to the touch gesture recognizer if they are on top of hyperlinks. The code for determining this is quite similar to the gesture handling method.
- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldRecognizeSimultaneouslyWithGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer { return YES; } - (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldReceiveTouch:(UITouch *)touch { CGPoint touchLocation = [touch locationInView:self]; NSInteger index = [self _stringIndexAtLocation:touchLocation]; NSRange effectiveRange; NSURL *touchedURL = nil; if (index != NSNotFound) { touchedURL = [self.attributedText URLAtIndex:index effectiveRange:&effectiveRange]; } if (touchedURL) { return YES; } return NO; } |
With these additional modifications in place our label can also be used in table views without interfering with vertical scrolling. Also horizontal swipes (for editing) are unimpeded as well.
Conclusion
This is all the code that is necessary to get tap handling working for hyperlinks. It’s quite a few lines of code, but they should not be too hard to wrap your head around.
It would all be much easier if Apple allowed us access to the TextKit stack of UILabel, specifically letting us replace the internal layout manager so that we can customize text drawing on glyph granularity. However, the presented technique has the advantage of being backwards compatible. So we’ll stop whining about Apple’s omissions right now.
At the moment there is no public sample project containing the code mentioned, because I feel it is somewhat specific to my needs for the prod.ly app. Let’s make a deal: I’ll trade you the working source code I have for purchasing a copy of my new book. I am also looking for my next iOS and/or Mac project to participate in. Either way, you can contact me at oliver@cocoanetics.com.
Categories: Recipes
Safe to assume that _textOffsetForGlyphRange: is very similar to KILabel’s calcGlyphsPositionInView?
Thanks
Jim
Ah.. never mind, it’s in the previous post.
Jim