Skip to content

Commit

Permalink
Use CALayers to draw text (#24387)
Browse files Browse the repository at this point in the history
Summary:
The current technique we use to draw text uses linear memory, which means that when text is too long the UIView layer is unable to draw it. This causes the issue described [here](#19453). On an iOS simulator the bug happens at around 500 lines which is quite annoying. It can also happen on a real device but requires a lot more text.

To be more specific the amount of text doesn't actually matter, it is the size of the UIView that we use to draw the text. When we use `[drawRect:]` the view creates a bitmap to send to the gpu to render, if that bitmap is too big it cannot render.

To fix this we can use `CATiledLayer` which will split drawing into smaller parts, that gets executed when the content is about to be visible. This drawing is also async which means the text can seem to appear during scroll. See https://developer.apple.com/documentation/quartzcore/calayer?language=objc.

`CATiledLayer` also adds some overhead that we don't want when rendering small amount of text. To fix this we can use either a regular `CALayer` or a `CATiledLayer` depending on the size of the view containing the text. I picked 1024 as the threshold which is about 1 screen and a half, and is still smaller than the height needed for the bug to occur when using a regular `CALayer` on a iOS simulator.

Also found this which addresses the problem in a similar manner and took some inspiration from the code linked there GitHawkApp/StyledTextKit#14 (comment)

Fixes #19453

## Changelog

[iOS] [Fixed] - Use CALayers to draw text, fixes rendering for long text
Pull Request resolved: #24387

Test Plan:
- Added the example I was using to verify the fix to RNTester.
- Made sure all other examples are still rendering properly.
- Tested text selection

Reviewed By: shergin

Differential Revision: D15918277

Pulled By: sammy-SC

fbshipit-source-id: c45409a8413e6e3ad272be39ba527a4e8d349e28
  • Loading branch information
janicduplessis authored and kelset committed Jun 28, 2019
1 parent 99bc31c commit b68966e
Show file tree
Hide file tree
Showing 6 changed files with 222 additions and 31 deletions.
7 changes: 7 additions & 0 deletions Libraries/Text/RCTText.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
objects = {

/* Begin PBXBuildFile section */
19461666225DC3B300E4E008 /* RCTTextRenderer.m in Sources */ = {isa = PBXBuildFile; fileRef = 19461664225DC3B300E4E008 /* RCTTextRenderer.m */; };
5956B130200FEBAA008D9D16 /* RCTRawTextShadowView.m in Sources */ = {isa = PBXBuildFile; fileRef = 5956B0FD200FEBA9008D9D16 /* RCTRawTextShadowView.m */; };
5956B131200FEBAA008D9D16 /* RCTRawTextViewManager.m in Sources */ = {isa = PBXBuildFile; fileRef = 5956B0FE200FEBA9008D9D16 /* RCTRawTextViewManager.m */; };
5956B132200FEBAA008D9D16 /* RCTSinglelineTextInputView.m in Sources */ = {isa = PBXBuildFile; fileRef = 5956B101200FEBA9008D9D16 /* RCTSinglelineTextInputView.m */; };
Expand Down Expand Up @@ -187,6 +188,8 @@
/* End PBXCopyFilesBuildPhase section */

/* Begin PBXFileReference section */
19461664225DC3B300E4E008 /* RCTTextRenderer.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RCTTextRenderer.m; sourceTree = "<group>"; };
19461665225DC3B300E4E008 /* RCTTextRenderer.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = RCTTextRenderer.h; sourceTree = "<group>"; };
2D2A287B1D9B048500D4039D /* libRCTText-tvOS.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libRCTText-tvOS.a"; sourceTree = BUILT_PRODUCTS_DIR; };
58B5119B1A9E6C1200147676 /* libRCTText.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = libRCTText.a; sourceTree = BUILT_PRODUCTS_DIR; };
5956B0F9200FEBA9008D9D16 /* RCTConvert+Text.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "RCTConvert+Text.h"; sourceTree = "<group>"; };
Expand Down Expand Up @@ -360,6 +363,8 @@
children = (
5956B129200FEBAA008D9D16 /* NSTextStorage+FontScaling.h */,
5956B125200FEBAA008D9D16 /* NSTextStorage+FontScaling.m */,
19461665225DC3B300E4E008 /* RCTTextRenderer.h */,
19461664225DC3B300E4E008 /* RCTTextRenderer.m */,
5956B126200FEBAA008D9D16 /* RCTTextShadowView.h */,
5956B122200FEBAA008D9D16 /* RCTTextShadowView.m */,
5956B123200FEBAA008D9D16 /* RCTTextView.h */,
Expand Down Expand Up @@ -439,6 +444,7 @@
developmentRegion = English;
hasScannedForEncodings = 0;
knownRegions = (
English,
en,
);
mainGroup = 58B511921A9E6C1200147676;
Expand Down Expand Up @@ -504,6 +510,7 @@
5956B142200FEBAA008D9D16 /* RCTTextViewManager.m in Sources */,
5956B135200FEBAA008D9D16 /* RCTBaseTextInputView.m in Sources */,
5956B144200FEBAA008D9D16 /* RCTVirtualTextViewManager.m in Sources */,
19461666225DC3B300E4E008 /* RCTTextRenderer.m in Sources */,
5C245F39205E216A00D936E9 /* RCTInputAccessoryShadowView.m in Sources */,
5956B13B200FEBAA008D9D16 /* RCTMultilineTextInputViewManager.m in Sources */,
5956B134200FEBAA008D9D16 /* RCTSinglelineTextInputViewManager.m in Sources */,
Expand Down
23 changes: 23 additions & 0 deletions Libraries/Text/Text/RCTTextRenderer.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

#import <UIKit/UIKit.h>

NS_ASSUME_NONNULL_BEGIN

/**
* Used by text layers to render text. Note that UIKit crashes if this delegate is implemented
* directly on a UIView subclass since it already implements it for the view's root
* layer. This is why this is implemented in a separate class.
*/
@interface RCTTextRenderer : NSObject <CALayerDelegate>

- (void)setTextStorage:(NSTextStorage *)textStorage contentFrame:(CGRect)contentFrame;

@end

NS_ASSUME_NONNULL_END
56 changes: 56 additions & 0 deletions Libraries/Text/Text/RCTTextRenderer.m
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

#import "RCTTextRenderer.h"

#import "RCTTextAttributes.h"

@implementation RCTTextRenderer
{
NSTextStorage *_Nullable _textStorage;
CGRect _contentFrame;
}

- (void)setTextStorage:(NSTextStorage *)textStorage
contentFrame:(CGRect)contentFrame
{
_textStorage = textStorage;
_contentFrame = contentFrame;
}

- (void)drawLayer:(CALayer *)layer
inContext:(CGContextRef)ctx;
{
if (!_textStorage) {
return;
}

CGRect boundingBox = CGContextGetClipBoundingBox(ctx);
CGContextSaveGState(ctx);
UIGraphicsPushContext(ctx);

NSLayoutManager *layoutManager = _textStorage.layoutManagers.firstObject;
NSTextContainer *textContainer = layoutManager.textContainers.firstObject;

NSRange glyphRange =
[layoutManager glyphRangeForBoundingRect:boundingBox
inTextContainer:textContainer];

[layoutManager drawBackgroundForGlyphRange:glyphRange atPoint:_contentFrame.origin];
[layoutManager drawGlyphsForGlyphRange:glyphRange atPoint:_contentFrame.origin];

UIGraphicsPopContext();
CGContextRestoreGState(ctx);
}

- (id<CAAction>)actionForLayer:(CALayer *)layer forKey:(NSString *)event
{
// Disable all implicit animations.
return (id)[NSNull null];
}

@end
144 changes: 113 additions & 31 deletions Libraries/Text/Text/RCTTextView.m
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,36 @@
#import <React/UIView+React.h>

#import "RCTTextShadowView.h"
#import "RCTTextRenderer.h"

@interface RCTTextTiledLayer : CATiledLayer

@end

@implementation RCTTextTiledLayer

+ (CFTimeInterval)fadeDuration
{
return 0.05;
}

@end

@implementation RCTTextView
{
CAShapeLayer *_highlightLayer;
UILongPressGestureRecognizer *_longPressGestureRecognizer;

NSArray<UIView *> *_Nullable _descendantViews;
NSTextStorage *_Nullable _textStorage;
CGRect _contentFrame;
RCTTextRenderer *_renderer;
// For small amount of text avoid the overhead of CATiledLayer and
// make render text synchronously. For large amount of text, use
// CATiledLayer to chunk text rendering and avoid linear memory
// usage.
CALayer *_Nullable _syncLayer;
RCTTextTiledLayer *_Nullable _asyncTiledLayer;
CAShapeLayer *_highlightLayer;
}

- (instancetype)initWithFrame:(CGRect)frame
Expand All @@ -31,6 +52,7 @@ - (instancetype)initWithFrame:(CGRect)frame
self.accessibilityTraits |= UIAccessibilityTraitStaticText;
self.opaque = NO;
self.contentMode = UIViewContentModeRedraw;
_renderer = [RCTTextRenderer new];
}
return self;
}
Expand Down Expand Up @@ -65,6 +87,7 @@ - (void)reactSetFrame:(CGRect)frame
// This disables the frame animation, without affecting opacity, etc.
[UIView performWithoutAnimation:^{
[super reactSetFrame:frame];
[self configureLayer];
}];
}

Expand All @@ -91,55 +114,101 @@ - (void)setTextStorage:(NSTextStorage *)textStorage
[self addSubview:view];
}

[self setNeedsDisplay];
[_renderer setTextStorage:textStorage contentFrame:contentFrame];
[self configureLayer];
[self setCurrentLayerNeedsDisplay];
}

- (void)drawRect:(CGRect)rect
- (void)configureLayer
{
if (!_textStorage) {
return;
}

CALayer *currentLayer;

CGSize screenSize = RCTScreenSize();
CGFloat textViewTileSize = MAX(screenSize.width, screenSize.height) * 1.5;

if (self.frame.size.width > textViewTileSize || self.frame.size.height > textViewTileSize) {
// Cleanup sync layer
if (_syncLayer != nil) {
_syncLayer.delegate = nil;
[_syncLayer removeFromSuperlayer];
_syncLayer = nil;
}

if (_asyncTiledLayer == nil) {
RCTTextTiledLayer *layer = [RCTTextTiledLayer layer];
layer.delegate = _renderer;
layer.contentsScale = RCTScreenScale();
layer.tileSize = CGSizeMake(textViewTileSize, textViewTileSize);
_asyncTiledLayer = layer;
[self.layer addSublayer:layer];
[layer setNeedsDisplay];
}
_asyncTiledLayer.frame = self.bounds;
currentLayer = _asyncTiledLayer;
} else {
// Cleanup async tiled layer
if (_asyncTiledLayer != nil) {
_asyncTiledLayer.delegate = nil;
[_asyncTiledLayer removeFromSuperlayer];
_asyncTiledLayer = nil;
}

if (_syncLayer == nil) {
CALayer *layer = [CALayer layer];
layer.delegate = _renderer;
layer.contentsScale = RCTScreenScale();
_syncLayer = layer;
[self.layer addSublayer:layer];
[layer setNeedsDisplay];
}
_syncLayer.frame = self.bounds;
currentLayer = _syncLayer;
}

NSLayoutManager *layoutManager = _textStorage.layoutManagers.firstObject;
NSTextContainer *textContainer = layoutManager.textContainers.firstObject;

NSRange glyphRange = [layoutManager glyphRangeForTextContainer:textContainer];
[layoutManager drawBackgroundForGlyphRange:glyphRange atPoint:_contentFrame.origin];
[layoutManager drawGlyphsForGlyphRange:glyphRange atPoint:_contentFrame.origin];
NSRange glyphRange =
[layoutManager glyphRangeForTextContainer:textContainer];

__block UIBezierPath *highlightPath = nil;
NSRange characterRange = [layoutManager characterRangeForGlyphRange:glyphRange
actualGlyphRange:NULL];

[_textStorage enumerateAttribute:RCTTextAttributesIsHighlightedAttributeName
inRange:characterRange
options:0
usingBlock:
^(NSNumber *value, NSRange range, __unused BOOL *stop) {
if (!value.boolValue) {
return;
}

[layoutManager enumerateEnclosingRectsForGlyphRange:range
withinSelectedGlyphRange:range
inTextContainer:textContainer
usingBlock:
^(CGRect enclosingRect, __unused BOOL *anotherStop) {
UIBezierPath *path = [UIBezierPath bezierPathWithRoundedRect:CGRectInset(enclosingRect, -2, -2) cornerRadius:2];
if (highlightPath) {
[highlightPath appendPath:path];
} else {
highlightPath = path;
}
^(NSNumber *value, NSRange range, __unused BOOL *stop) {
if (!value.boolValue) {
return;
}

[layoutManager enumerateEnclosingRectsForGlyphRange:range
withinSelectedGlyphRange:range
inTextContainer:textContainer
usingBlock:
^(CGRect enclosingRect, __unused BOOL *anotherStop) {
UIBezierPath *path = [UIBezierPath bezierPathWithRoundedRect:CGRectInset(enclosingRect, -2, -2) cornerRadius:2];
if (highlightPath) {
[highlightPath appendPath:path];
} else {
highlightPath = path;
}
}
];
}];
}];

if (highlightPath) {
if (!_highlightLayer) {
_highlightLayer = [CAShapeLayer layer];
_highlightLayer.fillColor = [UIColor colorWithWhite:0 alpha:0.25].CGColor;
[self.layer addSublayer:_highlightLayer];
}
if (![currentLayer.sublayers containsObject:_highlightLayer]) {
[currentLayer addSublayer:_highlightLayer];
}
_highlightLayer.position = _contentFrame.origin;
_highlightLayer.path = highlightPath.CGPath;
Expand All @@ -149,6 +218,15 @@ - (void)drawRect:(CGRect)rect
}
}

- (void)setCurrentLayerNeedsDisplay
{
if (_asyncTiledLayer != nil) {
[_asyncTiledLayer setNeedsDisplay];
} else if (_syncLayer != nil) {
[_syncLayer setNeedsDisplay];
}
[_highlightLayer setNeedsDisplay];
}

- (NSNumber *)reactTagAtPoint:(CGPoint)point
{
Expand All @@ -174,14 +252,18 @@ - (void)didMoveToWindow
{
[super didMoveToWindow];

// When an `RCTText` instance moves offscreen (possibly due to parent clipping),
// we unset the layer's contents until it comes onscreen again.
if (!self.window) {
self.layer.contents = nil;
if (_highlightLayer) {
[_highlightLayer removeFromSuperlayer];
_highlightLayer = nil;
}
[_syncLayer removeFromSuperlayer];
_syncLayer = nil;
[_asyncTiledLayer removeFromSuperlayer];
_asyncTiledLayer = nil;
[_highlightLayer removeFromSuperlayer];
_highlightLayer = nil;
} else if (_textStorage) {
[self setNeedsDisplay];
[self configureLayer];
[self setCurrentLayerNeedsDisplay];
}
}

Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
23 changes: 23 additions & 0 deletions RNTester/js/TextExample.ios.js
Original file line number Diff line number Diff line change
Expand Up @@ -453,6 +453,23 @@ class TextWithCapBaseBox extends React.Component<*, *> {
}
}

function LongTextExample() {
const [collapsed, setCollapsed] = React.useState(true);
return (
<View>
<Button
onPress={() => setCollapsed(state => !state)}
title="Toggle long text"
/>
<Text>
{Array.from({length: collapsed ? 5 : 5000})
.map((_, i) => i)
.join('\n')}
</Text>
</View>
);
}

exports.title = '<Text>';
exports.description = 'Base component for rendering styled text.';
exports.displayName = 'TextExample';
Expand Down Expand Up @@ -1125,4 +1142,10 @@ exports.examples = [
);
},
},
{
title: 'Async rendering for long text',
render: function() {
return <LongTextExample />;
},
},
];

0 comments on commit b68966e

Please sign in to comment.