The New <Text> on iOS

Summary:
This is a complete rewrite of RCTText, the part of React Native which manages Text and TextInput components.

Key points:

* It's understandable now. It follows a simple architectural pattern, and it's easy to debug and iterate. Text flow layout is a first-class citizen in React Native layout system now, not just a wired special case. It also brings entirely new possibilities such as nested interleaving <Text> and <View> components.
* All <Text>-specific APIs were removed from UIManager and co (it's about ~16 public methods which were used exclusively only by <Text>).
* It relies on new Yoga measurement/cloning API and on-dirty handler. So, it removes built-in dirty propagation subsystem from RN completely.
* It caches string fragments properly and granularly on a per-node basis which makes updating text-containing components more performant.
* It does not instantiate UIView for virtual components which reduces memory utilization.
* It drastically improves <TextInput> capabilities (e.g. rich text inside single line <TextInput> is now supported).

Screenshots:
https://cl.ly/2j3r1V0L0324
https://cl.ly/3N2V3C3d3q3R

Reviewed By: mmmulani

Differential Revision: D6617326

fbshipit-source-id: 35d4d81b35c9870e9557d0211c0e934e6072a41e
This commit is contained in:
Valentin Shergin
2018-01-23 23:17:57 -08:00
committed by Facebook Github Bot
parent cd263a2cc7
commit 2716f53220
57 changed files with 2462 additions and 2115 deletions

View File

@@ -17,11 +17,15 @@
#import <React/RCTUtils.h>
#import <React/UIView+React.h>
#import "RCTTextAttributes.h"
#import "RCTTextSelection.h"
@implementation RCTBaseTextInputView {
CGSize _previousContentSize;
__weak RCTBridge *_bridge;
__weak RCTEventDispatcher *_eventDispatcher;
BOOL _hasInputAccesoryView;
NSString *_Nullable _predictedText;
NSInteger _nativeEventCount;
}
- (instancetype)initWithBridge:(RCTBridge *)bridge
@@ -31,8 +35,6 @@
if (self = [super initWithFrame:CGRectZero]) {
_bridge = bridge;
_eventDispatcher = bridge.eventDispatcher;
_fontAttributes = [[RCTFontAttributes alloc] initWithAccessibilityManager:bridge.accessibilityManager];
_fontAttributes.delegate = self;
}
return self;
@@ -42,25 +44,39 @@ RCT_NOT_IMPLEMENTED(- (instancetype)init)
RCT_NOT_IMPLEMENTED(- (instancetype)initWithCoder:(NSCoder *)decoder)
RCT_NOT_IMPLEMENTED(- (instancetype)initWithFrame:(CGRect)frame)
- (id<RCTBackedTextInputViewProtocol>)backedTextInputView
- (UIView<RCTBackedTextInputViewProtocol> *)backedTextInputView
{
RCTAssert(NO, @"-[RCTBaseTextInputView backedTextInputView] must be implemented in subclass.");
return nil;
}
- (void)setFont:(UIFont *)font
{
self.backedTextInputView.font = font;
[self invalidateContentSize];
}
#pragma mark - RCTComponent
- (void)fontAttributesDidChangeWithFont:(UIFont *)font
- (void)didUpdateReactSubviews
{
self.font = font;
// Do nothing.
}
#pragma mark - Properties
- (void)setTextAttributes:(RCTTextAttributes *)textAttributes
{
_textAttributes = textAttributes;
[self enforceTextAttributesIfNeeded];
}
- (void)enforceTextAttributesIfNeeded
{
id<RCTBackedTextInputViewProtocol> backedTextInputView = self.backedTextInputView;
if (backedTextInputView.attributedText.string.length != 0) {
return;
}
backedTextInputView.font = _textAttributes.effectiveFont;
backedTextInputView.textColor = _textAttributes.effectiveForegroundColor;
backedTextInputView.textAlignment = _textAttributes.alignment;
}
- (void)setReactPaddingInsets:(UIEdgeInsets)reactPaddingInsets
{
_reactPaddingInsets = reactPaddingInsets;
@@ -77,12 +93,47 @@ RCT_NOT_IMPLEMENTED(- (instancetype)initWithFrame:(CGRect)frame)
[self setNeedsLayout];
}
- (NSAttributedString *)attributedText
{
return self.backedTextInputView.attributedText;
}
- (void)setAttributedText:(NSAttributedString *)attributedText
{
NSInteger eventLag = _nativeEventCount - _mostRecentEventCount;
if (eventLag == 0 && ![attributedText.string isEqualToString:self.backedTextInputView.attributedText.string]) {
UITextRange *selection = self.backedTextInputView.selectedTextRange;
NSInteger oldTextLength = self.backedTextInputView.attributedText.string.length;
self.backedTextInputView.attributedText = attributedText;
if (selection.empty) {
// Maintaining a cursor position relative to the end of the old text.
NSInteger offsetStart =
[self.backedTextInputView offsetFromPosition:self.backedTextInputView.beginningOfDocument
toPosition:selection.start];
NSInteger offsetFromEnd = oldTextLength - offsetStart;
NSInteger newOffset = attributedText.string.length - offsetFromEnd;
UITextPosition *position =
[self.backedTextInputView positionFromPosition:self.backedTextInputView.beginningOfDocument
offset:newOffset];
[self.backedTextInputView setSelectedTextRange:[self.backedTextInputView textRangeFromPosition:position toPosition:position]
notifyDelegate:YES];
}
[self updateLocalData];
} else if (eventLag > RCTTextUpdateLagWarningThreshold) {
RCTLogWarn(@"Native TextInput(%@) is %lld events ahead of JS - try to make your JS faster.", self.backedTextInputView.attributedText.string, (long long)eventLag);
}
}
- (RCTTextSelection *)selection
{
id<RCTBackedTextInputViewProtocol> backedTextInput = self.backedTextInputView;
UITextRange *selectedTextRange = backedTextInput.selectedTextRange;
return [[RCTTextSelection new] initWithStart:[backedTextInput offsetFromPosition:backedTextInput.beginningOfDocument toPosition:selectedTextRange.start]
end:[backedTextInput offsetFromPosition:backedTextInput.beginningOfDocument toPosition:selectedTextRange.end]];
id<RCTBackedTextInputViewProtocol> backedTextInputView = self.backedTextInputView;
UITextRange *selectedTextRange = backedTextInputView.selectedTextRange;
return [[RCTTextSelection new] initWithStart:[backedTextInputView offsetFromPosition:backedTextInputView.beginningOfDocument toPosition:selectedTextRange.start]
end:[backedTextInputView offsetFromPosition:backedTextInputView.beginningOfDocument toPosition:selectedTextRange.end]];
}
- (void)setSelection:(RCTTextSelection *)selection
@@ -91,18 +142,18 @@ RCT_NOT_IMPLEMENTED(- (instancetype)initWithFrame:(CGRect)frame)
return;
}
id<RCTBackedTextInputViewProtocol> backedTextInput = self.backedTextInputView;
id<RCTBackedTextInputViewProtocol> backedTextInputView = self.backedTextInputView;
UITextRange *previousSelectedTextRange = backedTextInput.selectedTextRange;
UITextPosition *start = [backedTextInput positionFromPosition:backedTextInput.beginningOfDocument offset:selection.start];
UITextPosition *end = [backedTextInput positionFromPosition:backedTextInput.beginningOfDocument offset:selection.end];
UITextRange *selectedTextRange = [backedTextInput textRangeFromPosition:start toPosition:end];
UITextRange *previousSelectedTextRange = backedTextInputView.selectedTextRange;
UITextPosition *start = [backedTextInputView positionFromPosition:backedTextInputView.beginningOfDocument offset:selection.start];
UITextPosition *end = [backedTextInputView positionFromPosition:backedTextInputView.beginningOfDocument offset:selection.end];
UITextRange *selectedTextRange = [backedTextInputView textRangeFromPosition:start toPosition:end];
NSInteger eventLag = _nativeEventCount - _mostRecentEventCount;
if (eventLag == 0 && ![previousSelectedTextRange isEqual:selectedTextRange]) {
[backedTextInput setSelectedTextRange:selectedTextRange notifyDelegate:NO];
[backedTextInputView setSelectedTextRange:selectedTextRange notifyDelegate:NO];
} else if (eventLag > RCTTextUpdateLagWarningThreshold) {
RCTLogWarn(@"Native TextInput(%@) is %lld events ahead of JS - try to make your JS faster.", backedTextInput.text, (long long)eventLag);
RCTLogWarn(@"Native TextInput(%@) is %lld events ahead of JS - try to make your JS faster.", backedTextInputView.attributedText.string, (long long)eventLag);
}
}
@@ -116,7 +167,7 @@ RCT_NOT_IMPLEMENTED(- (instancetype)initWithFrame:(CGRect)frame)
- (void)textInputDidBeginEditing
{
if (_clearTextOnFocus) {
self.backedTextInputView.text = @"";
self.backedTextInputView.attributedText = [NSAttributedString new];
}
if (_selectTextOnFocus) {
@@ -125,7 +176,27 @@ RCT_NOT_IMPLEMENTED(- (instancetype)initWithFrame:(CGRect)frame)
[_eventDispatcher sendTextEventWithType:RCTTextEventTypeFocus
reactTag:self.reactTag
text:self.backedTextInputView.text
text:self.backedTextInputView.attributedText.string
key:nil
eventCount:_nativeEventCount];
}
- (BOOL)textInputShouldEndEditing
{
return YES;
}
- (void)textInputDidEndEditing
{
[_eventDispatcher sendTextEventWithType:RCTTextEventTypeEnd
reactTag:self.reactTag
text:self.backedTextInputView.attributedText.string
key:nil
eventCount:_nativeEventCount];
[_eventDispatcher sendTextEventWithType:RCTTextEventTypeBlur
reactTag:self.reactTag
text:self.backedTextInputView.attributedText.string
key:nil
eventCount:_nativeEventCount];
}
@@ -139,7 +210,7 @@ RCT_NOT_IMPLEMENTED(- (instancetype)initWithFrame:(CGRect)frame)
// (no connection to any specific "submitting" process).
[_eventDispatcher sendTextEventWithType:RCTTextEventTypeSubmit
reactTag:self.reactTag
text:self.backedTextInputView.text
text:self.backedTextInputView.attributedText.string
key:nil
eventCount:_nativeEventCount];
@@ -151,6 +222,116 @@ RCT_NOT_IMPLEMENTED(- (instancetype)initWithFrame:(CGRect)frame)
// Does nothing.
}
- (BOOL)textInputShouldChangeTextInRange:(NSRange)range replacementText:(NSString *)text
{
id<RCTBackedTextInputViewProtocol> backedTextInputView = self.backedTextInputView;
if (!backedTextInputView.textWasPasted) {
[_eventDispatcher sendTextEventWithType:RCTTextEventTypeKeyPress
reactTag:self.reactTag
text:nil
key:text
eventCount:_nativeEventCount];
}
if (_maxLength) {
NSUInteger allowedLength = _maxLength.integerValue - backedTextInputView.attributedText.string.length + range.length;
if (text.length > allowedLength) {
// If we typed/pasted more than one character, limit the text inputted.
if (text.length > 1) {
// Truncate the input string so the result is exactly maxLength
NSString *limitedString = [text substringToIndex:allowedLength];
NSMutableAttributedString *newAttributedText = [backedTextInputView.attributedText mutableCopy];
[newAttributedText replaceCharactersInRange:range withString:limitedString];
backedTextInputView.attributedText = newAttributedText;
_predictedText = newAttributedText.string;
// Collapse selection at end of insert to match normal paste behavior.
UITextPosition *insertEnd = [backedTextInputView positionFromPosition:backedTextInputView.beginningOfDocument
offset:(range.location + allowedLength)];
[backedTextInputView setSelectedTextRange:[backedTextInputView textRangeFromPosition:insertEnd toPosition:insertEnd]
notifyDelegate:YES];
[self textInputDidChange];
}
return NO;
}
}
if (range.location + range.length > _predictedText.length) {
// _predictedText got out of sync in a bad way, so let's just force sync it. Haven't been able to repro this, but
// it's causing a real crash here: #6523822
_predictedText = backedTextInputView.attributedText.string;
}
NSString *previousText = [_predictedText substringWithRange:range] ?: @"";
if (_predictedText) {
_predictedText = [_predictedText stringByReplacingCharactersInRange:range withString:text];
} else {
_predictedText = text;
}
if (_onTextInput) {
_onTextInput(@{
@"text": text,
@"previousText": previousText,
@"range": @{
@"start": @(range.location),
@"end": @(range.location + range.length)
},
@"eventCount": @(_nativeEventCount),
});
}
[_eventDispatcher sendTextEventWithType:RCTTextEventTypeChange
reactTag:self.reactTag
text:backedTextInputView.attributedText.string
key:nil
eventCount:_nativeEventCount];
return YES;
}
- (void)textInputDidChange
{
[self updateLocalData];
id<RCTBackedTextInputViewProtocol> backedTextInputView = self.backedTextInputView;
// Detect when `backedTextInputView` updates happend that didn't invoke `shouldChangeTextInRange`
// (e.g. typing simplified chinese in pinyin will insert and remove spaces without
// calling shouldChangeTextInRange). This will cause JS to get out of sync so we
// update the mismatched range.
NSRange currentRange;
NSRange predictionRange;
if (findMismatch(backedTextInputView.attributedText.string, _predictedText, &currentRange, &predictionRange)) {
NSString *replacement = [backedTextInputView.attributedText.string substringWithRange:currentRange];
[self textInputShouldChangeTextInRange:predictionRange replacementText:replacement];
// JS will assume the selection changed based on the location of our shouldChangeTextInRange, so reset it.
[self textInputDidChangeSelection];
_predictedText = backedTextInputView.attributedText.string;
}
_nativeEventCount++;
if (_onChange) {
_onChange(@{
@"text": self.attributedText.string,
@"target": self.reactTag,
@"eventCount": @(_nativeEventCount),
});
}
[_eventDispatcher sendTextEventWithType:RCTTextEventTypeChange
reactTag:self.reactTag
text:backedTextInputView.attributedText.string
key:nil
eventCount:_nativeEventCount];
}
- (void)textInputDidChangeSelection
{
if (!_onSelectionChange) {
@@ -158,6 +339,7 @@ RCT_NOT_IMPLEMENTED(- (instancetype)initWithFrame:(CGRect)frame)
}
RCTTextSelection *selection = self.selection;
_onSelectionChange(@{
@"selection": @{
@"start": @(selection.start),
@@ -166,59 +348,12 @@ RCT_NOT_IMPLEMENTED(- (instancetype)initWithFrame:(CGRect)frame)
});
}
- (BOOL)textInputShouldEndEditing
- (void)updateLocalData
{
return YES;
}
[self enforceTextAttributesIfNeeded];
- (void)textInputDidEndEditing
{
[_eventDispatcher sendTextEventWithType:RCTTextEventTypeEnd
reactTag:self.reactTag
text:self.backedTextInputView.text
key:nil
eventCount:_nativeEventCount];
[_eventDispatcher sendTextEventWithType:RCTTextEventTypeBlur
reactTag:self.reactTag
text:self.backedTextInputView.text
key:nil
eventCount:_nativeEventCount];
}
#pragma mark - Content Size (in Yoga terms, without any insets)
- (CGSize)contentSize
{
CGSize contentSize = self.backedTextInputView.contentSize;
UIEdgeInsets reactPaddingInsets = self.reactPaddingInsets;
contentSize.width -= reactPaddingInsets.left + reactPaddingInsets.right;
contentSize.height -= reactPaddingInsets.top + reactPaddingInsets.bottom;
// Returning value does NOT include border and padding insets.
return contentSize;
}
- (void)invalidateContentSize
{
// Updates `contentSize` property and notifies Yoga about the change, if necessary.
CGSize contentSize = self.contentSize;
if (CGSizeEqualToSize(_previousContentSize, contentSize)) {
return;
}
_previousContentSize = contentSize;
[_bridge.uiManager setIntrinsicContentSize:contentSize forView:self];
if (_onContentSizeChange) {
_onContentSizeChange(@{
@"contentSize": @{
@"height": @(contentSize.height),
@"width": @(contentSize.width),
},
@"target": self.reactTag,
});
}
[_bridge.uiManager setLocalData:[self.backedTextInputView.attributedText copy]
forView:self];
}
#pragma mark - Layout (in UIKit terms, with all insets)
@@ -251,12 +386,6 @@ RCT_NOT_IMPLEMENTED(- (instancetype)initWithFrame:(CGRect)frame)
return fittingSize;
}
- (void)layoutSubviews
{
[super layoutSubviews];
[self invalidateContentSize];
}
#pragma mark - Accessibility
- (UIView *)reactAccessibilityElement
@@ -343,4 +472,35 @@ RCT_NOT_IMPLEMENTED(- (instancetype)initWithFrame:(CGRect)frame)
}
}
#pragma mark - Helpers
static BOOL findMismatch(NSString *first, NSString *second, NSRange *firstRange, NSRange *secondRange)
{
NSInteger firstMismatch = -1;
for (NSUInteger ii = 0; ii < MAX(first.length, second.length); ii++) {
if (ii >= first.length || ii >= second.length || [first characterAtIndex:ii] != [second characterAtIndex:ii]) {
firstMismatch = ii;
break;
}
}
if (firstMismatch == -1) {
return NO;
}
NSUInteger ii = second.length;
NSUInteger lastMismatch = first.length;
while (ii > firstMismatch && lastMismatch > firstMismatch) {
if ([first characterAtIndex:(lastMismatch - 1)] != [second characterAtIndex:(ii - 1)]) {
break;
}
ii--;
lastMismatch--;
}
*firstRange = NSMakeRange(firstMismatch, lastMismatch - firstMismatch);
*secondRange = NSMakeRange(firstMismatch, ii - firstMismatch);
return YES;
}
@end