mirror of
https://github.com/Swiftgram/Telegram-iOS.git
synced 2025-12-13 18:00:17 +00:00
[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:
parent
2834ba3490
commit
0d439a43b6
@ -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:@" "];
|
||||||
|
|||||||
@ -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
|
||||||
@ -144,9 +167,18 @@ static NSCharacterSet *_defaultAvoidTruncationCharacterSet()
|
|||||||
if (isinf(_constrainedSize.width) == NO && [_attributes.pointSizeScaleFactors count] > 0) {
|
if (isinf(_constrainedSize.width) == NO && [_attributes.pointSizeScaleFactors count] > 0) {
|
||||||
_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);
|
||||||
@ -210,35 +261,42 @@ static NSCharacterSet *_defaultAvoidTruncationCharacterSet()
|
|||||||
UIGraphicsPushContext(context);
|
UIGraphicsPushContext(context);
|
||||||
|
|
||||||
LOG(@"%@, shadowInsetBounds = %@",self, NSStringFromCGRect(shadowInsetBounds));
|
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 use default options, we can use NSAttributedString for a
|
||||||
// if we are going to scale the text, swap out the non-scaled text for the scaled version.
|
// fast path.
|
||||||
NSMutableAttributedString *scaledString = [[NSMutableAttributedString alloc] initWithAttributedString:textStorage];
|
if (self.canUseFastPath) {
|
||||||
[ASTextKitFontSizeAdjuster adjustFontSizeForAttributeString:scaledString withScaleFactor:_currentScaleFactor];
|
[_attributes.attributedString drawWithRect:shadowInsetBounds options:NSStringDrawingUsesLineFragmentOrigin | NSStringDrawingTruncatesLastVisibleLine context:self.stringDrawingContext];
|
||||||
scaledTextStorage = [[NSTextStorage alloc] initWithAttributedString:scaledString];
|
} else {
|
||||||
|
BOOL isScaled = [self isScaled];
|
||||||
|
[[self context] performBlockWithLockedTextKitComponents:^(NSLayoutManager *layoutManager, NSTextStorage *textStorage, NSTextContainer *textContainer) {
|
||||||
|
|
||||||
[textStorage removeLayoutManager:layoutManager];
|
NSTextStorage *scaledTextStorage = nil;
|
||||||
[scaledTextStorage addLayoutManager:layoutManager];
|
|
||||||
}
|
if (isScaled) {
|
||||||
|
// if we are going to scale the text, swap out the non-scaled text for the scaled version.
|
||||||
LOG(@"usedRect: %@", NSStringFromCGRect([layoutManager usedRectForTextContainer:textContainer]));
|
NSMutableAttributedString *scaledString = [[NSMutableAttributedString alloc] initWithAttributedString:textStorage];
|
||||||
NSRange glyphRange = [layoutManager glyphRangeForBoundingRect:CGRectMake(0,0,textContainer.size.width, textContainer.size.height) inTextContainer:textContainer];
|
[ASTextKitFontSizeAdjuster adjustFontSizeForAttributeString:scaledString withScaleFactor:_currentScaleFactor];
|
||||||
LOG(@"boundingRect: %@", NSStringFromCGRect([layoutManager boundingRectForGlyphRange:glyphRange inTextContainer:textContainer]));
|
scaledTextStorage = [[NSTextStorage alloc] initWithAttributedString:scaledString];
|
||||||
|
|
||||||
[layoutManager drawBackgroundForGlyphRange:glyphRange atPoint:shadowInsetBounds.origin];
|
[textStorage removeLayoutManager:layoutManager];
|
||||||
[layoutManager drawGlyphsForGlyphRange:glyphRange atPoint:shadowInsetBounds.origin];
|
[scaledTextStorage addLayoutManager:layoutManager];
|
||||||
|
}
|
||||||
if (isScaled) {
|
|
||||||
// put the non-scaled version back
|
LOG(@"usedRect: %@", NSStringFromCGRect([layoutManager usedRectForTextContainer:textContainer]));
|
||||||
[scaledTextStorage removeLayoutManager:layoutManager];
|
|
||||||
[textStorage addLayoutManager:layoutManager];
|
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();
|
UIGraphicsPopContext();
|
||||||
CGContextRestoreGState(context);
|
CGContextRestoreGState(context);
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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 |
Loading…
x
Reference in New Issue
Block a user