[ASTextNode] Add Fast-Paths For Text Measurement and Drawing (#2392)

* Add test case for TextKit truncation style

* Add fast path for text node measurement with default truncation

* Use fast path more often

* Reverse options order

* Simplify implementation – no functional change

* Share "isScaled" variable

* Intersect with constrained rect

* Add a failing test case for fast-path truncation

* Add some more truncation tests, using slow path as reference image

* Update the tests

* In ASTextKitRenderer, intersect bounds with constrained rect

* Add test case for TextKit truncation style

* Add fast path for text node measurement with default truncation

* Use fast path more often

* Reverse options order

* Simplify implementation – no functional change

* Share "isScaled" variable

* Intersect with constrained rect

* Add a failing test case for fast-path truncation

* Add some more truncation tests, using slow path as reference image

* Update the tests

* In ASTextKitRenderer, intersect bounds with constrained rect

* Use maximumNumberOfLines property in text kit fast path

Add reference images

Disable fast-path for max-one-line case

* Remove unneeded snapshot files
This commit is contained in:
Adlai Holler 2016-10-24 17:05:59 -07:00 committed by GitHub
parent 2834ba3490
commit 0d439a43b6
9 changed files with 120 additions and 42 deletions

View File

@ -1356,7 +1356,7 @@ static NSAttributedString *DefaultTruncationAttributedString()
} }
// If we've reached this point, both _additionalTruncationMessage and // 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]; NSMutableAttributedString *newComposedTruncationString = [[NSMutableAttributedString alloc] initWithAttributedString:_truncationAttributedText];
[newComposedTruncationString replaceCharactersInRange:NSMakeRange(newComposedTruncationString.length, 0) withString:@" "]; [newComposedTruncationString replaceCharactersInRange:NSMakeRange(newComposedTruncationString.length, 0) withString:@" "];

View File

@ -35,11 +35,20 @@ static NSCharacterSet *_defaultAvoidTruncationCharacterSet()
return truncationCharacterSet; 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 { @implementation ASTextKitRenderer {
CGSize _calculatedSize; CGSize _calculatedSize;
BOOL _sizeIsCalculated; 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 #pragma mark - Initialization
@ -50,6 +59,7 @@ static NSCharacterSet *_defaultAvoidTruncationCharacterSet()
_constrainedSize = constrainedSize; _constrainedSize = constrainedSize;
_attributes = attributes; _attributes = attributes;
_sizeIsCalculated = NO; _sizeIsCalculated = NO;
_currentScaleFactor = 1;
} }
return self; return self;
} }
@ -106,6 +116,19 @@ static NSCharacterSet *_defaultAvoidTruncationCharacterSet()
return _context; 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 #pragma mark - Sizing
- (CGSize)size - (CGSize)size
@ -145,8 +168,17 @@ static NSCharacterSet *_defaultAvoidTruncationCharacterSet()
_currentScaleFactor = [[self fontSizeAdjuster] scaleFactor]; _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]; BOOL isScaled = [self isScaled];
__block NSTextStorage *scaledTextStorage = nil;
if (isScaled) { if (isScaled) {
// apply the string scale before truncating or else we may truncate the string after we've done the work to shrink it. // 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) { [[self context] performBlockWithLockedTextKitComponents:^(NSLayoutManager *layoutManager, NSTextStorage *textStorage, NSTextContainer *textContainer) {
@ -161,15 +193,13 @@ static NSCharacterSet *_defaultAvoidTruncationCharacterSet()
[[self truncater] truncate]; [[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 // Force glyph generation and layout, which may not have happened yet (and isn't triggered by
// -usedRectForTextContainer:). // -usedRectForTextContainer:).
[[self context] performBlockWithLockedTextKitComponents:^(NSLayoutManager *layoutManager, NSTextStorage *textStorage, NSTextContainer *textContainer) { [[self context] performBlockWithLockedTextKitComponents:^(NSLayoutManager *layoutManager, NSTextStorage *textStorage, NSTextContainer *textContainer) {
[layoutManager ensureLayoutForTextContainer:textContainer]; [layoutManager ensureLayoutForTextContainer:textContainer];
}];
CGRect constrainedRect = {CGPointZero, _constrainedSize};
__block CGRect boundingRect;
[[self context] performBlockWithLockedTextKitComponents:^(NSLayoutManager *layoutManager, NSTextStorage *textStorage, NSTextContainer *textContainer) {
boundingRect = [layoutManager usedRectForTextContainer:textContainer]; boundingRect = [layoutManager usedRectForTextContainer:textContainer];
if (isScaled) { if (isScaled) {
// put the non-scaled version back // 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 // 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. // to make sure our width calculations aren't being offset by glyphs going beyond the constrained rect.
boundingRect = CGRectIntersection(boundingRect, {.size = constrainedRect.size}); boundingRect = CGRectIntersection(boundingRect, {.size = constrainedRect.size});
CGSize boundingSize = [_shadower outsetSizeWithInsetSize:boundingRect.size]; _calculatedSize = [_shadower outsetSizeWithInsetSize:boundingRect.size];
_calculatedSize = CGSizeMake(boundingSize.width, boundingSize.height);
} }
- (BOOL)isScaled - (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 #pragma mark - Drawing
@ -198,11 +248,12 @@ static NSCharacterSet *_defaultAvoidTruncationCharacterSet()
ASDisplayNodeAssertNotNil(context, @"This is no good without a context."); 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. // 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) { if (_sizeIsCalculated == NO && isinf(_constrainedSize.width) == NO) {
[self _calculateSize]; [self _calculateSize];
} }
bounds = CGRectIntersection(bounds, { .size = _constrainedSize });
CGRect shadowInsetBounds = [[self shadower] insetRectWithConstrainedRect:bounds]; CGRect shadowInsetBounds = [[self shadower] insetRectWithConstrainedRect:bounds];
CGContextSaveGState(context); CGContextSaveGState(context);
@ -211,10 +262,15 @@ static NSCharacterSet *_defaultAvoidTruncationCharacterSet()
LOG(@"%@, shadowInsetBounds = %@",self, NSStringFromCGRect(shadowInsetBounds)); LOG(@"%@, shadowInsetBounds = %@",self, NSStringFromCGRect(shadowInsetBounds));
// 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) { [[self context] performBlockWithLockedTextKitComponents:^(NSLayoutManager *layoutManager, NSTextStorage *textStorage, NSTextContainer *textContainer) {
NSTextStorage *scaledTextStorage = nil; NSTextStorage *scaledTextStorage = nil;
BOOL isScaled = [self isScaled];
if (isScaled) { if (isScaled) {
// if we are going to scale the text, swap out the non-scaled text for the scaled version. // if we are going to scale the text, swap out the non-scaled text for the scaled version.
@ -227,6 +283,7 @@ static NSCharacterSet *_defaultAvoidTruncationCharacterSet()
} }
LOG(@"usedRect: %@", NSStringFromCGRect([layoutManager usedRectForTextContainer:textContainer])); LOG(@"usedRect: %@", NSStringFromCGRect([layoutManager usedRectForTextContainer:textContainer]));
NSRange glyphRange = [layoutManager glyphRangeForBoundingRect:CGRectMake(0,0,textContainer.size.width, textContainer.size.height) inTextContainer:textContainer]; NSRange glyphRange = [layoutManager glyphRangeForBoundingRect:CGRectMake(0,0,textContainer.size.width, textContainer.size.height) inTextContainer:textContainer];
LOG(@"boundingRect: %@", NSStringFromCGRect([layoutManager boundingRectForGlyphRange:glyphRange inTextContainer:textContainer])); LOG(@"boundingRect: %@", NSStringFromCGRect([layoutManager boundingRectForGlyphRange:glyphRange inTextContainer:textContainer]));
@ -239,6 +296,7 @@ static NSCharacterSet *_defaultAvoidTruncationCharacterSet()
[textStorage addLayoutManager:layoutManager]; [textStorage addLayoutManager:layoutManager];
} }
}]; }];
}
UIGraphicsPopContext(); UIGraphicsPopContext();
CGContextRestoreGState(context); CGContextRestoreGState(context);

View File

@ -29,6 +29,9 @@ NSOrderedSet *ASSnapshotTestCaseDefaultSuffixes(void);
#define ASSnapshotVerifyLayer(layer__, identifier__) \ #define ASSnapshotVerifyLayer(layer__, identifier__) \
FBSnapshotVerifyLayerWithOptions(layer__, identifier__, ASSnapshotTestCaseDefaultSuffixes(), 0); FBSnapshotVerifyLayerWithOptions(layer__, identifier__, ASSnapshotTestCaseDefaultSuffixes(), 0);
#define ASSnapshotVerifyView(view__, identifier__) \
FBSnapshotVerifyViewWithOptions(view__, identifier__, ASSnapshotTestCaseDefaultSuffixes(), 0);
@interface ASSnapshotTestCase : FBSnapshotTestCase @interface ASSnapshotTestCase : FBSnapshotTestCase
/** /**

View File

@ -108,7 +108,7 @@ static NSString *const kTestCaseUIKitWithReusedContext = @"UIKitReusedContext";
ctx.results[kTestCaseASDK].userInfo[@"size"] = NSStringFromCGSize(asdkSize); ctx.results[kTestCaseASDK].userInfo[@"size"] = NSStringFromCGSize(asdkSize);
ASXCTAssertEqualSizes(uiKitSize, asdkSize); ASXCTAssertEqualSizes(uiKitSize, asdkSize);
ASXCTAssertRelativePerformanceInRange(ctx, kTestCaseASDK, 0.2, 0.5); ASXCTAssertRelativePerformanceInRange(ctx, kTestCaseASDK, 0.5, 0.9);
} }
- (void)testPerformance_OneParagraphLatinWithTruncation - (void)testPerformance_OneParagraphLatinWithTruncation

View File

@ -72,7 +72,24 @@
textNode.highlightRange = NSMakeRange(0, textNode.attributedText.length); textNode.highlightRange = NSMakeRange(0, textNode.attributedText.length);
[ASSnapshotTestCase hackilySynchronouslyRecursivelyRenderNode:textNode]; [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 @end

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB