Tappable URLs in Core Text

As I've been struggling to get URLs underlined and tappable in a Core Text UIView, I'd like to share my findings here.

Intro to Core Text

Drawing text using Core Text is done by following these steps:

  1. Setup a CTFramesetter for the attributed string
  2. Create CTFrame's (based on paths) which holds portions of the text
  3. Draw these CTFrame's within drawRect

Implementing these steps results in this code:

-(void)setupFrame
{
  /** 1. Setup CTFramesetter **/
  CTFramesetterRef framesetter = CTFramesetterCreateWithAttributedString((CFAttributedStringRef)myAttributedString);
  /** 2. Create CTFrame **/
  CGMutablePathRef path = CGPathCreateMutable();
  CGPathAddRect(path, NULL, CGRectMake(0, 0, 1024, 10000));            
  myFrame = CTFramesetterCreateFrame(framesetter, CFRangeMake(0, 0), path, NULL);
  CFRelease (path);
  CFRelease (framesetter);
}

/** 3. Draw CTFrame's **/
-(void)drawFrame:(CTFrameRef)frame inContext:(CGContextRef)context forString:(NSAttributedString*)as
{
  CGContextRef context = UIGraphicsGetCurrentContext();
  // This is required, otherwise the text is drawn upside down in iPhone OS (!?)
  CGContextSaveGState(context);
  CGAffineTransform flip = CGAffineTransformMake(1.0, 0.0, 0.0, -1.0, 0.0, self.frame.size.height);
  CGContextConcatCTM(context, flip);
  CTFrameDraw(myFrame, context);
}

Tappable URLs

Now, if you want to support tappable URLs, you need to do some more advanced stuff:

  1. Detect URLs within the attributed string and insert a custom attribute around each URL (whereby the custom attribute's value is the real URL)
  2. Setup a CTFramesetter for the attributed string
  3. Create CTFrame's (based on paths) which holds portions of the text
  4. Draw these CTFrame's within drawRect, line by line.  For each line, store the bounding box as well as the Range of the attributed string which is shown

The implementation might look like this:

-(void)setupFrame
{
  /** 1. Detect URLs **/
NSDataDetector* detector = [NSDataDetector dataDetectorWithTypes:NSTextCheckingTypeLink error:&error];
NSString* string = [myAttributedString string];
[detector enumerateMatchesInString:string options:0 range:range usingBlock:^(NSTextCheckingResult* match, NSMatchingFlags flags, BOOL* stop){
  NSRange matchRange = [match range];
  [str addAttribute:(NSString*)kCTForegroundColorAttributeName value:(id)[UIColor blueColor].CGColor range:matchRange];
  [str addAttribute:(NSString*)kCTUnderlineStyleAttributeName value:[NSNumber numberWithInt:kCTUnderlineStyleSingle] range:matchRange];
  switch([match resultType])
  {
    case NSTextCheckingTypeLink:
    {
      NSURL* url = [match URL];
      [str addAttribute:@"MyURLAttribute" value:url range:matchRange];
    }
  }

  /** 2. Setup CTFramesetter **/
  CTFramesetterRef framesetter = CTFramesetterCreateWithAttributedString((CFAttributedStringRef)myAttributedString);
  /** 3. Create CTFrame **/
  CGMutablePathRef path = CGPathCreateMutable();
  CGPathAddRect(path, NULL, CGRectMake(0, 0, 1024, 10000));            
  myFrame = CTFramesetterCreateFrame(framesetter, CFRangeMake(0, 0), path, NULL);
  CFRelease (path);
  CFRelease (framesetter);
}

/** 4. Draw CTFrame's, line by line and store bounding box in variable**/
-(void)drawFrame:(CTFrameRef)frame inContext:(CGContextRef)context forString:(NSAttributedString*)as
{
  CGContextRef context = UIGraphicsGetCurrentContext();
  // This is required, otherwise the text is drawn upside down in iPhone OS (!?)
  CGContextSaveGState(context);
  CGAffineTransform flip = CGAffineTransformMake(1.0, 0.0, 0.0, -1.0, 0.0, self.frame.size.height);
  CGContextConcatCTM(context, flip);
  CGPathRef path = CTFrameGetPath(frame);
  CGRect frameBoundingBox = CGPathGetBoundingBox(path);
  CFArrayRef lines = CTFrameGetLines(frame);
  CGPoint origins[CFArrayGetCount(lines)];                              // the origins of each line at the baseline
  CTFrameGetLineOrigins(frame, CFRangeMake(0, 0), origins);
  CFIndex linesCount = CFArrayGetCount(lines);
  for (int lineIdx = 0; lineIdx < linesCount; lineIdx++)
  {
    CGContextSetTextPosition(context, origins[lineIdx].x + frameBoundingBox.origin.x, frameBoundingBox.origin.y + origins[lineIdx].y);
    CTLineRef line = (CTLineRef)CFArrayGetValueAtIndex(lines, lineIdx);
    CGRect lineBounds = CTLineGetImageBounds(line, context);
    lineBounds.origin.y = self.frame.size.height - origins[lineIdx].y - lineBounds.size.height;
    CFRange lineRange = CTLineGetStringRange(line);
                       
    [lineInfoForTap addObject:[NSDictionary dictionaryWithObjectsAndKeys:NSStringFromRange(NSMakeRange(lineRange.location, lineRange.length)), @"Range", NSStringFromCGRect(lineBounds), @"Bounds", nil]];
  }
}

As can be seen, this is a bit more complex.  Using the information we stored in [4], we can implement touch handling in our UIView.  Each time a touch is detected, we should check it's location, map it to a bounding box of a single line and get the range within the attributed string.  Given that range, we should loop over each character in the attributed string to check whether any character contains the custom attribute we set during [1].  If so, we fetch the value for the custom attribute (which holds the URL, see [1]) and launch the URL.

- (void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event
{
  [super touchesEnded:touches withEvent:event];
  CGPoint tapLocation = [[touches anyObject] locationInView:self];
  for (NSDictionary* lineInfo in lineInfoForTap)
  {
    CGRect lineBounds = CGRectFromString([lineInfo valueForKey:@"Bounds"]);
    NSRange lineRange = NSRangeFromString([lineInfo valueForKey:@"Range"]);
    if(CGRectContainsPoint(lineBounds, tapLocation))
    {
      NSRange longestRange;
      for (int i = lineRange.location; i < lineRange.location + lineRange.length; i++)
      {
        NSDictionary* attributes = [self.attributedString attributesAtIndex:i longestEffectiveRange:&longestRange inRange:NSMakeRange(i, 1)];
        NSURL* url = [attributes objectForKey:@"MyURLAttribute"];
        if (url)
        {
          [[UIApplication sharedApplication] openURL:url];
          break;
        }
      }
      break;
    }
  }
}

Hyphenation

To make things even more complex, you could add hyphenation support to your attributed string.  Core Text luckily honors UTF-8 soft-hyphen characters (0x00AD) in order to split strings but doesn't show a hard hyphenation character (-).  In order to get this working properly, you can check out Tupil's Blog. Unfortunately the hyphenation library suggested by Tupil also inserts soft hyphens for URLs which then breaks Apple's NSDataDetector class we use.  So I've made small change to NSString+Hyphenate.m, in order to not hyphenate URLs (note this change is not fool-proof but does the trick for my purposes).  Within stringByHyphenatingWithLocale in the loop which iterates over all tokens, I added this additional check:

if (tokenType & kCFStringTokenizerTokenHasNonLettersMask) {
  [result appendString:token];
}
/** begin new code **/
else if ([token isEqualToString:@"http"]) {
  while (![token isEqualToString:@" "]) {
    [result appendString:token];
    tokenType = CFStringTokenizerAdvanceToNextToken(tokenizer);
    if (tokenType == kCFStringTokenizerTokenNone)
      break;
    tokenRange = CFStringTokenizerGetCurrentTokenRange(tokenizer);
    token = [self substringWithRange:
    NSMakeRange(tokenRange.location, tokenRange.length)];
  }
}
/** end new code **/
else {

The resulting code is now exactly what I wanted.  Core Text in combination with hyphenation support and tappable URLs.