blob: bc80d390a47071b63e0314030b793044799a1e4d [file] [log] [blame]
// TTTAttributedLabel.m
//
// Copyright (c) 2011 Mattt Thompson (http://mattt.me)
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.
#import "TTTAttributedLabel.h"
#define kTTTLineBreakWordWrapTextWidthScalingFactor (M_PI / M_E)
static inline CTTextAlignment CTTextAlignmentFromUITextAlignment(UITextAlignment alignment) {
switch (alignment) {
case UITextAlignmentLeft: return kCTLeftTextAlignment;
case UITextAlignmentCenter: return kCTCenterTextAlignment;
case UITextAlignmentRight: return kCTRightTextAlignment;
default: return kCTNaturalTextAlignment;
}
}
static inline CTLineBreakMode CTLineBreakModeFromUILineBreakMode(UILineBreakMode lineBreakMode) {
switch (lineBreakMode) {
case UILineBreakModeWordWrap: return kCTLineBreakByWordWrapping;
case UILineBreakModeCharacterWrap: return kCTLineBreakByCharWrapping;
case UILineBreakModeClip: return kCTLineBreakByClipping;
case UILineBreakModeHeadTruncation: return kCTLineBreakByTruncatingHead;
case UILineBreakModeTailTruncation: return kCTLineBreakByTruncatingTail;
case UILineBreakModeMiddleTruncation: return kCTLineBreakByTruncatingMiddle;
default: return 0;
}
}
static inline NSTextCheckingType NSTextCheckingTypeFromUIDataDetectorType(UIDataDetectorTypes dataDetectorType) {
NSTextCheckingType textCheckingType = 0;
if (dataDetectorType & UIDataDetectorTypeAddress) {
textCheckingType |= NSTextCheckingTypeAddress;
}
if (dataDetectorType & UIDataDetectorTypeCalendarEvent) {
textCheckingType |= NSTextCheckingTypeDate;
}
if (dataDetectorType & UIDataDetectorTypeLink) {
textCheckingType |= NSTextCheckingTypeLink;
}
if (dataDetectorType & UIDataDetectorTypePhoneNumber) {
textCheckingType |= NSTextCheckingTypePhoneNumber;
}
return textCheckingType;
}
static inline NSDictionary * NSAttributedStringAttributesFromLabel(TTTAttributedLabel *label) {
NSMutableDictionary *mutableAttributes = [NSMutableDictionary dictionary];
CTFontRef font = CTFontCreateWithName((CFStringRef)label.font.fontName, label.font.pointSize, NULL);
[mutableAttributes setObject:(id)font forKey:(NSString *)kCTFontAttributeName];
CFRelease(font);
[mutableAttributes setObject:(id)[label.textColor CGColor] forKey:(NSString *)kCTForegroundColorAttributeName];
CTTextAlignment alignment = CTTextAlignmentFromUITextAlignment(label.textAlignment);
CTLineBreakMode lineBreakMode = CTLineBreakModeFromUILineBreakMode(label.lineBreakMode);
CGFloat lineSpacing = label.leading;
CGFloat lineHeightMultiple = label.lineHeightMultiple;
CGFloat topMargin = label.textInsets.top;
CGFloat bottomMargin = label.textInsets.bottom;
CGFloat leftMargin = label.textInsets.left;
CGFloat rightMargin = label.textInsets.right;
CGFloat firstLineIndent = label.firstLineIndent + leftMargin;
CTParagraphStyleSetting paragraphStyles[9] = {
{.spec = kCTParagraphStyleSpecifierAlignment, .valueSize = sizeof(CTTextAlignment), .value = (const void *)&alignment},
{.spec = kCTParagraphStyleSpecifierLineBreakMode, .valueSize = sizeof(CTLineBreakMode), .value = (const void *)&lineBreakMode},
{.spec = kCTParagraphStyleSpecifierLineSpacing, .valueSize = sizeof(CGFloat), .value = (const void *)&lineSpacing},
{.spec = kCTParagraphStyleSpecifierLineHeightMultiple, .valueSize = sizeof(CGFloat), .value = (const void *)&lineHeightMultiple},
{.spec = kCTParagraphStyleSpecifierFirstLineHeadIndent, .valueSize = sizeof(CGFloat), .value = (const void *)&firstLineIndent},
{.spec = kCTParagraphStyleSpecifierParagraphSpacingBefore, .valueSize = sizeof(CGFloat), .value = (const void *)&topMargin},
{.spec = kCTParagraphStyleSpecifierParagraphSpacing, .valueSize = sizeof(CGFloat), .value = (const void *)&bottomMargin},
{.spec = kCTParagraphStyleSpecifierHeadIndent, .valueSize = sizeof(CGFloat), .value = (const void *)&leftMargin},
{.spec = kCTParagraphStyleSpecifierTailIndent, .valueSize = sizeof(CGFloat), .value = (const void *)&rightMargin},
};
CTParagraphStyleRef paragraphStyle = CTParagraphStyleCreate(paragraphStyles, 9);
[mutableAttributes setObject:(id)paragraphStyle forKey:(NSString *)kCTParagraphStyleAttributeName];
CFRelease(paragraphStyle);
return [NSDictionary dictionaryWithDictionary:mutableAttributes];
}
static inline NSAttributedString * NSAttributedStringByScalingFontSize(NSAttributedString *attributedString, CGFloat scale, CGFloat minimumFontSize) {
NSMutableAttributedString *mutableAttributedString = [[attributedString mutableCopy] autorelease];
[mutableAttributedString enumerateAttribute:(NSString *)kCTFontAttributeName inRange:NSMakeRange(0, [mutableAttributedString length]) options:0 usingBlock:^(id value, NSRange range, BOOL *stop) {
CTFontRef font = (CTFontRef)value;
if (font) {
CGFloat scaledFontSize = floorf(CTFontGetSize(font) * scale);
CTFontRef scaledFont = CTFontCreateCopyWithAttributes(font, fmaxf(scaledFontSize, minimumFontSize), NULL, NULL);
CFAttributedStringSetAttribute((CFMutableAttributedStringRef)mutableAttributedString, CFRangeMake(range.location, range.length), kCTFontAttributeName, scaledFont);
}
}];
return mutableAttributedString;
}
@interface TTTAttributedLabel ()
@property (readwrite, nonatomic, copy) NSAttributedString *attributedText;
@property (readwrite, nonatomic, assign) CTFramesetterRef framesetter;
@property (readwrite, nonatomic, assign) CTFramesetterRef highlightFramesetter;
@property (readwrite, nonatomic, retain) NSArray *links;
- (id)initCommon;
- (void)setNeedsFramesetter;
- (NSArray *)detectedLinksInString:(NSString *)string range:(NSRange)range error:(NSError **)error;
- (NSTextCheckingResult *)linkAtCharacterIndex:(CFIndex)idx;
- (NSTextCheckingResult *)linkAtPoint:(CGPoint)p;
- (NSUInteger)characterIndexAtPoint:(CGPoint)p;
- (void)drawFramesetter:(CTFramesetterRef)framesetter textRange:(CFRange)textRange inRect:(CGRect)rect context:(CGContextRef)c;
@end
@implementation TTTAttributedLabel
@dynamic text;
@synthesize attributedText = _attributedText;
@synthesize framesetter = _framesetter;
@synthesize highlightFramesetter = _highlightFramesetter;
@synthesize delegate = _delegate;
@synthesize dataDetectorTypes = _dataDetectorTypes;
@synthesize links = _links;
@synthesize linkAttributes = _linkAttributes;
@synthesize shadowRadius = _shadowRadius;
@synthesize leading = _leading;
@synthesize lineHeightMultiple = _lineHeightMultiple;
@synthesize firstLineIndent = _firstLineIndent;
@synthesize textInsets = _textInsets;
@synthesize verticalAlignment = _verticalAlignment;
- (id)initWithFrame:(CGRect)frame {
self = [super initWithFrame:frame];
if (!self) {
return nil;
}
return [self initCommon];
}
- (id)initWithCoder:(NSCoder *)coder {
self = [super initWithCoder:coder];
if (!self) {
return nil;
}
return [self initCommon];
}
- (id)initCommon {
self.dataDetectorTypes = UIDataDetectorTypeNone;
self.links = [NSArray array];
NSMutableDictionary *mutableLinkAttributes = [NSMutableDictionary dictionary];
[mutableLinkAttributes setValue:(id)[[UIColor blueColor] CGColor] forKey:(NSString*)kCTForegroundColorAttributeName];
[mutableLinkAttributes setValue:[NSNumber numberWithBool:YES] forKey:(NSString *)kCTUnderlineStyleAttributeName];
self.linkAttributes = [NSDictionary dictionaryWithDictionary:mutableLinkAttributes];
self.textInsets = UIEdgeInsetsZero;
return self;
}
- (void)dealloc {
if (_framesetter) CFRelease(_framesetter);
if (_highlightFramesetter) CFRelease(_highlightFramesetter);
[_attributedText release];
[_links release];
[_linkAttributes release];
[super dealloc];
}
#pragma mark -
- (void)setAttributedText:(NSAttributedString *)text {
if ([text isEqualToAttributedString:self.attributedText]) {
return;
}
[self willChangeValueForKey:@"attributedText"];
[_attributedText release];
_attributedText = [text copy];
[self didChangeValueForKey:@"attributedText"];
[self setNeedsFramesetter];
}
- (void)setNeedsFramesetter {
_needsFramesetter = YES;
}
- (CTFramesetterRef)framesetter {
if (_needsFramesetter) {
@synchronized(self) {
if (_framesetter) CFRelease(_framesetter);
if (_highlightFramesetter) CFRelease(_highlightFramesetter);
self.framesetter = CTFramesetterCreateWithAttributedString((CFAttributedStringRef)self.attributedText);
self.highlightFramesetter = nil;
_needsFramesetter = NO;
}
}
return _framesetter;
}
- (BOOL)isUserInteractionEnabled {
return !_userInteractionDisabled && [self.links count] > 0;
}
- (void)setUserInteractionEnabled:(BOOL)userInteractionEnabled {
_userInteractionDisabled = !userInteractionEnabled;
}
- (BOOL)isExclusiveTouch {
return [self.links count] > 0;
}
#pragma mark -
- (NSArray *)detectedLinksInString:(NSString *)string range:(NSRange)range error:(NSError **)error {
if (!string) {
return [NSArray array];
}
NSMutableArray *mutableLinks = [NSMutableArray array];
NSDataDetector *dataDetector = [NSDataDetector dataDetectorWithTypes:NSTextCheckingTypeFromUIDataDetectorType(self.dataDetectorTypes) error:error];
[dataDetector enumerateMatchesInString:string options:0 range:range usingBlock:^(NSTextCheckingResult *result, NSMatchingFlags flags, BOOL *stop) {
[mutableLinks addObject:result];
}];
return [NSArray arrayWithArray:mutableLinks];
}
- (void)addLinkWithTextCheckingResult:(NSTextCheckingResult *)result {
self.links = [self.links arrayByAddingObject:result];
if (self.linkAttributes) {
NSMutableAttributedString *mutableAttributedString = [[[NSMutableAttributedString alloc] initWithAttributedString:self.attributedText] autorelease];
[mutableAttributedString addAttributes:self.linkAttributes range:result.range];
self.attributedText = mutableAttributedString;
}
}
- (void)addLinkToURL:(NSURL *)url withRange:(NSRange)range {
[self addLinkWithTextCheckingResult:[NSTextCheckingResult linkCheckingResultWithRange:range URL:url]];
}
- (void)addLinkToAddress:(NSDictionary *)addressComponents withRange:(NSRange)range {
[self addLinkWithTextCheckingResult:[NSTextCheckingResult addressCheckingResultWithRange:range components:addressComponents]];
}
- (void)addLinkToPhoneNumber:(NSString *)phoneNumber withRange:(NSRange)range {
[self addLinkWithTextCheckingResult:[NSTextCheckingResult phoneNumberCheckingResultWithRange:range phoneNumber:phoneNumber]];
}
- (void)addLinkToDate:(NSDate *)date withRange:(NSRange)range {
[self addLinkWithTextCheckingResult:[NSTextCheckingResult dateCheckingResultWithRange:range date:date]];
}
- (void)addLinkToDate:(NSDate *)date timeZone:(NSTimeZone *)timeZone duration:(NSTimeInterval)duration withRange:(NSRange)range {
[self addLinkWithTextCheckingResult:[NSTextCheckingResult dateCheckingResultWithRange:range date:date timeZone:timeZone duration:duration]];
}
#pragma mark -
- (NSTextCheckingResult *)linkAtCharacterIndex:(CFIndex)idx {
for (NSTextCheckingResult *result in self.links) {
NSRange range = result.range;
if (range.location <= idx && idx <= range.location + range.length) {
return result;
}
}
return nil;
}
- (NSTextCheckingResult *)linkAtPoint:(CGPoint)p {
CFIndex idx = [self characterIndexAtPoint:p];
return [self linkAtCharacterIndex:idx];
}
- (NSUInteger)characterIndexAtPoint:(CGPoint)p {
if (!CGRectContainsPoint(self.bounds, p)) {
return NSNotFound;
}
CGRect textRect = [self textRectForBounds:self.bounds limitedToNumberOfLines:self.numberOfLines];
if (!CGRectContainsPoint(textRect, p)) {
return NSNotFound;
}
// Convert tap coordinates (start at top left) to CT coordinates (start at bottom left)
p = CGPointMake(p.x, textRect.size.height - p.y);
CFIndex idx = NSNotFound;
CGMutablePathRef path = CGPathCreateMutable();
CGPathAddRect(path, NULL, textRect);
CTFrameRef frame = CTFramesetterCreateFrame(self.framesetter, CFRangeMake(0, [self.attributedText length]), path, NULL);
CFArrayRef lines = CTFrameGetLines(frame);
NSUInteger numberOfLines = CFArrayGetCount(lines);
CGPoint lineOrigins[numberOfLines];
CTFrameGetLineOrigins(frame, CFRangeMake(0, numberOfLines), lineOrigins);
NSUInteger lineIndex;
for (lineIndex = 0; lineIndex < (numberOfLines - 1); lineIndex++) {
CGPoint lineOrigin = lineOrigins[lineIndex];
if (lineOrigin.y < p.y) {
break;
}
}
CGPoint lineOrigin = lineOrigins[lineIndex];
CTLineRef line = CFArrayGetValueAtIndex(lines, lineIndex);
// Convert CT coordinates to line-relative coordinates
CGPoint relativePoint = CGPointMake(p.x - lineOrigin.x, p.y - lineOrigin.y);
idx = CTLineGetStringIndexForPosition(line, relativePoint);
CFRelease(frame);
CFRelease(path);
return idx;
}
- (void)drawFramesetter:(CTFramesetterRef)framesetter textRange:(CFRange)textRange inRect:(CGRect)rect context:(CGContextRef)c {
CGMutablePathRef path = CGPathCreateMutable();
CGPathAddRect(path, NULL, rect);
CTFrameRef frame = CTFramesetterCreateFrame(framesetter, textRange, path, NULL);
if (self.numberOfLines == 0) {
CTFrameDraw(frame, c);
} else {
CFArrayRef lines = CTFrameGetLines(frame);
NSUInteger numberOfLines = MIN(self.numberOfLines, CFArrayGetCount(lines));
CGPoint lineOrigins[numberOfLines];
CTFrameGetLineOrigins(frame, CFRangeMake(0, numberOfLines), lineOrigins);
for (NSUInteger lineIndex = 0; lineIndex < numberOfLines; lineIndex++) {
CGPoint lineOrigin = lineOrigins[lineIndex];
CGContextSetTextPosition(c, lineOrigin.x, lineOrigin.y);
CTLineRef line = CFArrayGetValueAtIndex(lines, lineIndex);
CTLineDraw(line, c);
}
}
CFRelease(frame);
CFRelease(path);
}
#pragma mark - TTTAttributedLabel
- (void)setText:(id)text {
if ([text isKindOfClass:[NSString class]]) {
[self setText:text afterInheritingLabelAttributesAndConfiguringWithBlock:nil];
} else {
self.attributedText = text;
}
self.links = [NSArray array];
if (self.dataDetectorTypes != UIDataDetectorTypeNone) {
for (NSTextCheckingResult *result in [self detectedLinksInString:[self.attributedText string] range:NSMakeRange(0, [text length]) error:nil]) {
[self addLinkWithTextCheckingResult:result];
}
}
[super setText:[self.attributedText string]];
}
- (void)setText:(id)text afterInheritingLabelAttributesAndConfiguringWithBlock:(NSMutableAttributedString *(^)(NSMutableAttributedString *mutableAttributedString))block {
if ([text isKindOfClass:[NSString class]]) {
self.attributedText = [[[NSAttributedString alloc] initWithString:text] autorelease];
}
NSMutableAttributedString *mutableAttributedString = [[[NSMutableAttributedString alloc] initWithAttributedString:self.attributedText] autorelease];
[mutableAttributedString addAttributes:NSAttributedStringAttributesFromLabel(self) range:NSMakeRange(0, [mutableAttributedString length])];
if (block) {
[self setText:block(mutableAttributedString)];
} else {
[self setText:mutableAttributedString];
}
}
#pragma mark - UILabel
- (void)setHighlighted:(BOOL)highlighted {
[super setHighlighted:highlighted];
[self setNeedsDisplay];
}
- (void)drawTextInRect:(CGRect)rect {
if (!self.attributedText) {
[super drawTextInRect:rect];
return;
}
NSAttributedString *originalAttributedText = nil;
// Adjust the font size to fit width, if necessarry
if (self.adjustsFontSizeToFitWidth && self.numberOfLines > 0) {
CGFloat textWidth = [self sizeThatFits:CGSizeZero].width;
CGFloat availableWidth = self.frame.size.width * self.numberOfLines;
if (self.numberOfLines > 1 && self.lineBreakMode == UILineBreakModeWordWrap) {
textWidth *= kTTTLineBreakWordWrapTextWidthScalingFactor;
}
if (textWidth > availableWidth && textWidth > 0.0f) {
originalAttributedText = [[self.attributedText copy] autorelease];
self.text = NSAttributedStringByScalingFontSize(self.attributedText, availableWidth / textWidth, self.minimumFontSize);
}
}
CGContextRef c = UIGraphicsGetCurrentContext();
CGContextSetTextMatrix(c, CGAffineTransformIdentity);
// Inverts the CTM to match iOS coordinates (otherwise text draws upside-down; Mac OS's system is different)
CGContextTranslateCTM(c, 0.0f, self.bounds.size.height);
CGContextScaleCTM(c, 1.0f, -1.0f);
CGRect textRect = rect;
CFRange textRange = CFRangeMake(0, [self.attributedText length]);
CFRange fitRange;
// First, adjust the text to be in the center vertically, if the text size is smaller than the drawing rect
CGSize textSize = CTFramesetterSuggestFrameSizeWithConstraints(self.framesetter, textRange, NULL, textRect.size, &fitRange);
if (textSize.height < textRect.size.height) {
CGFloat yOffset = 0.0f;
switch (self.verticalAlignment) {
case TTTAttributedLabelVerticalAlignmentTop:
break;
case TTTAttributedLabelVerticalAlignmentCenter:
yOffset = floorf((textRect.size.height - textSize.height) / 2.0f);
break;
case TTTAttributedLabelVerticalAlignmentBottom:
yOffset = (textRect.size.height - textSize.height);
break;
}
textRect.origin = CGPointMake(textRect.origin.x, textRect.origin.y + yOffset);
textRect.size = CGSizeMake(textRect.size.width, textRect.size.height - yOffset);
}
// Second, trace the shadow before the actual text, if we have one
if (self.shadowColor && !self.highlighted) {
CGContextSetShadowWithColor(c, self.shadowOffset, self.shadowRadius, [self.shadowColor CGColor]);
}
// Finally, draw the text or highlighted text itself (on top of the shadow, if there is one)
if (self.highlightedTextColor && self.highlighted) {
if (!self.highlightFramesetter) {
NSMutableAttributedString *mutableAttributedString = [[self.attributedText mutableCopy] autorelease];
[mutableAttributedString addAttribute:(NSString *)kCTForegroundColorAttributeName value:(id)[self.highlightedTextColor CGColor] range:NSMakeRange(0, mutableAttributedString.length)];
self.highlightFramesetter = CTFramesetterCreateWithAttributedString((CFAttributedStringRef)mutableAttributedString);
}
[self drawFramesetter:self.highlightFramesetter textRange:textRange inRect:textRect context:c];
} else {
[self drawFramesetter:self.framesetter textRange:textRange inRect:textRect context:c];
}
// If we adjusted the font size, set it back to its original size
if (originalAttributedText) {
self.text = originalAttributedText;
}
}
#pragma mark - UIControl
- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event {
UITouch *touch = [touches anyObject];
NSTextCheckingResult *result = [self linkAtPoint:[touch locationInView:self]];
if (result && self.delegate) {
switch (result.resultType) {
case NSTextCheckingTypeLink:
if ([self.delegate respondsToSelector:@selector(attributedLabel:didSelectLinkWithURL:)]) {
[self.delegate attributedLabel:self didSelectLinkWithURL:result.URL];
}
break;
case NSTextCheckingTypeAddress:
if ([self.delegate respondsToSelector:@selector(attributedLabel:didSelectLinkWithAddress:)]) {
[self.delegate attributedLabel:self didSelectLinkWithAddress:result.addressComponents];
}
break;
case NSTextCheckingTypePhoneNumber:
if ([self.delegate respondsToSelector:@selector(attributedLabel:didSelectLinkWithPhoneNumber:)]) {
[self.delegate attributedLabel:self didSelectLinkWithPhoneNumber:result.phoneNumber];
}
break;
case NSTextCheckingTypeDate:
if (result.timeZone && [self.delegate respondsToSelector:@selector(attributedLabel:didSelectLinkWithDate:timeZone:duration:)]) {
[self.delegate attributedLabel:self didSelectLinkWithDate:result.date timeZone:result.timeZone duration:result.duration];
} else if ([self.delegate respondsToSelector:@selector(attributedLabel:didSelectLinkWithDate:)]) {
[self.delegate attributedLabel:self didSelectLinkWithDate:result.date];
}
break;
}
} else {
[self.nextResponder touchesBegan:touches withEvent:event];
}
}
#pragma mark - UIView
- (CGSize)sizeThatFits:(CGSize)size {
if (!self.attributedText) {
return [super sizeThatFits:size];
}
CGSize suggestedSize = CTFramesetterSuggestFrameSizeWithConstraints(self.framesetter, CFRangeMake(0, [self.attributedText length]), NULL, CGSizeMake(size.width, CGFLOAT_MAX), NULL);
return CGSizeMake(ceilf(suggestedSize.width), ceilf(suggestedSize.height));
}
@end