KalyanChakravarthy.net

home photos apps about

Detect touches on words in UILabels

Thu 23 April 2015

Detecting touches on UIViews is quite trivial. But trying to recognize touches on individual words or attributed strings can be not so much, which was exactly what I wanted to do.

There are 2 approaches one can take

  • CoreText and compute the frames of all glyphs
  • TextKit - which is not exactly kit per se, but are bunch of classes part of UIKit

TextKit

TextKit is designed based on MVC, with:

  • M being NSTextStorage
  • V being NSTextContainer
  • C being NSLayoutManager

NSTextStorage acts as the data provider and can own multiple NSLayoutManagers. For the layout manager to display text, it needs an instance of NSTextContainer, which essentially defines the area of visible text.

NSTextContainer has 2 main properties defining the layout - the bounds and the exclusion paths. Bounds is the outline rect, this is required. Optionally one can pass a bezier path to define which areas are not to be layouted - allowing one flow text around images.

Here is some code that detects touch on "Read More" string of a UILabel*.

/*
    Some initialization
*/
- (void)viewDidLoad {
    // The full string
    NSMutableDictionary *attributesForString = [[NSMutableDictionary alloc] init];
    attributesForString[ NSFontAttributeName ] = [UIFont systemFontOfSize:13];
    self.attrString = [[NSMutableAttributedString alloc] initWithString:@"Lorem ipsum dolor set amit"];

    // The "Read More" string that should be touchable
    attributesForString[ NSFontAttributeName ] = [UIFont boldSystemFontOfSize:13];
    self.moreString = [[NSAttributedString alloc] initWithString:@"READ MORE" attributes:attributesForString];
    [self.attrString appendAttributedString:self.moreString];

    // Store range of chars we want to detect touches for
    self.moreStringRange = [self.attrString.string rangeOfString:self.moreString.string];

    self.textLabel.attributedString = self.attrString;

    UITapGestureRecognizer *tapRecognizer = [[UITapGestureRecognizer alloc] initWithTarget:self selector:@selector(didTap:)];
    [self.textLabel addGestureRecgonizer:tapRecognizer];
}

/*
    Simple touch recognition.
    Could be setup during initialisation.
*/
- (void)didTap:(UITapGestureRecognizer *)gesture {
    // Storage class stores the string, obviously
    NSTextStorage *textStorage = [[NSTextStorage alloc] initWithAttributedString:self.attrString];

    // The storage class owns a layout manager
    NSLayoutManager *layoutManager = [[NSLayoutManager alloc] init];
    [textStorage addLayoutManager:layoutManager];

    // Layout manager owns a container which basically
    // defines the bounds the text should be contained in
    NSTextContainer *textContainer = [[NSTextContainer alloc] initWithSize:self.textLabel.frame.size];

    // For labels the fragment padding should be 0
    textContainer.lineFragmentPadding = 0;

    // Begin computation of actual frame
    // Glyph is the final display representation
    // Eg: Ligatures have 2 characters but only 1 glyph.
    NSRange glyphRange;

    // Extract the glyph range
    [layoutManager characterRangeForGlyphRange:self.moreStringRange actualGlyphRange:&glyphRange];

    // Compute the rect of glyph in the text container
    CGRect glyphRect = [layoutManager boundingRectForGlyphRange:glyphRange inTextContainer:textContainer];

    // Final rect relative to the textLabel.
    NSLog( @"%@", glyphRect );

    // Now figure out if the touch point is inside our rect
    CGPoint touchPoint = [gesture locationOfTouch:0 inView:self.textLabel];

    if( CGRectContainsPoint(glyphRect, touchPoint) ) {
        NSLog( @"User tapped on Read More. So show something more");
    }
}