/* Copyright (c) 2014-present, Facebook, Inc. * All rights reserved. * * This source code is licensed under the BSD-style license found in the * LICENSE file in the root directory of this source tree. An additional grant * of patent rights can be found in the PATENTS file in the same directory. */ #import "ASTextNodeRenderer.h" #import #import "ASAssert.h" #import "ASTextNodeTextKitHelpers.h" #import "ASTextNodeWordKerner.h" #import "ASThread.h" static const CGFloat ASTextNodeRendererGlyphTouchHitSlop = 5.0; static const CGFloat ASTextNodeRendererTextCapHeightPadding = 1.3; @interface ASTextNodeRenderer () @end @implementation ASTextNodeRenderer { CGSize _constrainedSize; CGSize _calculatedSize; NSAttributedString *_attributedString; NSAttributedString *_truncationString; NSLineBreakMode _truncationMode; NSRange _truncationCharacterRange; NSRange _visibleRange; ASTextNodeWordKerner *_wordKerner; ASDN::RecursiveMutex _textKitLock; NSLayoutManager *_layoutManager; NSTextStorage *_textStorage; NSTextContainer *_textContainer; } #pragma mark - Initialization - (instancetype)initWithAttributedString:(NSAttributedString *)attributedString truncationString:(NSAttributedString *)truncationString truncationMode:(NSLineBreakMode)truncationMode constrainedSize:(CGSize)constrainedSize { if (self = [super init]) { _attributedString = attributedString; _truncationString = truncationString; _truncationMode = truncationMode; _truncationCharacterRange = NSMakeRange(NSNotFound, truncationString.length); _constrainedSize = constrainedSize; } return self; } /* * Use this method to lazily construct the TextKit components. */ - (void)_initializeTextKitComponentsIfNeeded { ASDN::MutexLocker l(_textKitLock); if (_layoutManager == nil) { [self _initializeTextKitComponentsWithAttributedString:_attributedString]; } } - (void)_initializeTextKitComponentsWithAttributedString:(NSAttributedString *)attributedString { ASDN::MutexLocker l(_textKitLock); // Create the TextKit component stack with our default configuration. _textStorage = (attributedString ? [[NSTextStorage alloc] initWithAttributedString:attributedString] : [[NSTextStorage alloc] init]); _layoutManager = [[NSLayoutManager alloc] init]; _layoutManager.usesFontLeading = NO; _wordKerner = [[ASTextNodeWordKerner alloc] init]; _layoutManager.delegate = _wordKerner; [_textStorage addLayoutManager:_layoutManager]; _textContainer = [[NSTextContainer alloc] initWithSize:_constrainedSize]; // We want the text laid out up to the very edges of the container. _textContainer.lineFragmentPadding = 0; // Translate our truncation mode into a line break mode on the container _textContainer.lineBreakMode = _truncationMode; [_layoutManager addTextContainer:_textContainer]; [self _invalidateLayout]; } #pragma mark - Layout Initialization - (void)_invalidateLayout { ASDN::MutexLocker l(_textKitLock); // Force a layout, which means we have to recompute our truncation parameters NSInteger originalStringLength = _textStorage.string.length; [self _calculateSize]; NSRange visibleGlyphRange = [_layoutManager glyphRangeForTextContainer:_textContainer]; _visibleRange = [_layoutManager characterRangeForGlyphRange:visibleGlyphRange actualGlyphRange:NULL]; // Check if text is truncated, and if so apply our truncation string if (_visibleRange.length < originalStringLength && _truncationString.length > 0) { NSInteger firstCharacterIndexToReplace = [self _calculateCharacterIndexBeforeTruncationMessage]; if (firstCharacterIndexToReplace == 0 || firstCharacterIndexToReplace == NSNotFound) { // Something went horribly wrong, short-circuit [self _calculateSize]; return; } // Update/truncate the visible range of text _visibleRange = NSMakeRange(0, firstCharacterIndexToReplace); NSRange truncationReplacementRange = NSMakeRange(firstCharacterIndexToReplace, _textStorage.length - firstCharacterIndexToReplace); // Replace the end of the visible message with the truncation string [_textStorage replaceCharactersInRange:truncationReplacementRange withAttributedString:_truncationString]; _truncationCharacterRange = NSMakeRange(firstCharacterIndexToReplace, _truncationString.length); // We must recompute the calculated size because we may have changed it in // changing the string [self _calculateSize]; } } #pragma mark - Sizing /* * Calculates the size of the text in the renderer based on the parameters * stored in the ivars of this class. * * This method can be expensive, so it is important that it not be called * frequently. It not only sizes the text, but it also configures the TextKit * components for drawing, and responding to all other queries made to this * class. */ - (void)_calculateSize { ASDN::MutexLocker l(_textKitLock); [self _initializeTextKitComponentsIfNeeded]; // Force glyph generation and layout, which may not have happened yet (and // isn't triggered by -usedRectForTextContainer:). [_layoutManager ensureLayoutForTextContainer:_textContainer]; CGRect constrainedRect = CGRect{CGPointZero, _constrainedSize}; CGRect boundingRect = [_layoutManager usedRectForTextContainer:_textContainer]; // TextKit often returns incorrect glyph bounding rects in the horizontal // direction, so we clip to our bounding rect to make sure our width // calculations aren't being offset by glyphs going beyond the constrained // rect. boundingRect = CGRectIntersection(boundingRect, (CGRect){.size = constrainedRect.size}); _calculatedSize = boundingRect.size; } - (CGSize)size { [self _initializeTextKitComponentsIfNeeded]; return _calculatedSize; } #pragma mark - Layout - (CGRect)trailingRect { ASDN::MutexLocker l(_textKitLock); [self _initializeTextKitComponentsIfNeeded]; // If have an empty string, then our whole bounds constitute trailing space. if ([_textStorage length] == 0) { return CGRectMake(0, 0, _calculatedSize.width, _calculatedSize.height); } // Take everything after our final character as trailing space. NSArray *finalRects = [self rectsForTextRange:NSMakeRange([_textStorage length] - 1, 1) measureOption:ASTextNodeRendererMeasureOptionLineHeight]; CGRect finalGlyphRect = [[finalRects lastObject] CGRectValue]; CGPoint origin = CGPointMake(CGRectGetMaxX(finalGlyphRect), CGRectGetMinY(finalGlyphRect)); CGSize size = CGSizeMake(_calculatedSize.width - origin.x, _calculatedSize.height - origin.y); return (CGRect){origin, size}; } - (CGRect)frameForTextRange:(NSRange)textRange { ASDN::MutexLocker l(_textKitLock); [self _initializeTextKitComponentsIfNeeded]; // Bail on invalid range. if (NSMaxRange(textRange) > [_textStorage length]) { ASDisplayNodeAssertNotNil(nil, @"Invalid range"); return CGRectZero; } // Force glyph generation and layout. [_layoutManager ensureLayoutForTextContainer:_textContainer]; NSRange glyphRange = [_layoutManager glyphRangeForCharacterRange:textRange actualCharacterRange:NULL]; CGRect textRect = [_layoutManager boundingRectForGlyphRange:glyphRange inTextContainer:_textContainer]; return textRect; } - (NSArray *)rectsForTextRange:(NSRange)textRange measureOption:(ASTextNodeRendererMeasureOption)measureOption { ASDN::MutexLocker l(_textKitLock); [self _initializeTextKitComponentsIfNeeded]; BOOL textRangeIsValid = (NSMaxRange(textRange) <= [_textStorage length]); ASDisplayNodeAssertTrue(textRangeIsValid); if (!textRangeIsValid) { return @[]; } // Used for block measure option __block CGRect firstRect = CGRectNull; __block CGRect lastRect = CGRectNull; __block CGRect blockRect = CGRectNull; NSMutableArray *textRects = [NSMutableArray array]; NSString *string = _textStorage.string; NSRange totalGlyphRange = [_layoutManager glyphRangeForCharacterRange:textRange actualCharacterRange:NULL]; [_layoutManager enumerateLineFragmentsForGlyphRange:totalGlyphRange usingBlock:^(CGRect rect, CGRect usedRect, NSTextContainer *textContainer, NSRange glyphRange, BOOL *stop) { CGRect lineRect = CGRectNull; // If we're empty, don't bother looping through glyphs, use the default. if (CGRectIsEmpty(usedRect)) { lineRect = usedRect; } else { // TextKit's bounding rect computations are just a touch off, so we actually // compose the rects by hand from the center of the given TextKit bounds and // imposing the font attributes returned by the glyph's font. NSRange lineGlyphRange = NSIntersectionRange(totalGlyphRange, glyphRange); for (NSUInteger i = lineGlyphRange.location; i < NSMaxRange(lineGlyphRange) && i < string.length; i++) { // We grab the properly sized rect for the glyph CGRect properGlyphRect = [self _rectForGlyphAtIndex:i measureOption:measureOption]; // Don't count empty glyphs towards our line rect. if (!CGRectIsEmpty(properGlyphRect)) { lineRect = CGRectIsNull(lineRect) ? properGlyphRect : CGRectUnion(lineRect, properGlyphRect); } } } if (!CGRectIsNull(lineRect)) { if (measureOption == ASTextNodeRendererMeasureOptionBlock) { // For the block measurement option we store the first & last rect as // special cases, then merge everything else into a single block rect if (CGRectIsNull(firstRect)) { // We don't have a firstRect, so we must be on the first line. firstRect = lineRect; } else if(CGRectIsNull(lastRect)) { // We don't have a lastRect, but we do have a firstRect, so we must // be on the second line. No need to merge in the blockRect just yet lastRect = lineRect; } else if(CGRectIsNull(blockRect)) { // We have both a first and last rect, so we must be on the third line // we don't have any blockRect to merge it into, so we just set it // directly. blockRect = lastRect; lastRect = lineRect; } else { // Everything is already set, so we just merge this line into the // block. blockRect = CGRectUnion(blockRect, lastRect); lastRect = lineRect; } } else { // If the block option isn't being used then each line is being treated // individually. [textRects addObject:[NSValue valueWithCGRect:lineRect]]; } } }]; if (measureOption == ASTextNodeRendererMeasureOptionBlock) { // Block measure option is handled differently with just 3 vars for the entire range. if (!CGRectIsNull(firstRect)) { if (!CGRectIsNull(blockRect)) { CGFloat rightEdge = MAX(CGRectGetMaxX(blockRect), CGRectGetMaxX(lastRect)); if (rightEdge > CGRectGetMaxX(firstRect)) { // Force the right side of the first rect to properly align with the // right side of the rightmost of the block and last rect firstRect.size.width += rightEdge - CGRectGetMaxX(firstRect); } // Force the left side of the block rect to properly align with the // left side of the leftmost of the first and last rect blockRect.origin.x = MIN(CGRectGetMinX(firstRect), CGRectGetMinX(lastRect)); // Force the right side of the block rect to properly align with the // right side of the rightmost of the first and last rect blockRect.size.width += MAX(CGRectGetMaxX(firstRect), CGRectGetMaxX(lastRect)) - CGRectGetMaxX(blockRect); } if (!CGRectIsNull(lastRect)) { // Force the left edge of the last rect to properly align with the // left side of the leftmost of the first and block rect, if necessary. CGFloat leftEdge = MIN(CGRectGetMinX(blockRect), CGRectGetMinX(firstRect)); CGFloat lastRectNudgeAmount = MAX(CGRectGetMinX(lastRect) - leftEdge, 0); lastRect.origin.x = MIN(leftEdge, CGRectGetMinX(lastRect)); lastRect.size.width += lastRectNudgeAmount; } [textRects addObject:[NSValue valueWithCGRect:firstRect]]; } if (!CGRectIsNull(blockRect)) { [textRects addObject:[NSValue valueWithCGRect:blockRect]]; } if (!CGRectIsNull(lastRect)) { [textRects addObject:[NSValue valueWithCGRect:lastRect]]; } } return textRects; } - (CGRect)_rectForGlyphAtIndex:(NSUInteger)glyphIndex measureOption:(ASTextNodeRendererMeasureOption)measureOption { ASDN::MutexLocker l(_textKitLock); NSUInteger charIndex = [_layoutManager characterIndexForGlyphAtIndex:glyphIndex]; CGGlyph glyph = [_layoutManager glyphAtIndex:glyphIndex]; CTFontRef font = (__bridge_retained CTFontRef)[_textStorage attribute:NSFontAttributeName atIndex:charIndex effectiveRange:NULL]; if (font == nil) { font = (__bridge_retained CTFontRef)[UIFont systemFontOfSize:12.0]; } // Glyph Advance // +-------------------------+ // | | // | | // +------------------------+--|-------------------------|--+-----------+-----+ What TextKit returns sometimes // | | | XXXXXXXXXXX + | | | (approx. correct height, but // | ---------|--+---------+ XXX XXXX +|-----------|-----| sometimes inaccurate bounding // | | | XXX XXXXX| | | widths) // | | | XX XX | | | // | | | XX | | | // | | | XXX | | | // | | | XX | | | // | | | XXXXXXXXXXX | | | // | Cap Height->| | XX | | | // | | | XX | Ascent-->| | // | | | XX | | | // | | | XX | | | // | | | X | | | // | | | X | | | // | | | X | | | // | | | XX | | | // | | | X | | | // | ---------|-------+ X +-------------------------------------| // | | XX | | // | | X | | // | | XX Descent------>| | // | | XXXXXX | | // | | XXX | | // +------------------------+-------------------------------------------------+ // | // +--+Actual bounding box CGRect glyphRect = [_layoutManager boundingRectForGlyphRange:NSMakeRange(glyphIndex, 1) inTextContainer:_textContainer]; // If it is a NSTextAttachment, we don't have the matched glyph and use width of glyphRect instead of advance. CGFloat advance = (glyph == kCGFontIndexInvalid) ? glyphRect.size.width : CTFontGetAdvancesForGlyphs(font, kCTFontOrientationHorizontal, &glyph, NULL, 1); // We treat the center of the glyph's bounding box as the center of our new rect CGPoint glyphCenter = CGPointMake(CGRectGetMidX(glyphRect), CGRectGetMidY(glyphRect)); CGRect properGlyphRect; if (measureOption == ASTextNodeRendererMeasureOptionCapHeight || measureOption == ASTextNodeRendererMeasureOptionBlock) { CGFloat ascent = CTFontGetAscent(font); CGFloat descent = CTFontGetDescent(font); CGFloat capHeight = CTFontGetCapHeight(font); CGFloat leading = CTFontGetLeading(font); CGFloat glyphHeight = ascent + descent; // For visual balance, we add the cap height padding above the cap, and // below the baseline, we scale by the descent so it grows with the size of // the text. CGFloat topPadding = ASTextNodeRendererTextCapHeightPadding * descent; CGFloat bottomPadding = topPadding; properGlyphRect = CGRectMake(glyphCenter.x - advance * 0.5, glyphCenter.y - glyphHeight * 0.5 + (ascent - capHeight) - topPadding + leading, advance, capHeight + topPadding + bottomPadding); } else { // We are just measuring the line heights here, so we can use the // heights used by TextKit, which tend to be pretty good. properGlyphRect = CGRectMake(glyphCenter.x - advance * 0.5, glyphRect.origin.y, advance, glyphRect.size.height); } CFRelease(font); return properGlyphRect; } - (void)enumerateTextIndexesAtPosition:(CGPoint)position usingBlock:(as_renderer_index_block_t)block { if (position.x > _constrainedSize.width || position.y > _constrainedSize.height || block == NULL) { // Short circuit if the position is outside the size of this renderer, or // if the block is null. return; } ASDN::MutexLocker l(_textKitLock); [self _initializeTextKitComponentsIfNeeded]; // We break it up into a 44pt box for the touch, and find the closest link // attribute-containing glyph to the center of the touch. CGFloat squareSide = 44.f; // Should be odd if you want to test the center of the touch. NSInteger pointsOnASide = 3; // The distance between any 2 of the adjacent points CGFloat pointSeparation = squareSide / pointsOnASide; // These are for tracking which point we're on. We start with -pointsOnASide/2 // and go to pointsOnASide/2. So if pointsOnASide=3, we go from -1 to 1. NSInteger endIndex = pointsOnASide / 2; NSInteger startIndex = -endIndex; BOOL stop = NO; for (NSInteger i = startIndex; i <= endIndex && !stop; i++) { for (NSInteger j = startIndex; j <= endIndex && !stop; j++) { CGPoint currentPoint = CGPointMake(position.x + i * pointSeparation, position.y + j * pointSeparation); // We ask the layout manager for the proper glyph at the touch point NSUInteger glyphIndex = [_layoutManager glyphIndexForPoint:currentPoint inTextContainer:_textContainer]; // If it's an invalid glyph, quit. BOOL isValidGlyph = NO; [_layoutManager glyphAtIndex:glyphIndex isValidIndex:&isValidGlyph]; if (!isValidGlyph) { continue; } NSUInteger characterIndex = [_layoutManager characterIndexForGlyphAtIndex:glyphIndex]; CGRect glyphRect = [self _rectForGlyphAtIndex:glyphIndex measureOption:ASTextNodeRendererMeasureOptionLineHeight]; // Sometimes TextKit plays jokes on us and returns glyphs that really // aren't close to the point in question. Silly TextKit... if (!CGRectContainsPoint(CGRectInset(glyphRect, -ASTextNodeRendererGlyphTouchHitSlop, -ASTextNodeRendererGlyphTouchHitSlop), currentPoint)) { continue; } block(characterIndex, glyphRect, &stop); } } } #pragma mark - Truncation /* * Calculates the intersection of the truncation message within the end of the * last line. * * This is accomplished by temporarily adding an exclusion rect for the size of * the truncation string at the end of the last line of text, and forcing the * layout manager to re-layout and clip the text such that we get a natural * clipping based on the settings of the layout manager. */ - (NSUInteger)_calculateCharacterIndexBeforeTruncationMessage { ASDN::MutexLocker l(_textKitLock); CGRect constrainedRect = (CGRect){.size = _calculatedSize}; NSRange visibleGlyphRange = [_layoutManager glyphRangeForBoundingRect:constrainedRect inTextContainer:_textContainer]; NSInteger lastVisibleGlyphIndex = (NSMaxRange(visibleGlyphRange) - 1); CGRect lastLineRect = [_layoutManager lineFragmentRectForGlyphAtIndex:lastVisibleGlyphIndex effectiveRange:NULL]; // Calculate the bounding rectangle for the truncation message ASTextKitComponents truncationComponents = ASTextKitComponentsCreate(_truncationString, constrainedRect.size); // Size the truncation message [truncationComponents.layoutManager ensureLayoutForTextContainer:truncationComponents.textContainer]; NSRange truncationGlyphRange = [truncationComponents.layoutManager glyphRangeForTextContainer:truncationComponents.textContainer]; CGRect truncationUsedRect = [truncationComponents.layoutManager boundingRectForGlyphRange:truncationGlyphRange inTextContainer:truncationComponents.textContainer]; CGRect translatedTruncationRect = CGRectMake(CGRectGetMaxX(constrainedRect) - truncationUsedRect.size.width, CGRectGetMinY(lastLineRect), truncationUsedRect.size.width, truncationUsedRect.size.height); // Determine which glyph is the first to be clipped / overlaps the truncation message. CGPoint beginningOfTruncationMessage = CGPointMake(translatedTruncationRect.origin.x, CGRectGetMidY(translatedTruncationRect)); NSUInteger firstClippedGlyphIndex = [_layoutManager glyphIndexForPoint:beginningOfTruncationMessage inTextContainer:_textContainer fractionOfDistanceThroughGlyph:NULL]; NSUInteger firstCharacterIndexToReplace = [_layoutManager characterIndexForGlyphAtIndex:firstClippedGlyphIndex]; ASDisplayNodeAssert(firstCharacterIndexToReplace != NSNotFound, @"The beginning of the truncation message exclusion rect (%@) didn't intersect any glyphs", NSStringFromCGPoint(beginningOfTruncationMessage)); // Break on word boundaries return [self _findTruncationInsertionPointAtOrBeforeCharacterIndex:firstCharacterIndexToReplace]; } + (NSCharacterSet *)_truncationCharacterSet { static NSCharacterSet *truncationCharacterSet; static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ NSMutableCharacterSet *mutableCharacterSet = [[NSMutableCharacterSet alloc] init]; [mutableCharacterSet formUnionWithCharacterSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]]; [mutableCharacterSet addCharactersInString:@".,!?:;"]; truncationCharacterSet = mutableCharacterSet; }); return truncationCharacterSet; } /** * @abstract Finds the first whitespace at or before the character index do we don't truncate in the middle of words * @discussion If there are multiple whitespaces together (say a space and a newline), this will backtrack to the first one */ - (NSUInteger)_findTruncationInsertionPointAtOrBeforeCharacterIndex:(NSUInteger)firstCharacterIndexToReplace { ASDN::MutexLocker l(_textKitLock); // Don't attempt to truncate beyond the beginning of the string if (firstCharacterIndexToReplace >= _textStorage.length) { return 0; } // Find the glyph range of the line fragment containing the first character to replace. NSRange lineGlyphRange; [_layoutManager lineFragmentRectForGlyphAtIndex:[_layoutManager glyphIndexForCharacterAtIndex:firstCharacterIndexToReplace] effectiveRange:&lineGlyphRange]; // Look for the first whitespace from the end of the line, starting from the truncation point NSUInteger startingSearchIndex = [_layoutManager characterIndexForGlyphAtIndex:lineGlyphRange.location]; NSUInteger endingSearchIndex = firstCharacterIndexToReplace; NSRange rangeToSearch = NSMakeRange(startingSearchIndex, (endingSearchIndex - startingSearchIndex)); NSCharacterSet *truncationCharacterSet = [[self class] _truncationCharacterSet]; NSRange rangeOfLastVisibleWhitespace = [_textStorage.string rangeOfCharacterFromSet:truncationCharacterSet options:NSBackwardsSearch range:rangeToSearch]; // Couldn't find a good place to truncate. Might be because there is no whitespace in the text, or we're dealing // with a foreign language encoding. Settle for truncating at the original place, which may be mid-word. if (rangeOfLastVisibleWhitespace.location == NSNotFound) { return firstCharacterIndexToReplace; } else { return rangeOfLastVisibleWhitespace.location; } } #pragma mark - Drawing - (void)drawInRect:(CGRect)bounds isRasterizing:(BOOL)isRasterizing { CGContextRef context = UIGraphicsGetCurrentContext(); ASDisplayNodeAssert(context, @"This is no good without a context."); CGContextSaveGState(context); [self _initializeTextKitComponentsIfNeeded]; NSRange glyphRange = [_layoutManager glyphRangeForTextContainer:_textContainer]; { ASDN::MutexLocker l(_textKitLock); [_layoutManager drawBackgroundForGlyphRange:glyphRange atPoint:bounds.origin]; [_layoutManager drawGlyphsForGlyphRange:glyphRange atPoint:bounds.origin]; } CGContextRestoreGState(context); } #pragma mark - String Ranges - (NSUInteger)lineCount { ASDN::MutexLocker l(_textKitLock); [self _initializeTextKitComponentsIfNeeded]; NSUInteger lineCount = 0; for (NSRange lineRange = { 0, 0 }; NSMaxRange(lineRange) < [_layoutManager numberOfGlyphs]; lineCount++) { [_layoutManager lineFragmentRectForGlyphAtIndex:NSMaxRange(lineRange) effectiveRange:&lineRange]; } return lineCount; } - (NSRange)visibleRange { ASDN::MutexLocker l(_textKitLock); [self _initializeTextKitComponentsIfNeeded]; return _visibleRange; } - (NSRange)truncationStringCharacterRange { ASDN::MutexLocker l(_textKitLock); [self _initializeTextKitComponentsIfNeeded]; return _truncationCharacterRange; } @end