mirror of
https://github.com/zhigang1992/react-native.git
synced 2026-05-01 06:22:39 +08:00
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:
committed by
Facebook Github Bot
parent
cd263a2cc7
commit
2716f53220
@@ -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, ¤tRange, &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
|
||||
|
||||
Reference in New Issue
Block a user