// Created by Tencent on 2023/06/09. // Copyright © 2023 Tencent. All rights reserved. // TUIAttributedLabel.m #import "TUIAttributedLabel.h" #import #import #import #define kTUILineBreakWordWrapTextWidthScalingFactor (M_PI / M_E) static CGFloat const TUIFLOAT_MAX = 100000; NSString *const kTUIStrikeOutAttributeName = @"TUIStrikeOutAttribute"; NSString *const kTUIBackgroundFillColorAttributeName = @"TUIBackgroundFillColor"; NSString *const kTUIBackgroundFillPaddingAttributeName = @"TUIBackgroundFillPadding"; NSString *const kTUIBackgroundStrokeColorAttributeName = @"TUIBackgroundStrokeColor"; NSString *const kTUIBackgroundLineWidthAttributeName = @"TUIBackgroundLineWidth"; NSString *const kTUIBackgroundCornerRadiusAttributeName = @"TUIBackgroundCornerRadius"; const NSTextAlignment TUITextAlignmentLeft = NSTextAlignmentLeft; const NSTextAlignment TUITextAlignmentCenter = NSTextAlignmentCenter; const NSTextAlignment TUITextAlignmentRight = NSTextAlignmentRight; const NSTextAlignment TUITextAlignmentJustified = NSTextAlignmentJustified; const NSTextAlignment TUITextAlignmentNatural = NSTextAlignmentNatural; const NSLineBreakMode TUILineBreakByWordWrapping = NSLineBreakByWordWrapping; const NSLineBreakMode TUILineBreakByCharWrapping = NSLineBreakByCharWrapping; const NSLineBreakMode TUILineBreakByClipping = NSLineBreakByClipping; const NSLineBreakMode TUILineBreakByTruncatingHead = NSLineBreakByTruncatingHead; const NSLineBreakMode TUILineBreakByTruncatingMiddle = NSLineBreakByTruncatingMiddle; const NSLineBreakMode TUILineBreakByTruncatingTail = NSLineBreakByTruncatingTail; typedef NSTextAlignment TUITextAlignment; typedef NSLineBreakMode TUILineBreakMode; static inline CGFLOAT_TYPE formatCGFloatCeil(CGFLOAT_TYPE cgfloat) { #if CGFLOAT_IS_DOUBLE return ceil(cgfloat); #else return ceilf(cgfloat); #endif } static inline CGFLOAT_TYPE formatCGFloatFloor(CGFLOAT_TYPE cgfloat) { #if CGFLOAT_IS_DOUBLE return floor(cgfloat); #else return floorf(cgfloat); #endif } static inline CGFLOAT_TYPE formatCGFloatRound(CGFLOAT_TYPE cgfloat) { #if CGFLOAT_IS_DOUBLE return round(cgfloat); #else return roundf(cgfloat); #endif } static inline CGFLOAT_TYPE formatCGFloatSqrt(CGFLOAT_TYPE cgfloat) { #if CGFLOAT_IS_DOUBLE return sqrt(cgfloat); #else return sqrtf(cgfloat); #endif } static inline CGFloat flushFactorForTextAlignment(NSTextAlignment textAlignment) { switch (textAlignment) { case TUITextAlignmentCenter: return 0.5f; case TUITextAlignmentRight: return 1.0f; case TUITextAlignmentLeft: default: return 0.0f; } } static inline NSDictionary *formatNSAttributedStringAttributesFromLabel(TUIAttributedLabel *label) { NSMutableDictionary *mutableAttributes = [NSMutableDictionary dictionary]; [mutableAttributes setObject:label.font forKey:(NSString *)kCTFontAttributeName]; [mutableAttributes setObject:label.textColor forKey:(NSString *)kCTForegroundColorAttributeName]; [mutableAttributes setObject:@(label.kern) forKey:(NSString *)kCTKernAttributeName]; NSMutableParagraphStyle *paragraphStyle = [[NSMutableParagraphStyle alloc] init]; paragraphStyle.alignment = label.textAlignment; paragraphStyle.lineSpacing = label.lineSpacing; paragraphStyle.minimumLineHeight = label.minimumLineHeight > 0 ? label.minimumLineHeight : label.font.lineHeight * label.lineHeightMultiple; paragraphStyle.maximumLineHeight = label.maximumLineHeight > 0 ? label.maximumLineHeight : label.font.lineHeight * label.lineHeightMultiple; paragraphStyle.lineHeightMultiple = label.lineHeightMultiple; paragraphStyle.firstLineHeadIndent = label.firstLineIndent; if (label.numberOfLines == 1) { paragraphStyle.lineBreakMode = label.lineBreakMode; } else { paragraphStyle.lineBreakMode = NSLineBreakByWordWrapping; } [mutableAttributes setObject:paragraphStyle forKey:(NSString *)kCTParagraphStyleAttributeName]; return [NSDictionary dictionaryWithDictionary:mutableAttributes]; } static inline CGColorRef formatCGColorRefFromColor(id color); static inline NSDictionary *convertNSAttributedStringAttributesToCTAttributes(NSDictionary *attributes); static inline NSAttributedString *formatNSAttributedStringByScalingFontSize(NSAttributedString *attributedString, CGFloat scale) { NSMutableAttributedString *mutableAttributedString = [attributedString mutableCopy]; [mutableAttributedString enumerateAttribute:(NSString *)kCTFontAttributeName inRange:NSMakeRange(0, [mutableAttributedString length]) options:0 usingBlock:^(id value, NSRange range, BOOL *__unused stop) { UIFont *font = (UIFont *)value; if (font) { NSString *fontName; CGFloat pointSize; if ([font isKindOfClass:[UIFont class]]) { fontName = font.fontName; pointSize = font.pointSize; } else { fontName = (NSString *)CFBridgingRelease(CTFontCopyName((__bridge CTFontRef)font, kCTFontPostScriptNameKey)); pointSize = CTFontGetSize((__bridge CTFontRef)font); } [mutableAttributedString removeAttribute:(NSString *)kCTFontAttributeName range:range]; CTFontRef fontRef = CTFontCreateWithName((__bridge CFStringRef)fontName, formatCGFloatFloor(pointSize * scale), NULL); [mutableAttributedString addAttribute:(NSString *)kCTFontAttributeName value:(__bridge id)fontRef range:range]; CFRelease(fontRef); } }]; return mutableAttributedString; } static inline NSAttributedString *formatNSAttributedStringBySettingColorFromContext(NSAttributedString *attributedString, UIColor *color) { if (!color) { return attributedString; } NSMutableAttributedString *mutableAttributedString = [attributedString mutableCopy]; [mutableAttributedString enumerateAttribute:(NSString *)kCTForegroundColorFromContextAttributeName inRange:NSMakeRange(0, [mutableAttributedString length]) options:0 usingBlock:^(id value, NSRange range, __unused BOOL *stop) { BOOL usesColorFromContext = (BOOL)value; if (usesColorFromContext) { [mutableAttributedString setAttributes:[NSDictionary dictionaryWithObject:color forKey:(NSString *)kCTForegroundColorAttributeName] range:range]; [mutableAttributedString removeAttribute:(NSString *)kCTForegroundColorFromContextAttributeName range:range]; } }]; return mutableAttributedString; } static inline CGSize formatCTFramesetterSuggestFrameSizeForAttributedStringWithConstraints(CTFramesetterRef framesetter, NSAttributedString *attributedString, CGSize size, NSUInteger numberOfLines) { CFRange rangeToSize = CFRangeMake(0, (CFIndex)[attributedString length]); CGSize constraints = CGSizeMake(size.width, TUIFLOAT_MAX); if (numberOfLines == 1) { // If there is one line, the size that fits is the full width of the line constraints = CGSizeMake(TUIFLOAT_MAX, TUIFLOAT_MAX); } else if (numberOfLines > 0) { // If the line count of the label more than 1, limit the range to size to the number of lines that have been set CGMutablePathRef path = CGPathCreateMutable(); CGPathAddRect(path, NULL, CGRectMake(0.0f, 0.0f, constraints.width, TUIFLOAT_MAX)); CTFrameRef frame = CTFramesetterCreateFrame(framesetter, CFRangeMake(0, 0), path, NULL); CFArrayRef lines = CTFrameGetLines(frame); if (CFArrayGetCount(lines) > 0) { NSInteger lastVisibleLineIndex = MIN((CFIndex)numberOfLines, CFArrayGetCount(lines)) - 1; CTLineRef lastVisibleLine = CFArrayGetValueAtIndex(lines, lastVisibleLineIndex); CFRange rangeToLayout = CTLineGetStringRange(lastVisibleLine); rangeToSize = CFRangeMake(0, rangeToLayout.location + rangeToLayout.length); } CFRelease(frame); CGPathRelease(path); } CGSize suggestedSize = CTFramesetterSuggestFrameSizeWithConstraints(framesetter, rangeToSize, NULL, constraints, NULL); return CGSizeMake(formatCGFloatCeil(suggestedSize.width), formatCGFloatCeil(suggestedSize.height)); } @interface TUIAccessibilityElement : UIAccessibilityElement @property(nonatomic, weak) UIView *superview; @property(nonatomic, assign) CGRect boundingRect; @end @implementation TUIAccessibilityElement - (CGRect)accessibilityFrame { return UIAccessibilityConvertFrameToScreenCoordinates(self.boundingRect, self.superview); } @end @interface TUIAttributedLabel () @property(readwrite, nonatomic, copy) NSAttributedString *inactiveAttributedText; @property(readwrite, nonatomic, copy) NSAttributedString *renderedAttributedText; @property(readwrite, atomic, strong) NSDataDetector *dataDetector; @property(readwrite, nonatomic, strong) NSArray *linkModels; @property(readwrite, nonatomic, strong) TUIAttributedLabelLink *activeLink; @property(readwrite, nonatomic, strong) NSArray *accessibilityElements; - (void)longPressGestureDidFire:(UILongPressGestureRecognizer *)sender; @end @implementation TUIAttributedLabel { @private BOOL _needsFramesetter; CTFramesetterRef _framesetter; CTFramesetterRef _highlightFramesetter; } @dynamic text; @synthesize attributedText = _attributedText; #ifndef kCFCoreFoundationVersionNumber_iOS_7_0 #define kCFCoreFoundationVersionNumber_iOS_7_0 847.2 #endif + (void)load { static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ if (kCFCoreFoundationVersionNumber < kCFCoreFoundationVersionNumber_iOS_7_0) { Class class = [self class]; Class superclass = class_getSuperclass(class); NSArray *strings = @[ NSStringFromSelector(@selector(isAccessibilityElement)), NSStringFromSelector(@selector(accessibilityElementCount)), NSStringFromSelector(@selector(accessibilityElementAtIndex:)), NSStringFromSelector(@selector(indexOfAccessibilityElement:)), ]; for (NSString *string in strings) { SEL selector = NSSelectorFromString(string); IMP superImplementation = class_getMethodImplementation(superclass, selector); Method method = class_getInstanceMethod(class, selector); const char *types = method_getTypeEncoding(method); class_replaceMethod(class, selector, superImplementation, types); } } }); } - (instancetype)initWithFrame:(CGRect)frame { self = [super initWithFrame:frame]; if (!self) { return nil; } [self commonInit]; return self; } - (void)commonInit { self.userInteractionEnabled = YES; #if !TARGET_OS_TV self.multipleTouchEnabled = NO; #endif self.textInsets = UIEdgeInsetsZero; self.lineHeightMultiple = 1.0f; self.linkModels = [NSArray array]; self.linkBackgroundEdgeInset = UIEdgeInsetsMake(0.0f, -1.0f, 0.0f, -1.0f); NSMutableDictionary *mutableLinkAttributes = [NSMutableDictionary dictionary]; [mutableLinkAttributes setObject:[NSNumber numberWithBool:YES] forKey:(NSString *)kCTUnderlineStyleAttributeName]; NSMutableDictionary *mutableActiveLinkAttributes = [NSMutableDictionary dictionary]; [mutableActiveLinkAttributes setObject:[NSNumber numberWithBool:NO] forKey:(NSString *)kCTUnderlineStyleAttributeName]; NSMutableDictionary *mutableInactiveLinkAttributes = [NSMutableDictionary dictionary]; [mutableInactiveLinkAttributes setObject:[NSNumber numberWithBool:NO] forKey:(NSString *)kCTUnderlineStyleAttributeName]; if ([NSMutableParagraphStyle class]) { [mutableLinkAttributes setObject:[UIColor blueColor] forKey:(NSString *)kCTForegroundColorAttributeName]; [mutableActiveLinkAttributes setObject:[UIColor redColor] forKey:(NSString *)kCTForegroundColorAttributeName]; [mutableInactiveLinkAttributes setObject:[UIColor grayColor] forKey:(NSString *)kCTForegroundColorAttributeName]; } else { [mutableLinkAttributes setObject:(__bridge id)[[UIColor blueColor] CGColor] forKey:(NSString *)kCTForegroundColorAttributeName]; [mutableActiveLinkAttributes setObject:(__bridge id)[[UIColor redColor] CGColor] forKey:(NSString *)kCTForegroundColorAttributeName]; [mutableInactiveLinkAttributes setObject:(__bridge id)[[UIColor grayColor] CGColor] forKey:(NSString *)kCTForegroundColorAttributeName]; } self.linkAttributes = [NSDictionary dictionaryWithDictionary:mutableLinkAttributes]; self.activeLinkAttributes = [NSDictionary dictionaryWithDictionary:mutableActiveLinkAttributes]; self.inactiveLinkAttributes = [NSDictionary dictionaryWithDictionary:mutableInactiveLinkAttributes]; _extendsLinkTouchArea = NO; _longPressGestureRecognizer = [[UILongPressGestureRecognizer alloc] initWithTarget:self action:@selector(longPressGestureDidFire:)]; self.longPressGestureRecognizer.delegate = self; [self addGestureRecognizer:self.longPressGestureRecognizer]; } - (void)dealloc { if (_framesetter) { CFRelease(_framesetter); } if (_highlightFramesetter) { CFRelease(_highlightFramesetter); } if (_longPressGestureRecognizer) { [self removeGestureRecognizer:_longPressGestureRecognizer]; } } #pragma mark - + (CGSize)sizeThatFitsAttributedString:(NSAttributedString *)attributedString withConstraints:(CGSize)size limitedToNumberOfLines:(NSUInteger)numberOfLines { if (!attributedString || attributedString.length == 0) { return CGSizeZero; } CTFramesetterRef framesetter = CTFramesetterCreateWithAttributedString((__bridge CFAttributedStringRef)attributedString); CGSize calculatedSize = formatCTFramesetterSuggestFrameSizeForAttributedStringWithConstraints(framesetter, attributedString, size, numberOfLines); CFRelease(framesetter); return calculatedSize; } #pragma mark - - (void)setAttributedText:(NSAttributedString *)text { if ([text isEqualToAttributedString:_attributedText]) { return; } _attributedText = [text copy]; [self setNeedsFramesetter]; [self setNeedsDisplay]; if ([self respondsToSelector:@selector(invalidateIntrinsicContentSize)]) { [self invalidateIntrinsicContentSize]; } [super setText:[self.attributedText string]]; } - (NSAttributedString *)renderedAttributedText { if (!_renderedAttributedText) { NSMutableAttributedString *fullString = [[NSMutableAttributedString alloc] initWithAttributedString:self.attributedText]; if (self.attributedTruncationToken) { [fullString appendAttributedString:self.attributedTruncationToken]; } NSAttributedString *string = [[NSAttributedString alloc] initWithAttributedString:fullString]; self.renderedAttributedText = formatNSAttributedStringBySettingColorFromContext(string, self.textColor); } return _renderedAttributedText; } - (NSArray *)links { return [_linkModels valueForKey:@"result"]; } - (void)setLinkModels:(NSArray *)linkModels { _linkModels = linkModels; self.accessibilityElements = nil; } - (void)setNeedsFramesetter { // Reset the rendered attributed text so it has a chance to regenerate self.renderedAttributedText = nil; _needsFramesetter = YES; } - (CTFramesetterRef)framesetter { if (_needsFramesetter) { @synchronized(self) { CTFramesetterRef framesetter = CTFramesetterCreateWithAttributedString((__bridge CFAttributedStringRef)self.renderedAttributedText); [self setFramesetter:framesetter]; [self setHighlightFramesetter:nil]; _needsFramesetter = NO; if (framesetter) { CFRelease(framesetter); } } } return _framesetter; } - (void)setFramesetter:(CTFramesetterRef)framesetter { if (framesetter) { CFRetain(framesetter); } if (_framesetter) { CFRelease(_framesetter); } _framesetter = framesetter; } - (CTFramesetterRef)highlightFramesetter { return _highlightFramesetter; } - (void)setHighlightFramesetter:(CTFramesetterRef)highlightFramesetter { if (highlightFramesetter) { CFRetain(highlightFramesetter); } if (_highlightFramesetter) { CFRelease(_highlightFramesetter); } _highlightFramesetter = highlightFramesetter; } #pragma mark - - (void)setEnabledTextCheckingTypes:(NSTextCheckingTypes)enabledTextCheckingTypes { if (self.enabledTextCheckingTypes == enabledTextCheckingTypes) { return; } _enabledTextCheckingTypes = enabledTextCheckingTypes; // one detector instance per type (combination), fast reuse e.g. in cells static NSMutableDictionary *dataDetectorsByType = nil; if (!dataDetectorsByType) { dataDetectorsByType = [NSMutableDictionary dictionary]; } if (enabledTextCheckingTypes) { if (![dataDetectorsByType objectForKey:@(enabledTextCheckingTypes)]) { NSDataDetector *detector = [NSDataDetector dataDetectorWithTypes:enabledTextCheckingTypes error:nil]; if (detector) { [dataDetectorsByType setObject:detector forKey:@(enabledTextCheckingTypes)]; } } self.dataDetector = [dataDetectorsByType objectForKey:@(enabledTextCheckingTypes)]; } else { self.dataDetector = nil; } } - (void)addLink:(TUIAttributedLabelLink *)link { [self addLinks:@[ link ]]; } - (void)addLinks:(NSArray *)links { NSMutableArray *mutableLinkModels = [NSMutableArray arrayWithArray:self.linkModels]; NSMutableAttributedString *mutableAttributedString = [self.attributedText mutableCopy]; for (TUIAttributedLabelLink *link in links) { if (link.attributes) { [mutableAttributedString addAttributes:link.attributes range:link.result.range]; } } self.attributedText = mutableAttributedString; [self setNeedsDisplay]; [mutableLinkModels addObjectsFromArray:links]; self.linkModels = [NSArray arrayWithArray:mutableLinkModels]; } - (TUIAttributedLabelLink *)addLinkWithTextCheckingResult:(NSTextCheckingResult *)result attributes:(NSDictionary *)attributes { return [self addLinksWithTextCheckingResults:@[ result ] attributes:attributes].firstObject; } - (NSArray *)addLinksWithTextCheckingResults:(NSArray *)results attributes:(NSDictionary *)attributes { NSMutableArray *links = [NSMutableArray array]; for (NSTextCheckingResult *result in results) { NSDictionary *activeAttributes = attributes ? self.activeLinkAttributes : nil; NSDictionary *inactiveAttributes = attributes ? self.inactiveLinkAttributes : nil; TUIAttributedLabelLink *link = [[TUIAttributedLabelLink alloc] initWithAttributes:attributes activeAttributes:activeAttributes inactiveAttributes:inactiveAttributes textCheckingResult:result]; [links addObject:link]; } [self addLinks:links]; return links; } - (TUIAttributedLabelLink *)addLinkWithTextCheckingResult:(NSTextCheckingResult *)result { return [self addLinkWithTextCheckingResult:result attributes:self.linkAttributes]; } - (TUIAttributedLabelLink *)addLinkToURL:(NSURL *)url withRange:(NSRange)range { return [self addLinkWithTextCheckingResult:[NSTextCheckingResult linkCheckingResultWithRange:range URL:url]]; } - (TUIAttributedLabelLink *)addLinkToAddress:(NSDictionary *)addressComponents withRange:(NSRange)range { return [self addLinkWithTextCheckingResult:[NSTextCheckingResult addressCheckingResultWithRange:range components:addressComponents]]; } - (TUIAttributedLabelLink *)addLinkToPhoneNumber:(NSString *)phoneNumber withRange:(NSRange)range { return [self addLinkWithTextCheckingResult:[NSTextCheckingResult phoneNumberCheckingResultWithRange:range phoneNumber:phoneNumber]]; } - (TUIAttributedLabelLink *)addLinkToDate:(NSDate *)date withRange:(NSRange)range { return [self addLinkWithTextCheckingResult:[NSTextCheckingResult dateCheckingResultWithRange:range date:date]]; } - (TUIAttributedLabelLink *)addLinkToDate:(NSDate *)date timeZone:(NSTimeZone *)timeZone duration:(NSTimeInterval)duration withRange:(NSRange)range { return [self addLinkWithTextCheckingResult:[NSTextCheckingResult dateCheckingResultWithRange:range date:date timeZone:timeZone duration:duration]]; } - (TUIAttributedLabelLink *)addLinkToTransitInformation:(NSDictionary *)components withRange:(NSRange)range { return [self addLinkWithTextCheckingResult:[NSTextCheckingResult transitInformationCheckingResultWithRange:range components:components]]; } #pragma mark - - (BOOL)containslinkAtPoint:(CGPoint)point { return [self linkAtPoint:point] != nil; } - (TUIAttributedLabelLink *)linkAtPoint:(CGPoint)point { // Stop quickly if none of the points to be tested are in the bounds. if (!CGRectContainsPoint(CGRectInset(self.bounds, -15.f, -15.f), point) || self.links.count == 0) { return nil; } TUIAttributedLabelLink *result = [self linkAtCharacterIndex:[self characterIndexAtPoint:point]]; if (!result && self.extendsLinkTouchArea) { result = [self linkAtRadius:2.5f aroundPoint:point] ?: [self linkAtRadius:5.f aroundPoint:point] ?: [self linkAtRadius:7.5f aroundPoint:point] ?: [self linkAtRadius:12.5f aroundPoint:point] ?: [self linkAtRadius:15.f aroundPoint:point]; } return result; } - (TUIAttributedLabelLink *)linkAtRadius:(const CGFloat)radius aroundPoint:(CGPoint)point { const CGFloat diagonal = formatCGFloatSqrt(2 * radius * radius); const CGPoint deltas[] = { CGPointMake(0, -radius), CGPointMake(0, radius), // Above and below CGPointMake(-radius, 0), CGPointMake(radius, 0), // Beside CGPointMake(-diagonal, -diagonal), CGPointMake(-diagonal, diagonal), CGPointMake(diagonal, diagonal), CGPointMake(diagonal, -diagonal) // Diagonal }; const size_t count = sizeof(deltas) / sizeof(CGPoint); TUIAttributedLabelLink *link = nil; for (NSUInteger i = 0; i < count && link.result == nil; i++) { CGPoint currentPoint = CGPointMake(point.x + deltas[i].x, point.y + deltas[i].y); link = [self linkAtCharacterIndex:[self characterIndexAtPoint:currentPoint]]; } return link; } - (TUIAttributedLabelLink *)linkAtCharacterIndex:(CFIndex)idx { // Do not enumerate if the index is outside of the bounds of the text. if (!NSLocationInRange((NSUInteger)idx, NSMakeRange(0, self.attributedText.length))) { return nil; } NSEnumerator *enumerator = [self.linkModels reverseObjectEnumerator]; TUIAttributedLabelLink *link = nil; while ((link = [enumerator nextObject])) { if (NSLocationInRange((NSUInteger)idx, link.result.range)) { return link; } } return nil; } - (CFIndex)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; } // Offset tap coordinates by textRect origin to make them relative to the origin of frame p = CGPointMake(p.x - textRect.origin.x, p.y - textRect.origin.y); // Convert tap coordinates (start at top left) to CT coordinates (start at bottom left) p = CGPointMake(p.x, textRect.size.height - p.y); CGMutablePathRef path = CGPathCreateMutable(); CGPathAddRect(path, NULL, textRect); CTFrameRef frame = CTFramesetterCreateFrame([self framesetter], CFRangeMake(0, (CFIndex)[self.attributedText length]), path, NULL); if (frame == NULL) { CGPathRelease(path); return NSNotFound; } CFArrayRef lines = CTFrameGetLines(frame); NSInteger numberOfLines = self.numberOfLines > 0 ? MIN(self.numberOfLines, CFArrayGetCount(lines)) : CFArrayGetCount(lines); if (numberOfLines == 0) { CFRelease(frame); CGPathRelease(path); return NSNotFound; } CFIndex idx = NSNotFound; CGPoint lineOrigins[numberOfLines]; CTFrameGetLineOrigins(frame, CFRangeMake(0, numberOfLines), lineOrigins); for (CFIndex lineIndex = 0; lineIndex < numberOfLines; lineIndex++) { CGPoint lineOrigin = lineOrigins[lineIndex]; CTLineRef line = CFArrayGetValueAtIndex(lines, lineIndex); // Get bounding information of line CGFloat ascent = 0.0f, descent = 0.0f, leading = 0.0f; CGFloat width = (CGFloat)CTLineGetTypographicBounds(line, &ascent, &descent, &leading); CGFloat yMin = (CGFloat)floor(lineOrigin.y - descent); CGFloat yMax = (CGFloat)ceil(lineOrigin.y + ascent); // Apply penOffset using flushFactor for horizontal alignment to set lineOrigin since this is the horizontal offset from drawFramesetter CGFloat flushFactor = flushFactorForTextAlignment(self.textAlignment); CGFloat penOffset = (CGFloat)CTLineGetPenOffsetForFlush(line, flushFactor, textRect.size.width); lineOrigin.x = penOffset; // Check if we've already passed the line if (p.y > yMax) { break; } // Check if the point is within this line vertically if (p.y >= yMin) { // Check if the point is within this line horizontally if (p.x >= lineOrigin.x && p.x <= lineOrigin.x + width) { // Convert CT coordinates to line-relative coordinates CGPoint relativePoint = CGPointMake(p.x - lineOrigin.x, p.y - lineOrigin.y); idx = CTLineGetStringIndexForPosition(line, relativePoint); break; } } } CFRelease(frame); CGPathRelease(path); return idx; } - (CGRect)boundingRectForCharacterRange:(NSRange)range { NSMutableAttributedString *mutableAttributedString = [self.attributedText mutableCopy]; NSTextStorage *textStorage = [[NSTextStorage alloc] initWithAttributedString:mutableAttributedString]; NSLayoutManager *layoutManager = [[NSLayoutManager alloc] init]; [textStorage addLayoutManager:layoutManager]; NSTextContainer *textContainer = [[NSTextContainer alloc] initWithSize:self.bounds.size]; [layoutManager addTextContainer:textContainer]; NSRange glyphRange; [layoutManager characterRangeForGlyphRange:range actualGlyphRange:&glyphRange]; return [layoutManager boundingRectForGlyphRange:glyphRange inTextContainer:textContainer]; } - (void)drawFramesetter:(CTFramesetterRef)framesetter attributedString:(NSAttributedString *)attributedString textRange:(CFRange)textRange inRect:(CGRect)rect context:(CGContextRef)c { CGMutablePathRef path = CGPathCreateMutable(); CGPathAddRect(path, NULL, rect); CTFrameRef frame = CTFramesetterCreateFrame(framesetter, textRange, path, NULL); [self drawBackground:frame inRect:rect context:c]; CFArrayRef lines = CTFrameGetLines(frame); NSInteger numberOfLines = self.numberOfLines > 0 ? MIN(self.numberOfLines, CFArrayGetCount(lines)) : CFArrayGetCount(lines); BOOL truncateLastLine = (self.lineBreakMode == TUILineBreakByTruncatingHead || self.lineBreakMode == TUILineBreakByTruncatingMiddle || self.lineBreakMode == TUILineBreakByTruncatingTail); CGPoint lineOrigins[numberOfLines]; CTFrameGetLineOrigins(frame, CFRangeMake(0, numberOfLines), lineOrigins); for (CFIndex lineIndex = 0; lineIndex < numberOfLines; lineIndex++) { CGPoint lineOrigin = lineOrigins[lineIndex]; CGContextSetTextPosition(c, lineOrigin.x, lineOrigin.y); CTLineRef line = CFArrayGetValueAtIndex(lines, lineIndex); CGFloat descent = 0.0f; CTLineGetTypographicBounds((CTLineRef)line, NULL, &descent, NULL); // Adjust pen offset for flush depending on text alignment CGFloat flushFactor = flushFactorForTextAlignment(self.textAlignment); if (lineIndex == numberOfLines - 1 && truncateLastLine) { // Check if the range of text in the last line reaches the end of the full attributed string CFRange lastLineRange = CTLineGetStringRange(line); if (!(lastLineRange.length == 0 && lastLineRange.location == 0) && lastLineRange.location + lastLineRange.length < textRange.location + textRange.length) { // Get correct truncationType and attribute position CTLineTruncationType truncationType; CFIndex truncationAttributePosition = lastLineRange.location; TUILineBreakMode lineBreakMode = self.lineBreakMode; // Multiple lines, only use UILineBreakModeTailTruncation if (numberOfLines != 1) { lineBreakMode = TUILineBreakByTruncatingTail; } switch (lineBreakMode) { case TUILineBreakByTruncatingHead: truncationType = kCTLineTruncationStart; break; case TUILineBreakByTruncatingMiddle: truncationType = kCTLineTruncationMiddle; truncationAttributePosition += (lastLineRange.length / 2); break; case TUILineBreakByTruncatingTail: default: truncationType = kCTLineTruncationEnd; truncationAttributePosition += (lastLineRange.length - 1); break; } NSAttributedString *attributedTruncationString = self.attributedTruncationToken; if (!attributedTruncationString) { NSString *truncationTokenString = @"\u2026"; // Unicode Character 'HORIZONTAL ELLIPSIS' (U+2026) NSDictionary *truncationTokenStringAttributes = truncationTokenStringAttributes = [attributedString attributesAtIndex:(NSUInteger)truncationAttributePosition effectiveRange:NULL]; attributedTruncationString = [[NSAttributedString alloc] initWithString:truncationTokenString attributes:truncationTokenStringAttributes]; } CTLineRef truncationToken = CTLineCreateWithAttributedString((__bridge CFAttributedStringRef)attributedTruncationString); // Append truncationToken to the string // because if string isn't too long, CT won't add the truncationToken on its own. // There is no chance of a double truncationToken because CT only adds the // token if it removes characters (and the one we add will go first) NSMutableAttributedString *truncationString = [[NSMutableAttributedString alloc] initWithAttributedString:[attributedString attributedSubstringFromRange:NSMakeRange((NSUInteger)lastLineRange.location, (NSUInteger)lastLineRange.length)]]; if (lastLineRange.length > 0) { // Remove any newline at the end (we don't want newline space between the text and the truncation token). There can only be one, because the // second would be on the next line. unichar lastCharacter = [[truncationString string] characterAtIndex:(NSUInteger)(lastLineRange.length - 1)]; if ([[NSCharacterSet newlineCharacterSet] characterIsMember:lastCharacter]) { [truncationString deleteCharactersInRange:NSMakeRange((NSUInteger)(lastLineRange.length - 1), 1)]; } } [truncationString appendAttributedString:attributedTruncationString]; CTLineRef truncationLine = CTLineCreateWithAttributedString((__bridge CFAttributedStringRef)truncationString); // Truncate the line in case it is too long. CTLineRef truncatedLine = CTLineCreateTruncatedLine(truncationLine, rect.size.width, truncationType, truncationToken); if (!truncatedLine) { // If the line is not as wide as the truncationToken, truncatedLine is NULL truncatedLine = CFRetain(truncationToken); } CGFloat penOffset = (CGFloat)CTLineGetPenOffsetForFlush(truncatedLine, flushFactor, rect.size.width); CGContextSetTextPosition(c, penOffset, lineOrigin.y - descent - self.font.descender); CTLineDraw(truncatedLine, c); NSRange linkRange; if ([attributedTruncationString attribute:NSLinkAttributeName atIndex:0 effectiveRange:&linkRange]) { NSRange tokenRange = [truncationString.string rangeOfString:attributedTruncationString.string]; NSRange tokenLinkRange = NSMakeRange((NSUInteger)(lastLineRange.location + lastLineRange.length) - tokenRange.length, (NSUInteger)tokenRange.length); [self addLinkToURL:[attributedTruncationString attribute:NSLinkAttributeName atIndex:0 effectiveRange:&linkRange] withRange:tokenLinkRange]; } CFRelease(truncatedLine); CFRelease(truncationLine); CFRelease(truncationToken); } else { CGFloat penOffset = (CGFloat)CTLineGetPenOffsetForFlush(line, flushFactor, rect.size.width); CGContextSetTextPosition(c, penOffset, lineOrigin.y - descent - self.font.descender); CTLineDraw(line, c); } } else { CGFloat penOffset = (CGFloat)CTLineGetPenOffsetForFlush(line, flushFactor, rect.size.width); CGContextSetTextPosition(c, penOffset, lineOrigin.y - descent - self.font.descender); CTLineDraw(line, c); } } [self drawStrike:frame inRect:rect context:c]; CFRelease(frame); CGPathRelease(path); } - (void)drawBackground:(CTFrameRef)frame inRect:(CGRect)rect context:(CGContextRef)c { NSArray *lines = (__bridge NSArray *)CTFrameGetLines(frame); CGPoint origins[[lines count]]; CTFrameGetLineOrigins(frame, CFRangeMake(0, 0), origins); CFIndex lineIndex = 0; for (id line in lines) { CGFloat ascent = 0.0f, descent = 0.0f, leading = 0.0f; CGFloat width = (CGFloat)CTLineGetTypographicBounds((__bridge CTLineRef)line, &ascent, &descent, &leading); for (id glyphRun in (__bridge NSArray *)CTLineGetGlyphRuns((__bridge CTLineRef)line)) { NSDictionary *attributes = (__bridge NSDictionary *)CTRunGetAttributes((__bridge CTRunRef)glyphRun); CGColorRef strokeColor = formatCGColorRefFromColor([attributes objectForKey:kTUIBackgroundStrokeColorAttributeName]); CGColorRef fillColor = formatCGColorRefFromColor([attributes objectForKey:kTUIBackgroundFillColorAttributeName]); UIEdgeInsets fillPadding = [[attributes objectForKey:kTUIBackgroundFillPaddingAttributeName] UIEdgeInsetsValue]; CGFloat cornerRadius = [[attributes objectForKey:kTUIBackgroundCornerRadiusAttributeName] floatValue]; CGFloat lineWidth = [[attributes objectForKey:kTUIBackgroundLineWidthAttributeName] floatValue]; if (strokeColor || fillColor) { CGRect runBounds = CGRectZero; CGFloat runAscent = 0.0f; CGFloat runDescent = 0.0f; runBounds.size.width = (CGFloat)CTRunGetTypographicBounds((__bridge CTRunRef)glyphRun, CFRangeMake(0, 0), &runAscent, &runDescent, NULL) + fillPadding.left + fillPadding.right; runBounds.size.height = runAscent + runDescent + fillPadding.top + fillPadding.bottom; CGFloat xOffset = 0.0f; CFRange glyphRange = CTRunGetStringRange((__bridge CTRunRef)glyphRun); switch (CTRunGetStatus((__bridge CTRunRef)glyphRun)) { case kCTRunStatusRightToLeft: xOffset = CTLineGetOffsetForStringIndex((__bridge CTLineRef)line, glyphRange.location + glyphRange.length, NULL); break; default: xOffset = CTLineGetOffsetForStringIndex((__bridge CTLineRef)line, glyphRange.location, NULL); break; } runBounds.origin.x = origins[lineIndex].x + rect.origin.x + xOffset - fillPadding.left - rect.origin.x; runBounds.origin.y = origins[lineIndex].y + rect.origin.y - fillPadding.bottom - rect.origin.y; runBounds.origin.y -= runDescent; // Don't draw higlightedLinkBackground too far to the right if (CGRectGetWidth(runBounds) > width) { runBounds.size.width = width; } CGPathRef path = [[UIBezierPath bezierPathWithRoundedRect:CGRectInset(UIEdgeInsetsInsetRect(runBounds, self.linkBackgroundEdgeInset), lineWidth, lineWidth) cornerRadius:cornerRadius] CGPath]; CGContextSetLineJoin(c, kCGLineJoinRound); if (fillColor) { CGContextSetFillColorWithColor(c, fillColor); CGContextAddPath(c, path); CGContextFillPath(c); } if (strokeColor) { CGContextSetStrokeColorWithColor(c, strokeColor); CGContextAddPath(c, path); CGContextStrokePath(c); } } } lineIndex++; } } - (void)drawStrike:(CTFrameRef)frame inRect:(__unused CGRect)rect context:(CGContextRef)c { NSArray *lines = (__bridge NSArray *)CTFrameGetLines(frame); CGPoint origins[[lines count]]; CTFrameGetLineOrigins(frame, CFRangeMake(0, 0), origins); CFIndex lineIndex = 0; for (id line in lines) { CGFloat ascent = 0.0f, descent = 0.0f, leading = 0.0f; CGFloat width = (CGFloat)CTLineGetTypographicBounds((__bridge CTLineRef)line, &ascent, &descent, &leading); for (id glyphRun in (__bridge NSArray *)CTLineGetGlyphRuns((__bridge CTLineRef)line)) { NSDictionary *attributes = (__bridge NSDictionary *)CTRunGetAttributes((__bridge CTRunRef)glyphRun); BOOL strikeOut = [[attributes objectForKey:kTUIStrikeOutAttributeName] boolValue]; NSInteger superscriptStyle = [[attributes objectForKey:(id)kCTSuperscriptAttributeName] integerValue]; if (strikeOut) { CGRect runBounds = CGRectZero; CGFloat runAscent = 0.0f; CGFloat runDescent = 0.0f; runBounds.size.width = (CGFloat)CTRunGetTypographicBounds((__bridge CTRunRef)glyphRun, CFRangeMake(0, 0), &runAscent, &runDescent, NULL); runBounds.size.height = runAscent + runDescent; CGFloat xOffset = 0.0f; CFRange glyphRange = CTRunGetStringRange((__bridge CTRunRef)glyphRun); switch (CTRunGetStatus((__bridge CTRunRef)glyphRun)) { case kCTRunStatusRightToLeft: xOffset = CTLineGetOffsetForStringIndex((__bridge CTLineRef)line, glyphRange.location + glyphRange.length, NULL); break; default: xOffset = CTLineGetOffsetForStringIndex((__bridge CTLineRef)line, glyphRange.location, NULL); break; } runBounds.origin.x = origins[lineIndex].x + xOffset; runBounds.origin.y = origins[lineIndex].y; runBounds.origin.y -= runDescent; // Don't draw strikeout too far to the right if (CGRectGetWidth(runBounds) > width) { runBounds.size.width = width; } switch (superscriptStyle) { case 1: runBounds.origin.y -= runAscent * 0.47f; break; case -1: runBounds.origin.y += runAscent * 0.25f; break; default: break; } // Use text color, or default to black id color = [attributes objectForKey:(id)kCTForegroundColorAttributeName]; if (color) { CGContextSetStrokeColorWithColor(c, formatCGColorRefFromColor(color)); } else { CGContextSetGrayStrokeColor(c, 0.0f, 1.0); } CTFontRef font = CTFontCreateWithName((__bridge CFStringRef)self.font.fontName, self.font.pointSize, NULL); CGContextSetLineWidth(c, CTFontGetUnderlineThickness(font)); CFRelease(font); CGFloat y = formatCGFloatRound(runBounds.origin.y + runBounds.size.height / 2.0f); CGContextMoveToPoint(c, runBounds.origin.x, y); CGContextAddLineToPoint(c, runBounds.origin.x + runBounds.size.width, y); CGContextStrokePath(c); } } lineIndex++; } } #pragma mark - TUIAttributedLabel - (void)setText:(id)text { NSParameterAssert(!text || [text isKindOfClass:[NSAttributedString class]] || [text isKindOfClass:[NSString class]]); if ([text isKindOfClass:[NSString class]]) { [self setText:text afterInheritingLabelAttributesAndConfiguringWithBlock:nil]; return; } self.attributedText = text; self.activeLink = nil; self.linkModels = [NSArray array]; if (text && self.attributedText && self.enabledTextCheckingTypes) { __weak __typeof(self) weakSelf = self; dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ __strong __typeof(weakSelf) strongSelf = weakSelf; NSDataDetector *dataDetector = strongSelf.dataDetector; if (dataDetector && [dataDetector respondsToSelector:@selector(matchesInString:options:range:)]) { NSArray *results = [dataDetector matchesInString:[(NSAttributedString *)text string] options:0 range:NSMakeRange(0, [(NSAttributedString *)text length])]; if ([results count] > 0) { dispatch_async(dispatch_get_main_queue(), ^{ if ([[strongSelf.attributedText string] isEqualToString:[(NSAttributedString *)text string]]) { [strongSelf addLinksWithTextCheckingResults:results attributes:strongSelf.linkAttributes]; } }); } } }); } [self.attributedText enumerateAttribute:NSLinkAttributeName inRange:NSMakeRange(0, self.attributedText.length) options:0 usingBlock:^(id value, __unused NSRange range, __unused BOOL *stop) { if (value) { NSURL *URL = [value isKindOfClass:[NSString class]] ? [NSURL URLWithString:value] : value; [self addLinkToURL:URL withRange:range]; } }]; } - (void)setText:(id)text afterInheritingLabelAttributesAndConfiguringWithBlock:(NSMutableAttributedString * (^)(NSMutableAttributedString *mutableAttributedString))block { NSMutableAttributedString *mutableAttributedString = nil; if ([text isKindOfClass:[NSString class]]) { mutableAttributedString = [[NSMutableAttributedString alloc] initWithString:text attributes:formatNSAttributedStringAttributesFromLabel(self)]; } else { mutableAttributedString = [[NSMutableAttributedString alloc] initWithAttributedString:text]; [mutableAttributedString addAttributes:formatNSAttributedStringAttributesFromLabel(self) range:NSMakeRange(0, [mutableAttributedString length])]; } if (block) { mutableAttributedString = block(mutableAttributedString); } [self setText:mutableAttributedString]; } - (void)setActiveLink:(TUIAttributedLabelLink *)activeLink { _activeLink = activeLink; NSDictionary *activeAttributes = activeLink.activeAttributes ?: self.activeLinkAttributes; if (_activeLink && activeAttributes.count > 0) { if (!self.inactiveAttributedText) { self.inactiveAttributedText = [self.attributedText copy]; } NSMutableAttributedString *mutableAttributedString = [self.inactiveAttributedText mutableCopy]; if (self.activeLink.result.range.length > 0 && NSLocationInRange(NSMaxRange(self.activeLink.result.range) - 1, NSMakeRange(0, [self.inactiveAttributedText length]))) { [mutableAttributedString addAttributes:activeAttributes range:self.activeLink.result.range]; } self.attributedText = mutableAttributedString; [self setNeedsDisplay]; [CATransaction flush]; } else if (self.inactiveAttributedText) { self.attributedText = self.inactiveAttributedText; self.inactiveAttributedText = nil; [self setNeedsDisplay]; } } - (void)setLinkAttributes:(NSDictionary *)linkAttributes { _linkAttributes = convertNSAttributedStringAttributesToCTAttributes(linkAttributes); } - (void)setActiveLinkAttributes:(NSDictionary *)activeLinkAttributes { _activeLinkAttributes = convertNSAttributedStringAttributesToCTAttributes(activeLinkAttributes); } - (void)setInactiveLinkAttributes:(NSDictionary *)inactiveLinkAttributes { _inactiveLinkAttributes = convertNSAttributedStringAttributesToCTAttributes(inactiveLinkAttributes); } #pragma mark - UILabel - (void)setHighlighted:(BOOL)highlighted { [super setHighlighted:highlighted]; [self setNeedsDisplay]; } // Fixes crash when loading from a UIStoryboard - (UIColor *)textColor { UIColor *color = [super textColor]; if (!color) { color = [UIColor blackColor]; } return color; } - (void)setTextColor:(UIColor *)textColor { UIColor *oldTextColor = self.textColor; [super setTextColor:textColor]; // Redraw to allow any ColorFromContext attributes a chance to update if (textColor != oldTextColor) { [self setNeedsFramesetter]; [self setNeedsDisplay]; } } - (CGRect)textRectForBounds:(CGRect)bounds limitedToNumberOfLines:(NSInteger)numberOfLines { bounds = UIEdgeInsetsInsetRect(bounds, self.textInsets); if (!self.attributedText) { return [super textRectForBounds:bounds limitedToNumberOfLines:numberOfLines]; } CGRect textRect = bounds; // Calculate height with a minimum of double the font pointSize, to ensure that CTFramesetterSuggestFrameSizeWithConstraints doesn't return CGSizeZero, as // it would if textRect height is insufficient. textRect.size.height = MAX(self.font.lineHeight * MAX(2, numberOfLines), bounds.size.height); // Adjust the text to be in the center vertically, if the text size is smaller than bounds CGSize textSize = CTFramesetterSuggestFrameSizeWithConstraints([self framesetter], CFRangeMake(0, (CFIndex)[self.attributedText length]), NULL, textRect.size, NULL); textSize = CGSizeMake(formatCGFloatCeil(textSize.width), formatCGFloatCeil(textSize.height)); // Fix for iOS 4, CTFramesetterSuggestFrameSizeWithConstraints sometimes returns fractional sizes if (textSize.height < bounds.size.height) { CGFloat yOffset = 0.0f; switch (self.verticalAlignment) { case TUIAttributedLabelVerticalAlignmentCenter: yOffset = formatCGFloatFloor((bounds.size.height - textSize.height) / 2.0f); break; case TUIAttributedLabelVerticalAlignmentBottom: yOffset = bounds.size.height - textSize.height; break; case TUIAttributedLabelVerticalAlignmentTop: default: break; } textRect.origin.y += yOffset; } return textRect; } - (void)drawTextInRect:(CGRect)rect { CGRect insetRect = UIEdgeInsetsInsetRect(rect, self.textInsets); if (!self.attributedText) { [super drawTextInRect:insetRect]; return; } NSAttributedString *originalAttributedText = nil; // Adjust the font size to fit width, if necessarry if (self.adjustsFontSizeToFitWidth && self.numberOfLines > 0) { // Framesetter could still be working with a resized version of the text; // need to reset so we start from the original font size. // See #393. [self setNeedsFramesetter]; [self setNeedsDisplay]; if ([self respondsToSelector:@selector(invalidateIntrinsicContentSize)]) { [self invalidateIntrinsicContentSize]; } // Use infinite width to find the max width, which will be compared to availableWidth if needed. CGSize maxSize = (self.numberOfLines > 1) ? CGSizeMake(TUIFLOAT_MAX, TUIFLOAT_MAX) : CGSizeZero; CGFloat textWidth = [self sizeThatFits:maxSize].width; CGFloat availableWidth = self.frame.size.width * self.numberOfLines; if (self.numberOfLines > 1 && self.lineBreakMode == TUILineBreakByWordWrapping) { textWidth *= kTUILineBreakWordWrapTextWidthScalingFactor; } if (textWidth > availableWidth && textWidth > 0.0f) { originalAttributedText = [self.attributedText copy]; CGFloat scaleFactor = availableWidth / textWidth; if ([self respondsToSelector:@selector(minimumScaleFactor)] && self.minimumScaleFactor > scaleFactor) { scaleFactor = self.minimumScaleFactor; } self.attributedText = formatNSAttributedStringByScalingFontSize(self.attributedText, scaleFactor); } } CGContextRef c = UIGraphicsGetCurrentContext(); CGContextSaveGState(c); { 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, insetRect.size.height); CGContextScaleCTM(c, 1.0f, -1.0f); CFRange textRange = CFRangeMake(0, (CFIndex)[self.attributedText length]); // First, get the text rect (which takes vertical centering into account) CGRect textRect = [self textRectForBounds:rect limitedToNumberOfLines:self.numberOfLines]; // CoreText draws its text aligned to the bottom, so we move the CTM here to take our vertical offsets into account CGContextTranslateCTM(c, insetRect.origin.x, insetRect.size.height - textRect.origin.y - textRect.size.height); // 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]); } else if (self.highlightedShadowColor) { CGContextSetShadowWithColor(c, self.highlightedShadowOffset, self.highlightedShadowRadius, [self.highlightedShadowColor CGColor]); } // Finally, draw the text or highlighted text itself (on top of the shadow, if there is one) if (self.highlightedTextColor && self.highlighted) { NSMutableAttributedString *highlightAttributedString = [self.renderedAttributedText mutableCopy]; [highlightAttributedString addAttribute:(__bridge NSString *)kCTForegroundColorAttributeName value:(id)[self.highlightedTextColor CGColor] range:NSMakeRange(0, highlightAttributedString.length)]; if (![self highlightFramesetter]) { CTFramesetterRef highlightFramesetter = CTFramesetterCreateWithAttributedString((__bridge CFAttributedStringRef)highlightAttributedString); [self setHighlightFramesetter:highlightFramesetter]; CFRelease(highlightFramesetter); } [self drawFramesetter:[self highlightFramesetter] attributedString:highlightAttributedString textRange:textRange inRect:textRect context:c]; } else { [self drawFramesetter:[self framesetter] attributedString:self.renderedAttributedText textRange:textRange inRect:textRect context:c]; } // If we adjusted the font size, set it back to its original size if (originalAttributedText) { // Use ivar directly to avoid clearing out framesetter and renderedAttributedText _attributedText = originalAttributedText; } } CGContextRestoreGState(c); } #pragma mark - UIAccessibilityElement - (BOOL)isAccessibilityElement { return NO; } - (NSInteger)accessibilityElementCount { return (NSInteger)[[self accessibilityElements] count]; } - (id)accessibilityElementAtIndex:(NSInteger)index { return [[self accessibilityElements] objectAtIndex:(NSUInteger)index]; } - (NSInteger)indexOfAccessibilityElement:(id)element { return (NSInteger)[[self accessibilityElements] indexOfObject:element]; } - (NSArray *)accessibilityElements { if (!_accessibilityElements) { @synchronized(self) { NSMutableArray *mutableAccessibilityItems = [NSMutableArray array]; for (TUIAttributedLabelLink *link in self.linkModels) { if (link.result.range.location == NSNotFound) { continue; } NSString *sourceText = [self.text isKindOfClass:[NSString class]] ? self.text : [(NSAttributedString *)self.text string]; NSString *accessibilityLabel = [sourceText substringWithRange:link.result.range]; NSString *accessibilityValue = link.accessibilityValue; if (accessibilityLabel) { TUIAccessibilityElement *linkElement = [[TUIAccessibilityElement alloc] initWithAccessibilityContainer:self]; linkElement.accessibilityTraits = UIAccessibilityTraitLink; linkElement.boundingRect = [self boundingRectForCharacterRange:link.result.range]; linkElement.superview = self; linkElement.accessibilityLabel = accessibilityLabel; if (![accessibilityLabel isEqualToString:accessibilityValue]) { linkElement.accessibilityValue = accessibilityValue; } [mutableAccessibilityItems addObject:linkElement]; } } TUIAccessibilityElement *baseElement = [[TUIAccessibilityElement alloc] initWithAccessibilityContainer:self]; baseElement.accessibilityLabel = [super accessibilityLabel]; baseElement.accessibilityHint = [super accessibilityHint]; baseElement.accessibilityValue = [super accessibilityValue]; baseElement.boundingRect = self.bounds; baseElement.superview = self; baseElement.accessibilityTraits = [super accessibilityTraits]; [mutableAccessibilityItems addObject:baseElement]; self.accessibilityElements = [NSArray arrayWithArray:mutableAccessibilityItems]; } } return _accessibilityElements; } #pragma mark - UIView - (CGSize)sizeThatFits:(CGSize)size { if (!self.attributedText) { return [super sizeThatFits:size]; } else { NSAttributedString *string = [self renderedAttributedText]; CGSize labelSize = formatCTFramesetterSuggestFrameSizeForAttributedStringWithConstraints([self framesetter], string, size, (NSUInteger)self.numberOfLines); labelSize.width += self.textInsets.left + self.textInsets.right; labelSize.height += self.textInsets.top + self.textInsets.bottom; return labelSize; } } - (CGSize)intrinsicContentSize { // There's an implicit width from the original UILabel implementation return [self sizeThatFits:[super intrinsicContentSize]]; } - (void)tintColorDidChange { if (!self.inactiveLinkAttributes || [self.inactiveLinkAttributes count] == 0) { return; } BOOL isInactive = (self.tintAdjustmentMode == UIViewTintAdjustmentModeDimmed); NSMutableAttributedString *mutableAttributedString = [self.attributedText mutableCopy]; for (TUIAttributedLabelLink *link in self.linkModels) { NSDictionary *attributesToRemove = isInactive ? link.attributes : link.inactiveAttributes; NSDictionary *attributesToAdd = isInactive ? link.inactiveAttributes : link.attributes; [attributesToRemove enumerateKeysAndObjectsUsingBlock:^(NSString *name, __unused id value, __unused BOOL *stop) { if (NSMaxRange(link.result.range) <= mutableAttributedString.length) { [mutableAttributedString removeAttribute:name range:link.result.range]; } }]; if (attributesToAdd) { if (NSMaxRange(link.result.range) <= mutableAttributedString.length) { [mutableAttributedString addAttributes:attributesToAdd range:link.result.range]; } } } self.attributedText = mutableAttributedString; [self setNeedsDisplay]; } - (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event { if (![self linkAtPoint:point] || !self.userInteractionEnabled || self.hidden || self.alpha < 0.01) { return [super hitTest:point withEvent:event]; } return self; } #pragma mark - UIResponder - (BOOL)canBecomeFirstResponder { return YES; } - (BOOL)canPerformAction:(SEL)action withSender:(__unused id)sender { #if !TARGET_OS_TV return (action == @selector(copy:)); #else return NO; #endif } - (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event { UITouch *touch = [touches anyObject]; self.activeLink = [self linkAtPoint:[touch locationInView:self]]; if (!self.activeLink) { [super touchesBegan:touches withEvent:event]; } } - (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event { if (self.activeLink) { UITouch *touch = [touches anyObject]; if (self.activeLink != [self linkAtPoint:[touch locationInView:self]]) { self.activeLink = nil; } } else { [super touchesMoved:touches withEvent:event]; } } - (void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event { if (self.activeLink) { if (self.activeLink.linkTapBlock) { self.activeLink.linkTapBlock(self, self.activeLink); self.activeLink = nil; return; } NSTextCheckingResult *result = self.activeLink.result; self.activeLink = nil; switch (result.resultType) { case NSTextCheckingTypeLink: if ([self.delegate respondsToSelector:@selector(attributedLabel:didSelectLinkWithURL:)]) { [self.delegate attributedLabel:self didSelectLinkWithURL:result.URL]; return; } break; case NSTextCheckingTypeAddress: if ([self.delegate respondsToSelector:@selector(attributedLabel:didSelectLinkWithAddress:)]) { [self.delegate attributedLabel:self didSelectLinkWithAddress:result.addressComponents]; return; } break; case NSTextCheckingTypePhoneNumber: if ([self.delegate respondsToSelector:@selector(attributedLabel:didSelectLinkWithPhoneNumber:)]) { [self.delegate attributedLabel:self didSelectLinkWithPhoneNumber:result.phoneNumber]; return; } 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]; return; } else if ([self.delegate respondsToSelector:@selector(attributedLabel:didSelectLinkWithDate:)]) { [self.delegate attributedLabel:self didSelectLinkWithDate:result.date]; return; } break; case NSTextCheckingTypeTransitInformation: if ([self.delegate respondsToSelector:@selector(attributedLabel:didSelectLinkWithTransitInformation:)]) { [self.delegate attributedLabel:self didSelectLinkWithTransitInformation:result.components]; return; } default: break; } // Fallback to `attributedLabel:didSelectLinkWithTextCheckingResult:` if no other delegate method matched. if ([self.delegate respondsToSelector:@selector(attributedLabel:didSelectLinkWithTextCheckingResult:)]) { [self.delegate attributedLabel:self didSelectLinkWithTextCheckingResult:result]; } } else { [super touchesEnded:touches withEvent:event]; } } - (void)touchesCancelled:(NSSet *)touches withEvent:(UIEvent *)event { if (self.activeLink) { self.activeLink = nil; } else { [super touchesCancelled:touches withEvent:event]; } } #pragma mark - UIGestureRecognizerDelegate - (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldReceiveTouch:(UITouch *)touch { return [self containslinkAtPoint:[touch locationInView:self]]; } #pragma mark - UILongPressGestureRecognizer - (void)longPressGestureDidFire:(UILongPressGestureRecognizer *)sender { switch (sender.state) { case UIGestureRecognizerStateBegan: { CGPoint touchPoint = [sender locationInView:self]; TUIAttributedLabelLink *link = [self linkAtPoint:touchPoint]; if (link) { if (link.linkLongPressBlock) { link.linkLongPressBlock(self, link); return; } NSTextCheckingResult *result = link.result; if (!result) { return; } switch (result.resultType) { case NSTextCheckingTypeLink: if ([self.delegate respondsToSelector:@selector(attributedLabel:didLongPressLinkWithURL:atPoint:)]) { [self.delegate attributedLabel:self didLongPressLinkWithURL:result.URL atPoint:touchPoint]; return; } break; case NSTextCheckingTypeAddress: if ([self.delegate respondsToSelector:@selector(attributedLabel:didLongPressLinkWithAddress:atPoint:)]) { [self.delegate attributedLabel:self didLongPressLinkWithAddress:result.addressComponents atPoint:touchPoint]; return; } break; case NSTextCheckingTypePhoneNumber: if ([self.delegate respondsToSelector:@selector(attributedLabel:didLongPressLinkWithPhoneNumber:atPoint:)]) { [self.delegate attributedLabel:self didLongPressLinkWithPhoneNumber:result.phoneNumber atPoint:touchPoint]; return; } break; case NSTextCheckingTypeDate: if (result.timeZone && [self.delegate respondsToSelector:@selector(attributedLabel: didLongPressLinkWithDate:timeZone:duration:atPoint:)]) { [self.delegate attributedLabel:self didLongPressLinkWithDate:result.date timeZone:result.timeZone duration:result.duration atPoint:touchPoint]; return; } else if ([self.delegate respondsToSelector:@selector(attributedLabel:didLongPressLinkWithDate:atPoint:)]) { [self.delegate attributedLabel:self didLongPressLinkWithDate:result.date atPoint:touchPoint]; return; } break; case NSTextCheckingTypeTransitInformation: if ([self.delegate respondsToSelector:@selector(attributedLabel:didLongPressLinkWithTransitInformation:atPoint:)]) { [self.delegate attributedLabel:self didLongPressLinkWithTransitInformation:result.components atPoint:touchPoint]; return; } default: break; } // Fallback to `attributedLabel:didLongPressLinkWithTextCheckingResult:atPoint:` if no other delegate method matched. if ([self.delegate respondsToSelector:@selector(attributedLabel:didLongPressLinkWithTextCheckingResult:atPoint:)]) { [self.delegate attributedLabel:self didLongPressLinkWithTextCheckingResult:result atPoint:touchPoint]; } } break; } default: break; } } #if !TARGET_OS_TV #pragma mark - UIResponderStandardEditActions - (void)copy:(__unused id)sender { [[UIPasteboard generalPasteboard] setString:self.text]; } #endif #pragma mark - NSCoding - (void)encodeWithCoder:(NSCoder *)coder { [super encodeWithCoder:coder]; [coder encodeObject:@(self.enabledTextCheckingTypes) forKey:NSStringFromSelector(@selector(enabledTextCheckingTypes))]; [coder encodeObject:self.linkModels forKey:NSStringFromSelector(@selector(linkModels))]; if ([NSMutableParagraphStyle class]) { [coder encodeObject:self.linkAttributes forKey:NSStringFromSelector(@selector(linkAttributes))]; [coder encodeObject:self.activeLinkAttributes forKey:NSStringFromSelector(@selector(activeLinkAttributes))]; [coder encodeObject:self.inactiveLinkAttributes forKey:NSStringFromSelector(@selector(inactiveLinkAttributes))]; } [coder encodeObject:@(self.shadowRadius) forKey:NSStringFromSelector(@selector(shadowRadius))]; [coder encodeObject:@(self.highlightedShadowRadius) forKey:NSStringFromSelector(@selector(highlightedShadowRadius))]; [coder encodeCGSize:self.highlightedShadowOffset forKey:NSStringFromSelector(@selector(highlightedShadowOffset))]; [coder encodeObject:self.highlightedShadowColor forKey:NSStringFromSelector(@selector(highlightedShadowColor))]; [coder encodeObject:@(self.kern) forKey:NSStringFromSelector(@selector(kern))]; [coder encodeObject:@(self.firstLineIndent) forKey:NSStringFromSelector(@selector(firstLineIndent))]; [coder encodeObject:@(self.lineSpacing) forKey:NSStringFromSelector(@selector(lineSpacing))]; [coder encodeObject:@(self.lineHeightMultiple) forKey:NSStringFromSelector(@selector(lineHeightMultiple))]; [coder encodeUIEdgeInsets:self.textInsets forKey:NSStringFromSelector(@selector(textInsets))]; [coder encodeInteger:self.verticalAlignment forKey:NSStringFromSelector(@selector(verticalAlignment))]; [coder encodeObject:self.attributedTruncationToken forKey:NSStringFromSelector(@selector(attributedTruncationToken))]; [coder encodeObject:NSStringFromUIEdgeInsets(self.linkBackgroundEdgeInset) forKey:NSStringFromSelector(@selector(linkBackgroundEdgeInset))]; [coder encodeObject:self.attributedText forKey:NSStringFromSelector(@selector(attributedText))]; [coder encodeObject:self.text forKey:NSStringFromSelector(@selector(text))]; } - (id)initWithCoder:(NSCoder *)coder { self = [super initWithCoder:coder]; if (!self) { return nil; } [self commonInit]; if ([coder containsValueForKey:NSStringFromSelector(@selector(enabledTextCheckingTypes))]) { self.enabledTextCheckingTypes = [[coder decodeObjectForKey:NSStringFromSelector(@selector(enabledTextCheckingTypes))] unsignedLongLongValue]; } if ([NSMutableParagraphStyle class]) { if ([coder containsValueForKey:NSStringFromSelector(@selector(linkAttributes))]) { self.linkAttributes = [coder decodeObjectForKey:NSStringFromSelector(@selector(linkAttributes))]; } if ([coder containsValueForKey:NSStringFromSelector(@selector(activeLinkAttributes))]) { self.activeLinkAttributes = [coder decodeObjectForKey:NSStringFromSelector(@selector(activeLinkAttributes))]; } if ([coder containsValueForKey:NSStringFromSelector(@selector(inactiveLinkAttributes))]) { self.inactiveLinkAttributes = [coder decodeObjectForKey:NSStringFromSelector(@selector(inactiveLinkAttributes))]; } } if ([coder containsValueForKey:NSStringFromSelector(@selector(links))]) { NSArray *oldLinks = [coder decodeObjectForKey:NSStringFromSelector(@selector(links))]; [self addLinksWithTextCheckingResults:oldLinks attributes:nil]; } if ([coder containsValueForKey:NSStringFromSelector(@selector(linkModels))]) { self.linkModels = [coder decodeObjectForKey:NSStringFromSelector(@selector(linkModels))]; } if ([coder containsValueForKey:NSStringFromSelector(@selector(shadowRadius))]) { self.shadowRadius = [[coder decodeObjectForKey:NSStringFromSelector(@selector(shadowRadius))] floatValue]; } if ([coder containsValueForKey:NSStringFromSelector(@selector(highlightedShadowRadius))]) { self.highlightedShadowRadius = [[coder decodeObjectForKey:NSStringFromSelector(@selector(highlightedShadowRadius))] floatValue]; } if ([coder containsValueForKey:NSStringFromSelector(@selector(highlightedShadowOffset))]) { self.highlightedShadowOffset = [coder decodeCGSizeForKey:NSStringFromSelector(@selector(highlightedShadowOffset))]; } if ([coder containsValueForKey:NSStringFromSelector(@selector(highlightedShadowColor))]) { self.highlightedShadowColor = [coder decodeObjectForKey:NSStringFromSelector(@selector(highlightedShadowColor))]; } if ([coder containsValueForKey:NSStringFromSelector(@selector(kern))]) { self.kern = [[coder decodeObjectForKey:NSStringFromSelector(@selector(kern))] floatValue]; } if ([coder containsValueForKey:NSStringFromSelector(@selector(firstLineIndent))]) { self.firstLineIndent = [[coder decodeObjectForKey:NSStringFromSelector(@selector(firstLineIndent))] floatValue]; } if ([coder containsValueForKey:NSStringFromSelector(@selector(lineSpacing))]) { self.lineSpacing = [[coder decodeObjectForKey:NSStringFromSelector(@selector(lineSpacing))] floatValue]; } if ([coder containsValueForKey:NSStringFromSelector(@selector(minimumLineHeight))]) { self.minimumLineHeight = [[coder decodeObjectForKey:NSStringFromSelector(@selector(minimumLineHeight))] floatValue]; } if ([coder containsValueForKey:NSStringFromSelector(@selector(maximumLineHeight))]) { self.maximumLineHeight = [[coder decodeObjectForKey:NSStringFromSelector(@selector(maximumLineHeight))] floatValue]; } if ([coder containsValueForKey:NSStringFromSelector(@selector(lineHeightMultiple))]) { self.lineHeightMultiple = [[coder decodeObjectForKey:NSStringFromSelector(@selector(lineHeightMultiple))] floatValue]; } if ([coder containsValueForKey:NSStringFromSelector(@selector(textInsets))]) { self.textInsets = [coder decodeUIEdgeInsetsForKey:NSStringFromSelector(@selector(textInsets))]; } if ([coder containsValueForKey:NSStringFromSelector(@selector(verticalAlignment))]) { self.verticalAlignment = [coder decodeIntegerForKey:NSStringFromSelector(@selector(verticalAlignment))]; } if ([coder containsValueForKey:NSStringFromSelector(@selector(attributedTruncationToken))]) { self.attributedTruncationToken = [coder decodeObjectForKey:NSStringFromSelector(@selector(attributedTruncationToken))]; } if ([coder containsValueForKey:NSStringFromSelector(@selector(linkBackgroundEdgeInset))]) { self.linkBackgroundEdgeInset = UIEdgeInsetsFromString([coder decodeObjectForKey:NSStringFromSelector(@selector(linkBackgroundEdgeInset))]); } if ([coder containsValueForKey:NSStringFromSelector(@selector(attributedText))]) { self.attributedText = [coder decodeObjectForKey:NSStringFromSelector(@selector(attributedText))]; } else { self.text = super.text; } return self; } @end #pragma mark - TUIAttributedLabelLink @implementation TUIAttributedLabelLink - (instancetype)initWithAttributes:(NSDictionary *)attributes activeAttributes:(NSDictionary *)activeAttributes inactiveAttributes:(NSDictionary *)inactiveAttributes textCheckingResult:(NSTextCheckingResult *)result { if ((self = [super init])) { _result = result; _attributes = [attributes copy]; _activeAttributes = [activeAttributes copy]; _inactiveAttributes = [inactiveAttributes copy]; } return self; } - (instancetype)initWithAttributesFromLabel:(TUIAttributedLabel *)label textCheckingResult:(NSTextCheckingResult *)result { return [self initWithAttributes:label.linkAttributes activeAttributes:label.activeLinkAttributes inactiveAttributes:label.inactiveLinkAttributes textCheckingResult:result]; } #pragma mark - Accessibility - (NSString *)accessibilityValue { if ([_accessibilityValue length] == 0) { switch (self.result.resultType) { case NSTextCheckingTypeLink: _accessibilityValue = self.result.URL.absoluteString; break; case NSTextCheckingTypePhoneNumber: _accessibilityValue = self.result.phoneNumber; break; case NSTextCheckingTypeDate: _accessibilityValue = [NSDateFormatter localizedStringFromDate:self.result.date dateStyle:NSDateFormatterLongStyle timeStyle:NSDateFormatterLongStyle]; break; default: break; } } return _accessibilityValue; } #pragma mark - NSCoding - (void)encodeWithCoder:(NSCoder *)aCoder { [aCoder encodeObject:self.result forKey:NSStringFromSelector(@selector(result))]; [aCoder encodeObject:self.attributes forKey:NSStringFromSelector(@selector(attributes))]; [aCoder encodeObject:self.activeAttributes forKey:NSStringFromSelector(@selector(activeAttributes))]; [aCoder encodeObject:self.inactiveAttributes forKey:NSStringFromSelector(@selector(inactiveAttributes))]; [aCoder encodeObject:self.accessibilityValue forKey:NSStringFromSelector(@selector(accessibilityValue))]; } - (id)initWithCoder:(NSCoder *)aDecoder { if ((self = [super init])) { _result = [aDecoder decodeObjectForKey:NSStringFromSelector(@selector(result))]; _attributes = [aDecoder decodeObjectForKey:NSStringFromSelector(@selector(attributes))]; _activeAttributes = [aDecoder decodeObjectForKey:NSStringFromSelector(@selector(activeAttributes))]; _inactiveAttributes = [aDecoder decodeObjectForKey:NSStringFromSelector(@selector(inactiveAttributes))]; self.accessibilityValue = [aDecoder decodeObjectForKey:NSStringFromSelector(@selector(accessibilityValue))]; } return self; } @end #pragma mark - static inline CGColorRef formatCGColorRefFromColor(id color) { return [color isKindOfClass:[UIColor class]] ? [color CGColor] : (__bridge CGColorRef)color; } static inline CTFontRef formatCTFontRefFromUIFont(UIFont *font) { CTFontRef ctfont = CTFontCreateWithName((__bridge CFStringRef)font.fontName, font.pointSize, NULL); return CFAutorelease(ctfont); } static inline NSDictionary *convertNSAttributedStringAttributesToCTAttributes(NSDictionary *attributes) { if (!attributes) return nil; NSMutableDictionary *mutableAttributes = [NSMutableDictionary dictionary]; NSDictionary *convertMap = @{ NSFontAttributeName : (NSString *)kCTFontAttributeName, NSBackgroundColorAttributeName : (NSString *)kTUIBackgroundFillColorAttributeName, NSForegroundColorAttributeName : (NSString *)kCTForegroundColorAttributeName, NSUnderlineColorAttributeName : (NSString *)kCTUnderlineColorAttributeName, NSUnderlineStyleAttributeName : (NSString *)kCTUnderlineStyleAttributeName, NSStrokeWidthAttributeName : (NSString *)kCTStrokeWidthAttributeName, NSStrokeColorAttributeName : (NSString *)kCTStrokeWidthAttributeName, NSKernAttributeName : (NSString *)kCTKernAttributeName, NSLigatureAttributeName : (NSString *)kCTLigatureAttributeName }; [attributes enumerateKeysAndObjectsUsingBlock:^(id key, id value, BOOL *stop) { key = [convertMap objectForKey:key] ?: key; if (![NSMutableParagraphStyle class]) { if ([value isKindOfClass:[UIFont class]]) { value = (__bridge id)formatCTFontRefFromUIFont(value); } else if ([value isKindOfClass:[UIColor class]]) { value = (__bridge id)((UIColor *)value).CGColor; } } [mutableAttributes setObject:value forKey:key]; }]; return [NSDictionary dictionaryWithDictionary:mutableAttributes]; }