I want to check if my UILabel
was touched. But i need even more than that. Was the text touched? Right now I only get true/false if the UILabel
frame was touched using this:
- (void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event
{
UITouch *touch = [[event allTouches] anyObject];
if (CGRectContainsPoint([self.currentLetter frame], [touch locationInView:self.view]))
{
NSLog(@"HIT!");
}
}
Is there any way to check this? As soon as I touch somewhere outside the letter in the UILabel
I want false to get returned.
I want to know when the actual black rendered "text pixles" has been touched.
Thanks!
tl;dr: You can hit test the path of the text. Gist is available here.
The approach I would go with is to check if the tap point is inside the path of the text or not. Let me give you a overview of the steps before going into detail.
pointInside:withEvent:
to be able to determine if a point should be considered inside or not.The big advantage of this approach is that it follows the font precisely and that you can modify the path to grow the "hittable" area like seen below. Both the black and the orange parts are tappable but only the black parts will be drawn in the label.
I created a subclass of UILabel
called TextHitTestingLabel
and added a private property for the text path.
@interface TextHitTestingLabel (/*Private stuff*/)
@property (assign) CGPathRef textPath;
@end
Since iOS labels can have either a text
or an attributedText
so I subclassed both these methods and made them call a method to update the text path.
- (void)setText:(NSString *)text {
[super setText:text];
[self textChanged];
}
- (void)setAttributedText:(NSAttributedString *)attributedText {
[super setAttributedText:attributedText];
[self textChanged];
}
Also, a label can be created from a NIB/Storyboard in which case the text will be set right away. In that case I check for the initial text in awake from nib.
- (void)awakeFromNib {
[self textChanged];
}
Core Text is a low level framework that gives you full control over the text rendering. You have to add CoreText.framework
to your project and import it to your file
#import <CoreText/CoreText.h>
The first thing I do inside textChanged
is to get the text. Depending on if it's iOS 6 or earlier I also have to check the attributed text. A label will only have one of these.
// Get the text
NSAttributedString *attributedString = nil;
if ([self respondsToSelector:@selector(attributedText)]) { // Available in iOS 6
attributedString = self.attributedText;
}
if (!attributedString) { // Either earlier than iOS6 or the `text` property was set instead of `attributedText`
attributedString = [[NSAttributedString alloc] initWithString:self.text
attributes:@{NSFontAttributeName: self.font}];
}
Next I create a new mutable path for all the letter glyphs.
// Create a mutable path for the paths of all the letters.
CGMutablePathRef letters = CGPathCreateMutable();
Core Text works with lines of text and glyphs and glyph runs. For example, if I have the text: "Hello" with attributes like this " Hel lo " (spaces added for clarity). Then that is going to be one line of text with two glyph runs: one bold and one regular. The first glyph run contains 3 glyphs and the second run contains 2 glyphs.
I enumerate all the glyph runs and their glyphs and get the path with CTFontCreatePathForGlyph()
. Each individual glyph path is then added to the mutable path.
// Create a line from the attributed string and get glyph runs from that line
CTLineRef line = CTLineCreateWithAttributedString((CFAttributedStringRef)attributedString);
CFArrayRef runArray = CTLineGetGlyphRuns(line);
// A line with more then one font, style, size etc will have multiple fonts.
// "Hello" formatted as " *Hel* lo " (spaces added for clarity) is two glyph
// runs: one italics and one regular. The first run contains 3 glyphs and the
// second run contains 2 glyphs.
// Note that " He *ll* o " is 3 runs even though "He" and "o" have the same font.
for (CFIndex runIndex = 0; runIndex < CFArrayGetCount(runArray); runIndex++)
{
// Get the font for this glyph run.
CTRunRef run = (CTRunRef)CFArrayGetValueAtIndex(runArray, runIndex);
CTFontRef runFont = CFDictionaryGetValue(CTRunGetAttributes(run), kCTFontAttributeName);
// This glyph run contains one or more glyphs (letters etc.)
for (CFIndex runGlyphIndex = 0; runGlyphIndex < CTRunGetGlyphCount(run); runGlyphIndex++)
{
// Read the glyph itself and it position from the glyph run.
CFRange glyphRange = CFRangeMake(runGlyphIndex, 1);
CGGlyph glyph;
CGPoint position;
CTRunGetGlyphs(run, glyphRange, &glyph);
CTRunGetPositions(run, glyphRange, &position);
// Create a CGPath for the outline of the glyph
CGPathRef letter = CTFontCreatePathForGlyph(runFont, glyph, NULL);
// Translate it to its position.
CGAffineTransform t = CGAffineTransformMakeTranslation(position.x, position.y);
// Add the glyph to the
CGPathAddPath(letters, &t, letter);
CGPathRelease(letter);
}
}
CFRelease(line);
The core text coordinate system is upside down compared to the regular UIView coordinate system so I then flip the path to match what we see on screen.
// Transform the path to not be upside down
CGAffineTransform t = CGAffineTransformMakeScale(1, -1); // flip 1
CGSize pathSize = CGPathGetBoundingBox(letters).size;
t = CGAffineTransformTranslate(t, 0, -pathSize.height); // move down
// Create the final path by applying the transform
CGPathRef finalPath = CGPathCreateMutableCopyByTransformingPath(letters, &t);
// Clean up all the unused path
CGPathRelease(letters);
self.textPath = finalPath;
And now I have a complete CGPath for the text of the label.
pointInside:withEvent:
To customize what points the label consider as inside itself I override point inside and have it check if the point is inside the text path. Other parts of UIKit is going to call this method for hit testing.
// Override -pointInside:withEvent to determine that ourselves.
- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event {
// Check if the points is inside the text path.
return CGPathContainsPoint(self.textPath, NULL, point, NO);
}
Now everything is setup to work with normal touch handling. I added a tap recognizer to my label in a NIB and connected it to a method in my view controller.
- (IBAction)labelWasTouched:(UITapGestureRecognizer *)sender {
NSLog(@"LABEL!");
}
That is all it takes. If you scrolled all the way down here and don't want to take the different pieces of code and paste them together I have the entire .m file in a Gist that you can download and use.
A note, most fonts are very, very thin compared to the precision of a touch (44px) and your users will most likely be very frustrated when the touches are considered "misses". That being said: happy coding!
To be slightly nicer to the user you can stroke the text path that you use for hit testing. This gives a larger area that hit tappable but still gives the feeling that you are tapping the text.
CGPathRef endPath = CGPathCreateMutableCopyByTransformingPath(letters, &t);
CGMutablePathRef finalPath = CGPathCreateMutableCopy(endPath);
CGPathRef strokedPath = CGPathCreateCopyByStrokingPath(endPath, NULL, 7, kCGLineCapRound, kCGLineJoinRound, 0);
CGPathAddPath(finalPath, NULL, strokedPath);
// Clean up all the unused paths
CGPathRelease(strokedPath);
CGPathRelease(letters);
CGPathRelease(endPath);
self.textPath = finalPath;
Now the orange area in the image below is going to be tappable as well. This still feels like you are touching the text but is less annoying to the users of your app.
If you want you can take this even further to make it even easier to hit the text but at some point it is going to feel like the entire label is tappable.
The problem, as I understand it, is to detect when a tap (touch) happens on one of the glyphs that comprise the text in a UILabel. If a touch lands outside the path of any of the glyphs then it isn't counted.
Here's my solution. It assumes a UILabel*
ivar named _label, and a UITapGestureRecognizer
associated with the view containing the label.
- (IBAction) onTouch: (UITapGestureRecognizer*) tgr
{
CGPoint p = [tgr locationInView: _label];
// in case the background of the label isn't transparent...
UIColor* labelBackgroundColor = _label.backgroundColor;
_label.backgroundColor = [UIColor clearColor];
// get a UIImage of the label
UIGraphicsBeginImageContext( _label.bounds.size );
CGContextRef c = UIGraphicsGetCurrentContext();
[_label.layer renderInContext: c];
UIImage* i = UIGraphicsGetImageFromCurrentImageContext();
UIGraphicsEndImageContext();
// restore the label's background...
_label.backgroundColor = labelBackgroundColor;
// draw the pixel we're interested in into a 1x1 bitmap
unsigned char pixel = 0x00;
c = CGBitmapContextCreate(&pixel,
1, 1, 8, 1, NULL,
kCGImageAlphaOnly);
UIGraphicsPushContext(c);
[i drawAtPoint: CGPointMake(-p.x, -p.y)];
UIGraphicsPopContext();
CGContextRelease(c);
if ( pixel != 0 )
{
NSLog( @"touched text" );
}
}
You can use a UIGestureRecognizer
:
http://developer.apple.com/library/ios/#documentation/EventHandling/Conceptual/EventHandlingiPhoneOS/GestureRecognizer_basics/GestureRecognizer_basics.html
Specifically, I guess you'd like to use the UITapGestureRecognizer
. If you want to recognize when the text frame is touched, then the easiest would be to make the size of your frame to fit the text with [yourLabel sizeToFit]
.
Anyway, to do so I will go to use a UIButton
, it's the easiest option.
In case you need to detect only when the actual text and not the entire UITextField
frame is tapped then it becomes much more difficult. One approach is detecting the darkness of the pixel the user tapped, but this involves some ugly code. Anyway, depending on the expected interaction within your application in can work out. Check this SO question:
iOS -- detect the color of a pixel?
I would take in consideration that not all the rendered pixel will be 100% black, so I would play with a threshold to achieve better results.
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With