mirror of
https://github.com/Swiftgram/Telegram-iOS.git
synced 2025-08-17 19:09:56 +00:00
628 lines
26 KiB
Plaintext
628 lines
26 KiB
Plaintext
/* 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 <CoreText/CoreText.h>
|
|
|
|
#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);
|
|
|
|
// Concurrently initialising TextKit components crashes (rdar://18448377) so we use a global lock. :(
|
|
static ASDN::StaticMutex mutex = ASDISPLAYNODE_MUTEX_INITIALIZER;
|
|
ASDN::StaticMutexLocker gl(mutex);
|
|
|
|
// 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];
|
|
|
|
ASDN::StaticMutexUnlocker gu(mutex);
|
|
|
|
[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
|