ComponentTextKit/TextKit/CKTextKitTailTruncater.mm (134 lines of code) (raw):

/* * 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 <RenderCore/RCAssert.h> #import <ComponentTextKit/CKTextKitContext.h> #import <ComponentTextKit/CKTextKitTailTruncater.h> @implementation CKTextKitTailTruncater { __weak CKTextKitContext *_context; NSAttributedString *_truncationAttributedString; NSCharacterSet *_avoidTailTruncationSet; CGSize _constrainedSize; } @synthesize visibleRanges = _visibleRanges; @synthesize truncationStringRect = _truncationStringRect; - (instancetype)initWithContext:(CKTextKitContext *)context truncationAttributedString:(NSAttributedString *)truncationAttributedString avoidTailTruncationSet:(NSCharacterSet *)avoidTailTruncationSet constrainedSize:(CGSize)constrainedSize { if (self = [super init]) { _context = context; _truncationAttributedString = truncationAttributedString; _avoidTailTruncationSet = avoidTailTruncationSet; _constrainedSize = constrainedSize; [self _truncate]; } return self; } /** Calculates the intersection of the truncation message within the end of the last line. */ - (NSUInteger)_calculateCharacterIndexBeforeTruncationMessage:(NSLayoutManager *)layoutManager textStorage:(NSTextStorage *)textStorage textContainer:(NSTextContainer *)textContainer { CGRect constrainedRect = (CGRect){ .size = textContainer.size }; NSRange visibleGlyphRange = [layoutManager glyphRangeForBoundingRect:constrainedRect inTextContainer:textContainer]; NSInteger lastVisibleGlyphIndex = (NSMaxRange(visibleGlyphRange) - 1); if (lastVisibleGlyphIndex < 0) { return NSNotFound; } CGRect lastLineRect = [layoutManager lineFragmentRectForGlyphAtIndex:lastVisibleGlyphIndex effectiveRange:NULL]; CGRect lastLineUsedRect = [layoutManager lineFragmentUsedRectForGlyphAtIndex:lastVisibleGlyphIndex effectiveRange:NULL]; NSParagraphStyle *paragraphStyle = [textStorage attributesAtIndex:[layoutManager characterIndexForGlyphAtIndex:lastVisibleGlyphIndex] effectiveRange:NULL][NSParagraphStyleAttributeName]; // We assume LTR so long as the writing direction is not BOOL rtlWritingDirection = paragraphStyle ? paragraphStyle.baseWritingDirection == NSWritingDirectionRightToLeft : NO; // We only want to treat the trunction rect as left-aligned in the case that we are right-aligned and our writing // direction is RTL. BOOL leftAligned = CGRectGetMinX(lastLineRect) == CGRectGetMinX(lastLineUsedRect) || !rtlWritingDirection; // Calculate the bounding rectangle for the truncation message CKTextKitContext *truncationContext = [[CKTextKitContext alloc] initWithAttributedString:_truncationAttributedString lineBreakMode:NSLineBreakByWordWrapping maximumNumberOfLines:1 constrainedSize:constrainedRect.size layoutManagerFactory:nil]; __block CGRect truncationUsedRect; [truncationContext performBlockWithLockedTextKitComponents:^(NSLayoutManager *truncationLayoutManager, NSTextStorage *truncationTextStorage, NSTextContainer *truncationTextContainer) { // Size the truncation message [truncationLayoutManager ensureLayoutForTextContainer:truncationTextContainer]; NSRange truncationGlyphRange = [truncationLayoutManager glyphRangeForTextContainer:truncationTextContainer]; truncationUsedRect = [truncationLayoutManager boundingRectForGlyphRange:truncationGlyphRange inTextContainer:truncationTextContainer]; }]; CGFloat truncationOriginX = (leftAligned ? CGRectGetMaxX(constrainedRect) - truncationUsedRect.size.width : CGRectGetMinX(constrainedRect)); CGRect translatedTruncationRect = CGRectMake(truncationOriginX, CGRectGetMinY(lastLineRect), truncationUsedRect.size.width, truncationUsedRect.size.height); // Determine which glyph is the first to be clipped / overlaps the truncation message. CGFloat truncationMessageX = (leftAligned ? CGRectGetMinX(translatedTruncationRect) : CGRectGetMaxX(translatedTruncationRect)); CGPoint beginningOfTruncationMessage = CGPointMake(truncationMessageX, CGRectGetMidY(translatedTruncationRect)); NSUInteger firstClippedGlyphIndex = [layoutManager glyphIndexForPoint:beginningOfTruncationMessage inTextContainer:textContainer fractionOfDistanceThroughGlyph:NULL]; // If it didn't intersect with any text then it should just return the last visible character index, since the // truncation rect can fully fit on the line without clipping any other text. if (firstClippedGlyphIndex == NSNotFound) { return [layoutManager characterIndexForGlyphAtIndex:lastVisibleGlyphIndex]; } NSUInteger firstCharacterIndexToReplace = [layoutManager characterIndexForGlyphAtIndex:firstClippedGlyphIndex]; if (firstCharacterIndexToReplace == NSNotFound) { return NSNotFound; } // Break on word boundaries return [self _findTruncationInsertionPointAtOrBeforeCharacterIndex:firstCharacterIndexToReplace layoutManager:layoutManager textStorage:textStorage]; } /** Finds the first whitespace at or before the character index do we don't truncate in the middle of words If there are multiple whitespaces together (say a space and a newline), this will backtrack to the first one */ - (NSUInteger)_findTruncationInsertionPointAtOrBeforeCharacterIndex:(NSUInteger)firstCharacterIndexToReplace layoutManager:(NSLayoutManager *)layoutManager textStorage:(NSTextStorage *)textStorage { // Don't attempt to truncate beyond the end 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)); NSRange rangeOfLastVisibleAvoidedChars = { .location = NSNotFound }; if (_avoidTailTruncationSet) { rangeOfLastVisibleAvoidedChars = [textStorage.string rangeOfCharacterFromSet:_avoidTailTruncationSet 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 (rangeOfLastVisibleAvoidedChars.location == NSNotFound) { return firstCharacterIndexToReplace; } else { return rangeOfLastVisibleAvoidedChars.location; } } - (void)_truncate { [_context performBlockWithLockedTextKitComponents:^(NSLayoutManager *layoutManager, NSTextStorage *textStorage, NSTextContainer *textContainer) { NSUInteger originalStringLength = textStorage.length; [layoutManager ensureLayoutForTextContainer:textContainer]; NSRange visibleGlyphRange = [layoutManager glyphRangeForBoundingRect:{ .size = textContainer.size } inTextContainer:textContainer]; NSRange visibleCharacterRange = [layoutManager characterRangeForGlyphRange:visibleGlyphRange actualGlyphRange:NULL]; // Check if text is truncated, and if so apply our truncation string if (visibleCharacterRange.length < originalStringLength && _truncationAttributedString.length > 0) { NSInteger firstCharacterIndexToReplace = [self _calculateCharacterIndexBeforeTruncationMessage:layoutManager textStorage:textStorage textContainer:textContainer]; if (firstCharacterIndexToReplace == 0 || firstCharacterIndexToReplace >= textStorage.length) { // Either nothing is visible, or everything is; nothing for us to do here return; } // Update/truncate the visible range of text visibleCharacterRange = 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:_truncationAttributedString]; } _visibleRanges = { visibleCharacterRange }; }]; } @end