diff --git a/AsyncDisplayKit/ASTextNode.mm b/AsyncDisplayKit/ASTextNode.mm index 3de201d87e..f287eb931f 100644 --- a/AsyncDisplayKit/ASTextNode.mm +++ b/AsyncDisplayKit/ASTextNode.mm @@ -1356,7 +1356,7 @@ static NSAttributedString *DefaultTruncationAttributedString() } // If we've reached this point, both _additionalTruncationMessage and - // _truncationAttributedString are present. Compose them. + // _truncationAttributedText are present. Compose them. NSMutableAttributedString *newComposedTruncationString = [[NSMutableAttributedString alloc] initWithAttributedString:_truncationAttributedText]; [newComposedTruncationString replaceCharactersInRange:NSMakeRange(newComposedTruncationString.length, 0) withString:@" "]; diff --git a/AsyncDisplayKit/TextKit/ASTextKitRenderer.mm b/AsyncDisplayKit/TextKit/ASTextKitRenderer.mm index 0beb67902c..4a6f5978c0 100755 --- a/AsyncDisplayKit/TextKit/ASTextKitRenderer.mm +++ b/AsyncDisplayKit/TextKit/ASTextKitRenderer.mm @@ -35,11 +35,20 @@ static NSCharacterSet *_defaultAvoidTruncationCharacterSet() return truncationCharacterSet; } +@interface ASTextKitRenderer() +/** + * This object is lazily created. It is provided to the NSAttributedString + * drawing methods used by the fast-paths in the size calculation and drawing + * instance methods. + */ +@property (nonatomic, strong, readonly) NSStringDrawingContext *stringDrawingContext; +@end + @implementation ASTextKitRenderer { CGSize _calculatedSize; BOOL _sizeIsCalculated; } -@synthesize attributes = _attributes, context = _context, shadower = _shadower, truncater = _truncater, fontSizeAdjuster = _fontSizeAdjuster; +@synthesize attributes = _attributes, context = _context, shadower = _shadower, truncater = _truncater, fontSizeAdjuster = _fontSizeAdjuster, stringDrawingContext = _stringDrawingContext; #pragma mark - Initialization @@ -50,6 +59,7 @@ static NSCharacterSet *_defaultAvoidTruncationCharacterSet() _constrainedSize = constrainedSize; _attributes = attributes; _sizeIsCalculated = NO; + _currentScaleFactor = 1; } return self; } @@ -106,6 +116,19 @@ static NSCharacterSet *_defaultAvoidTruncationCharacterSet() return _context; } +- (NSStringDrawingContext *)stringDrawingContext +{ + if (_stringDrawingContext == nil) { + _stringDrawingContext = [[NSStringDrawingContext alloc] init]; + + if (isinf(_constrainedSize.width) == NO && _attributes.maximumNumberOfLines > 0) { + ASDisplayNodeAssert(_attributes.maximumNumberOfLines != 1, @"Max line count 1 is not supported in fast-path."); + [_stringDrawingContext setValue:@(_attributes.maximumNumberOfLines) forKey:@"maximumNumberOfLines"]; + } + } + return _stringDrawingContext; +} + #pragma mark - Sizing - (CGSize)size @@ -144,9 +167,18 @@ static NSCharacterSet *_defaultAvoidTruncationCharacterSet() if (isinf(_constrainedSize.width) == NO && [_attributes.pointSizeScaleFactors count] > 0) { _currentScaleFactor = [[self fontSizeAdjuster] scaleFactor]; } - - __block NSTextStorage *scaledTextStorage = nil; + + // If we do not scale, do exclusion, or do custom truncation, we should just use NSAttributedString drawing for a fast-path. + if (self.canUseFastPath) { + CGRect rect = [_attributes.attributedString boundingRectWithSize:_constrainedSize options:NSStringDrawingUsesLineFragmentOrigin | NSStringDrawingTruncatesLastVisibleLine context:self.stringDrawingContext]; + // Intersect with constrained rect, in case text kit goes out-of-bounds. + rect = CGRectIntersection(rect, {CGPointZero, _constrainedSize}); + _calculatedSize = [self.shadower outsetSizeWithInsetSize:rect.size]; + return; + } + BOOL isScaled = [self isScaled]; + __block NSTextStorage *scaledTextStorage = nil; if (isScaled) { // apply the string scale before truncating or else we may truncate the string after we've done the work to shrink it. [[self context] performBlockWithLockedTextKitComponents:^(NSLayoutManager *layoutManager, NSTextStorage *textStorage, NSTextContainer *textContainer) { @@ -161,15 +193,13 @@ static NSCharacterSet *_defaultAvoidTruncationCharacterSet() [[self truncater] truncate]; + CGRect constrainedRect = {CGPointZero, _constrainedSize}; + __block CGRect boundingRect; + // Force glyph generation and layout, which may not have happened yet (and isn't triggered by // -usedRectForTextContainer:). [[self context] performBlockWithLockedTextKitComponents:^(NSLayoutManager *layoutManager, NSTextStorage *textStorage, NSTextContainer *textContainer) { [layoutManager ensureLayoutForTextContainer:textContainer]; - }]; - - CGRect constrainedRect = {CGPointZero, _constrainedSize}; - __block CGRect boundingRect; - [[self context] performBlockWithLockedTextKitComponents:^(NSLayoutManager *layoutManager, NSTextStorage *textStorage, NSTextContainer *textContainer) { boundingRect = [layoutManager usedRectForTextContainer:textContainer]; if (isScaled) { // put the non-scaled version back @@ -181,13 +211,33 @@ static NSCharacterSet *_defaultAvoidTruncationCharacterSet() // 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, {.size = constrainedRect.size}); - CGSize boundingSize = [_shadower outsetSizeWithInsetSize:boundingRect.size]; - _calculatedSize = CGSizeMake(boundingSize.width, boundingSize.height); + _calculatedSize = [_shadower outsetSizeWithInsetSize:boundingRect.size]; } - (BOOL)isScaled { - return (self.currentScaleFactor > 0 && self.currentScaleFactor < 1.0); + return (_currentScaleFactor > 0 && _currentScaleFactor < 1.0); +} + +- (BOOL)usesCustomTruncation +{ + // NOTE: This code does not correctly handle if they set `…` with different attributes. + return _attributes.avoidTailTruncationSet != nil || [_attributes.truncationAttributedString.string isEqualToString:@"\u2026"] == NO; +} + +- (BOOL)usesExclusionPaths +{ + return _attributes.exclusionPaths.count > 0; +} + +- (BOOL)canUseFastPath +{ + return self.isScaled == NO + && self.usesCustomTruncation == NO + && self.usesExclusionPaths == NO + // NSAttributedString drawing methods ignore usesLineFragmentOrigin if max line count 1, + // rendering them useless: + && (_attributes.maximumNumberOfLines != 1 || isinf(_constrainedSize.width)); } #pragma mark - Drawing @@ -198,11 +248,12 @@ static NSCharacterSet *_defaultAvoidTruncationCharacterSet() ASDisplayNodeAssertNotNil(context, @"This is no good without a context."); // This renderer may not be the one that did the sizing. If that is the case its truncation and currentScaleFactor may not have been evaluated. - // If there's any possibility we need to truncate or scale (e.g. width is not infinite, perform the size calculation. + // If there's any possibility we need to truncate or scale (i.e. width is not infinite), perform the size calculation. if (_sizeIsCalculated == NO && isinf(_constrainedSize.width) == NO) { [self _calculateSize]; } + bounds = CGRectIntersection(bounds, { .size = _constrainedSize }); CGRect shadowInsetBounds = [[self shadower] insetRectWithConstrainedRect:bounds]; CGContextSaveGState(context); @@ -210,35 +261,42 @@ static NSCharacterSet *_defaultAvoidTruncationCharacterSet() UIGraphicsPushContext(context); LOG(@"%@, shadowInsetBounds = %@",self, NSStringFromCGRect(shadowInsetBounds)); - - [[self context] performBlockWithLockedTextKitComponents:^(NSLayoutManager *layoutManager, NSTextStorage *textStorage, NSTextContainer *textContainer) { - - NSTextStorage *scaledTextStorage = nil; - BOOL isScaled = [self isScaled]; - if (isScaled) { - // if we are going to scale the text, swap out the non-scaled text for the scaled version. - NSMutableAttributedString *scaledString = [[NSMutableAttributedString alloc] initWithAttributedString:textStorage]; - [ASTextKitFontSizeAdjuster adjustFontSizeForAttributeString:scaledString withScaleFactor:_currentScaleFactor]; - scaledTextStorage = [[NSTextStorage alloc] initWithAttributedString:scaledString]; + // If we use default options, we can use NSAttributedString for a + // fast path. + if (self.canUseFastPath) { + [_attributes.attributedString drawWithRect:shadowInsetBounds options:NSStringDrawingUsesLineFragmentOrigin | NSStringDrawingTruncatesLastVisibleLine context:self.stringDrawingContext]; + } else { + BOOL isScaled = [self isScaled]; + [[self context] performBlockWithLockedTextKitComponents:^(NSLayoutManager *layoutManager, NSTextStorage *textStorage, NSTextContainer *textContainer) { - [textStorage removeLayoutManager:layoutManager]; - [scaledTextStorage addLayoutManager:layoutManager]; - } - - LOG(@"usedRect: %@", NSStringFromCGRect([layoutManager usedRectForTextContainer:textContainer])); - NSRange glyphRange = [layoutManager glyphRangeForBoundingRect:CGRectMake(0,0,textContainer.size.width, textContainer.size.height) inTextContainer:textContainer]; - LOG(@"boundingRect: %@", NSStringFromCGRect([layoutManager boundingRectForGlyphRange:glyphRange inTextContainer:textContainer])); - - [layoutManager drawBackgroundForGlyphRange:glyphRange atPoint:shadowInsetBounds.origin]; - [layoutManager drawGlyphsForGlyphRange:glyphRange atPoint:shadowInsetBounds.origin]; - - if (isScaled) { - // put the non-scaled version back - [scaledTextStorage removeLayoutManager:layoutManager]; - [textStorage addLayoutManager:layoutManager]; - } - }]; + NSTextStorage *scaledTextStorage = nil; + + if (isScaled) { + // if we are going to scale the text, swap out the non-scaled text for the scaled version. + NSMutableAttributedString *scaledString = [[NSMutableAttributedString alloc] initWithAttributedString:textStorage]; + [ASTextKitFontSizeAdjuster adjustFontSizeForAttributeString:scaledString withScaleFactor:_currentScaleFactor]; + scaledTextStorage = [[NSTextStorage alloc] initWithAttributedString:scaledString]; + + [textStorage removeLayoutManager:layoutManager]; + [scaledTextStorage addLayoutManager:layoutManager]; + } + + LOG(@"usedRect: %@", NSStringFromCGRect([layoutManager usedRectForTextContainer:textContainer])); + + NSRange glyphRange = [layoutManager glyphRangeForBoundingRect:CGRectMake(0,0,textContainer.size.width, textContainer.size.height) inTextContainer:textContainer]; + LOG(@"boundingRect: %@", NSStringFromCGRect([layoutManager boundingRectForGlyphRange:glyphRange inTextContainer:textContainer])); + + [layoutManager drawBackgroundForGlyphRange:glyphRange atPoint:shadowInsetBounds.origin]; + [layoutManager drawGlyphsForGlyphRange:glyphRange atPoint:shadowInsetBounds.origin]; + + if (isScaled) { + // put the non-scaled version back + [scaledTextStorage removeLayoutManager:layoutManager]; + [textStorage addLayoutManager:layoutManager]; + } + }]; + } UIGraphicsPopContext(); CGContextRestoreGState(context); diff --git a/AsyncDisplayKitTests/ASSnapshotTestCase.h b/AsyncDisplayKitTests/ASSnapshotTestCase.h index 977e267d17..c89fc79af4 100644 --- a/AsyncDisplayKitTests/ASSnapshotTestCase.h +++ b/AsyncDisplayKitTests/ASSnapshotTestCase.h @@ -29,6 +29,9 @@ NSOrderedSet *ASSnapshotTestCaseDefaultSuffixes(void); #define ASSnapshotVerifyLayer(layer__, identifier__) \ FBSnapshotVerifyLayerWithOptions(layer__, identifier__, ASSnapshotTestCaseDefaultSuffixes(), 0); +#define ASSnapshotVerifyView(view__, identifier__) \ + FBSnapshotVerifyViewWithOptions(view__, identifier__, ASSnapshotTestCaseDefaultSuffixes(), 0); + @interface ASSnapshotTestCase : FBSnapshotTestCase /** diff --git a/AsyncDisplayKitTests/ASTextNodePerformanceTests.m b/AsyncDisplayKitTests/ASTextNodePerformanceTests.m index 9ed8f1ea0c..d3a0fa50e8 100644 --- a/AsyncDisplayKitTests/ASTextNodePerformanceTests.m +++ b/AsyncDisplayKitTests/ASTextNodePerformanceTests.m @@ -108,7 +108,7 @@ static NSString *const kTestCaseUIKitWithReusedContext = @"UIKitReusedContext"; ctx.results[kTestCaseASDK].userInfo[@"size"] = NSStringFromCGSize(asdkSize); ASXCTAssertEqualSizes(uiKitSize, asdkSize); - ASXCTAssertRelativePerformanceInRange(ctx, kTestCaseASDK, 0.2, 0.5); + ASXCTAssertRelativePerformanceInRange(ctx, kTestCaseASDK, 0.5, 0.9); } - (void)testPerformance_OneParagraphLatinWithTruncation diff --git a/AsyncDisplayKitTests/ASTextNodeSnapshotTests.m b/AsyncDisplayKitTests/ASTextNodeSnapshotTests.m index 895f1841af..08b953374d 100644 --- a/AsyncDisplayKitTests/ASTextNodeSnapshotTests.m +++ b/AsyncDisplayKitTests/ASTextNodeSnapshotTests.m @@ -72,7 +72,24 @@ textNode.highlightRange = NSMakeRange(0, textNode.attributedText.length); [ASSnapshotTestCase hackilySynchronouslyRecursivelyRenderNode:textNode]; - ASSnapshotVerifyLayer(backgroundView.layer, nil); + ASSnapshotVerifyView(backgroundView, nil); +} + +- (void)testThatFastPathTruncationWorks +{ + ASTextNode *textNode = [[ASTextNode alloc] init]; + textNode.attributedText = [[NSAttributedString alloc] initWithString:@"Quality is Important" attributes:@{ NSForegroundColorAttributeName: [UIColor blueColor], NSFontAttributeName: [UIFont italicSystemFontOfSize:24] }]; + [textNode layoutThatFits:ASSizeRangeMake(CGSizeZero, CGSizeMake(100, 50))]; + ASSnapshotVerifyNode(textNode, nil); +} + +- (void)testThatSlowPathTruncationWorks +{ + ASTextNode *textNode = [[ASTextNode alloc] init]; + textNode.attributedText = [[NSAttributedString alloc] initWithString:@"Quality is Important" attributes:@{ NSForegroundColorAttributeName: [UIColor blueColor], NSFontAttributeName: [UIFont italicSystemFontOfSize:24] }]; + [textNode layoutThatFits:ASSizeRangeMake(CGSizeZero, CGSizeMake(100, 50))]; + textNode.exclusionPaths = @[ [UIBezierPath bezierPath] ]; + ASSnapshotVerifyNode(textNode, nil); } @end diff --git a/AsyncDisplayKitTests/ReferenceImages_64/ASTextNodeSnapshotTests/testThatFastPathTruncationWorks@2x.png b/AsyncDisplayKitTests/ReferenceImages_64/ASTextNodeSnapshotTests/testThatFastPathTruncationWorks@2x.png new file mode 100644 index 0000000000..d9f01fce31 Binary files /dev/null and b/AsyncDisplayKitTests/ReferenceImages_64/ASTextNodeSnapshotTests/testThatFastPathTruncationWorks@2x.png differ diff --git a/AsyncDisplayKitTests/ReferenceImages_64/ASTextNodeSnapshotTests/testThatSlowPathTruncationWorks@2x.png b/AsyncDisplayKitTests/ReferenceImages_64/ASTextNodeSnapshotTests/testThatSlowPathTruncationWorks@2x.png new file mode 100644 index 0000000000..dcae003909 Binary files /dev/null and b/AsyncDisplayKitTests/ReferenceImages_64/ASTextNodeSnapshotTests/testThatSlowPathTruncationWorks@2x.png differ diff --git a/AsyncDisplayKitTests/ReferenceImages_iOS_10/ASTextNodeSnapshotTests/testThatFastPathTruncationWorks@2x.png b/AsyncDisplayKitTests/ReferenceImages_iOS_10/ASTextNodeSnapshotTests/testThatFastPathTruncationWorks@2x.png new file mode 100644 index 0000000000..37b5444efa Binary files /dev/null and b/AsyncDisplayKitTests/ReferenceImages_iOS_10/ASTextNodeSnapshotTests/testThatFastPathTruncationWorks@2x.png differ diff --git a/AsyncDisplayKitTests/ReferenceImages_iOS_10/ASTextNodeSnapshotTests/testThatSlowPathTruncationWorks@2x.png b/AsyncDisplayKitTests/ReferenceImages_iOS_10/ASTextNodeSnapshotTests/testThatSlowPathTruncationWorks@2x.png new file mode 100644 index 0000000000..bf816c2ed4 Binary files /dev/null and b/AsyncDisplayKitTests/ReferenceImages_iOS_10/ASTextNodeSnapshotTests/testThatSlowPathTruncationWorks@2x.png differ