diff --git a/Libraries/Components/TextInput/TextInput.js b/Libraries/Components/TextInput/TextInput.js index 2c7c80e99..2df8d2781 100644 --- a/Libraries/Components/TextInput/TextInput.js +++ b/Libraries/Components/TextInput/TextInput.js @@ -11,37 +11,32 @@ */ 'use strict'; -var ColorPropType = require('ColorPropType'); -var DocumentSelectionState = require('DocumentSelectionState'); -var EventEmitter = require('EventEmitter'); -var NativeMethodsMixin = require('NativeMethodsMixin'); -var Platform = require('Platform'); -var PropTypes = require('ReactPropTypes'); -var React = require('React'); -var ReactNative = require('ReactNative'); -var ReactChildren = require('ReactChildren'); -var StyleSheet = require('StyleSheet'); -var Text = require('Text'); -var TextInputState = require('TextInputState'); -var TimerMixin = require('react-timer-mixin'); -var TouchableWithoutFeedback = require('TouchableWithoutFeedback'); -var UIManager = require('UIManager'); -var View = require('View'); +const ColorPropType = require('ColorPropType'); +const DocumentSelectionState = require('DocumentSelectionState'); +const EventEmitter = require('EventEmitter'); +const NativeMethodsMixin = require('NativeMethodsMixin'); +const Platform = require('Platform'); +const PropTypes = require('ReactPropTypes'); +const React = require('React'); +const ReactNative = require('ReactNative'); +const ReactChildren = require('ReactChildren'); +const StyleSheet = require('StyleSheet'); +const Text = require('Text'); +const TextInputState = require('TextInputState'); +const TimerMixin = require('react-timer-mixin'); +const TouchableWithoutFeedback = require('TouchableWithoutFeedback'); +const UIManager = require('UIManager'); +const View = require('View'); -var createReactNativeComponentClass = require('createReactNativeComponentClass'); -var emptyFunction = require('fbjs/lib/emptyFunction'); -var invariant = require('fbjs/lib/invariant'); -var requireNativeComponent = require('requireNativeComponent'); +const emptyFunction = require('fbjs/lib/emptyFunction'); +const invariant = require('fbjs/lib/invariant'); +const requireNativeComponent = require('requireNativeComponent'); -var onlyMultiline = { - onTextInput: true, // not supported in Open Source yet +const onlyMultiline = { + onTextInput: true, children: true, }; -var notMultiline = { - // nothing yet -}; - if (Platform.OS === 'android') { var AndroidTextInput = requireNativeComponent('AndroidTextInput', null); } else if (Platform.OS === 'ios') { @@ -90,7 +85,7 @@ type Event = Object; * `underlineColorAndroid` to transparent. * */ -var TextInput = React.createClass({ +const TextInput = React.createClass({ statics: { /* TODO(brentvatne) docs are needed for this */ State: TextInputState, @@ -472,14 +467,6 @@ var TextInput = React.createClass({ text={this._getText()} />; } else { - for (var propKey in notMultiline) { - if (props[propKey]) { - throw new Error( - 'TextInput prop `' + propKey + '` cannot be used with multiline.' - ); - } - } - var children = props.children; var childCount = 0; ReactChildren.forEach(children, () => ++childCount); diff --git a/Libraries/Text/RCTTextView.h b/Libraries/Text/RCTTextView.h index 93a93d409..8268e7a53 100644 --- a/Libraries/Text/RCTTextView.h +++ b/Libraries/Text/RCTTextView.h @@ -30,6 +30,7 @@ @property (nonatomic, copy) RCTDirectEventBlock onChange; @property (nonatomic, copy) RCTDirectEventBlock onSelectionChange; +@property (nonatomic, copy) RCTDirectEventBlock onTextInput; - (instancetype)initWithEventDispatcher:(RCTEventDispatcher *)eventDispatcher NS_DESIGNATED_INITIALIZER; diff --git a/Libraries/Text/RCTTextView.m b/Libraries/Text/RCTTextView.m index b8a396aa8..94e99af4c 100644 --- a/Libraries/Text/RCTTextView.m +++ b/Libraries/Text/RCTTextView.m @@ -61,17 +61,22 @@ @implementation RCTTextView { RCTEventDispatcher *_eventDispatcher; + NSString *_placeholder; UITextView *_placeholderView; UITextView *_textView; - NSInteger _nativeEventCount; RCTText *_richTextView; NSAttributedString *_pendingAttributedText; - BOOL _blockTextShouldChange; + UIScrollView *_scrollView; + UITextRange *_previousSelectionRange; NSUInteger _previousTextLength; CGFloat _previousContentHeight; - UIScrollView *_scrollView; + NSString *_predictedText; + + BOOL _blockTextShouldChange; + BOOL _nativeUpdatesInFlight; + NSInteger _nativeEventCount; } - (instancetype)initWithEventDispatcher:(RCTEventDispatcher *)eventDispatcher @@ -179,7 +184,7 @@ static NSAttributedString *removeReactTagFromString(NSAttributedString *string) - (void)performPendingTextUpdate { - if (!_pendingAttributedText || _mostRecentEventCount < _nativeEventCount) { + if (!_pendingAttributedText || _mostRecentEventCount < _nativeEventCount || _nativeUpdatesInFlight) { return; } @@ -205,6 +210,7 @@ static NSAttributedString *removeReactTagFromString(NSAttributedString *string) NSInteger oldTextLength = _textView.attributedText.length; _textView.attributedText = _pendingAttributedText; + _predictedText = _pendingAttributedText.string; _pendingAttributedText = nil; if (selection.empty) { @@ -218,7 +224,7 @@ static NSAttributedString *removeReactTagFromString(NSAttributedString *string) [_textView layoutIfNeeded]; - [self _setPlaceholderVisibility]; + [self updatePlaceholderVisibility]; _blockTextShouldChange = NO; } @@ -267,7 +273,7 @@ static NSAttributedString *removeReactTagFromString(NSAttributedString *string) _placeholderView.editable = NO; _placeholderView.userInteractionEnabled = NO; _placeholderView.backgroundColor = [UIColor clearColor]; - _placeholderView.scrollEnabled = false; + _placeholderView.scrollEnabled = NO; _placeholderView.scrollsToTop = NO; _placeholderView.attributedText = [[NSAttributedString alloc] initWithString:_placeholder attributes:@{ @@ -277,7 +283,7 @@ static NSAttributedString *removeReactTagFromString(NSAttributedString *string) _placeholderView.textAlignment = _textView.textAlignment; [self insertSubview:_placeholderView belowSubview:_textView]; - [self _setPlaceholderVisibility]; + [self updatePlaceholderVisibility]; } } @@ -314,21 +320,11 @@ static NSAttributedString *removeReactTagFromString(NSAttributedString *string) [self updateFrames]; } -- (NSString *)text -{ - return _textView.text; -} - - (BOOL)textView:(RCTUITextView *)textView shouldChangeTextInRange:(NSRange)range replacementText:(NSString *)text { - if (_blockTextShouldChange) { - return NO; - } - if (textView.textWasPasted) { textView.textWasPasted = NO; } else { - [_eventDispatcher sendTextEventWithType:RCTTextEventTypeKeyPress reactTag:self.reactTag text:nil @@ -336,7 +332,6 @@ static NSAttributedString *removeReactTagFromString(NSAttributedString *string) eventCount:_nativeEventCount]; if (_blurOnSubmit && [text isEqualToString:@"\n"]) { - // TODO: the purpose of blurOnSubmit on RCTextField is to decide if the // field should lose focus when return is pressed or not. We're cheating a // bit here by using it on RCTextView to decide if return character should @@ -348,7 +343,6 @@ static NSAttributedString *removeReactTagFromString(NSAttributedString *string) // where _blurOnSubmit = YES, this is still the correct and expected // behavior though, so we'll leave the don't-blur-or-add-newline problem // to be solved another day. - [_eventDispatcher sendTextEventWithType:RCTTextEventTypeSubmit reactTag:self.reactTag text:self.text @@ -359,27 +353,60 @@ static NSAttributedString *removeReactTagFromString(NSAttributedString *string) } } - if (_maxLength == nil) { - return YES; - } - NSUInteger allowedLength = _maxLength.integerValue - textView.text.length + range.length; - if (text.length > allowedLength) { - if (text.length > 1) { - // Truncate the input string so the result is exactly maxLength - NSString *limitedString = [text substringToIndex:allowedLength]; - NSMutableString *newString = textView.text.mutableCopy; - [newString replaceCharactersInRange:range withString:limitedString]; - textView.text = newString; - // Collapse selection at end of insert to match normal paste behavior - UITextPosition *insertEnd = [textView positionFromPosition:textView.beginningOfDocument - offset:(range.location + allowedLength)]; - textView.selectedTextRange = [textView textRangeFromPosition:insertEnd toPosition:insertEnd]; - [self textViewDidChange:textView]; - } + // So we need to track that there is a native update in flight just in case JS manages to come back around and update + // things /before/ UITextView can update itself asynchronously. If there is a native update in flight, we defer the + // JS update when it comes in and apply the deferred update once textViewDidChange fires with the native update applied. + if (_blockTextShouldChange) { return NO; - } else { - return YES; } + + if (_maxLength) { + NSUInteger allowedLength = _maxLength.integerValue - textView.text.length + range.length; + if (text.length > allowedLength) { + if (text.length > 1) { + // Truncate the input string so the result is exactly maxLength + NSString *limitedString = [text substringToIndex:allowedLength]; + NSMutableString *newString = textView.text.mutableCopy; + [newString replaceCharactersInRange:range withString:limitedString]; + textView.text = newString; + // Collapse selection at end of insert to match normal paste behavior + UITextPosition *insertEnd = [textView positionFromPosition:textView.beginningOfDocument + offset:(range.location + allowedLength)]; + textView.selectedTextRange = [textView textRangeFromPosition:insertEnd toPosition:insertEnd]; + [self textViewDidChange:textView]; + } + return NO; + } + } + + _nativeUpdatesInFlight = YES; + + 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 = textView.text; + } + + 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), + }); + } + + return YES; } - (void)textViewDidChangeSelection:(RCTUITextView *)textView @@ -402,6 +429,11 @@ static NSAttributedString *removeReactTagFromString(NSAttributedString *string) } } +- (NSString *)text +{ + return _textView.text; +} + - (void)setText:(NSString *)text { NSInteger eventLag = _nativeEventCount - _mostRecentEventCount; @@ -409,6 +441,7 @@ static NSAttributedString *removeReactTagFromString(NSAttributedString *string) UITextRange *selection = _textView.selectedTextRange; NSInteger oldTextLength = _textView.text.length; + _predictedText = text; _textView.text = text; if (selection.empty) { @@ -420,7 +453,7 @@ static NSAttributedString *removeReactTagFromString(NSAttributedString *string) _textView.selectedTextRange = [_textView textRangeFromPosition:position toPosition:position]; } - [self _setPlaceholderVisibility]; + [self updatePlaceholderVisibility]; [self updateContentSize]; //keep the text wrapping when the length of //the textline has been extended longer than the length of textinputView } else if (eventLag > RCTTextUpdateLagWarningThreshold) { @@ -428,7 +461,7 @@ static NSAttributedString *removeReactTagFromString(NSAttributedString *string) } } -- (void)_setPlaceholderVisibility +- (void)updatePlaceholderVisibility { if (_textView.text.length > 0) { [_placeholderView setHidden:YES]; @@ -461,7 +494,7 @@ static NSAttributedString *removeReactTagFromString(NSAttributedString *string) { if (_clearTextOnFocus) { _textView.text = @""; - [self _setPlaceholderVisibility]; + [self updatePlaceholderVisibility]; } [_eventDispatcher sendTextEventWithType:RCTTextEventTypeFocus @@ -471,10 +504,55 @@ static NSAttributedString *removeReactTagFromString(NSAttributedString *string) eventCount:_nativeEventCount]; } +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; +} + - (void)textViewDidChange:(UITextView *)textView { + [self updatePlaceholderVisibility]; [self updateContentSize]; - [self _setPlaceholderVisibility]; + + // Detect when textView 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(textView.text, _predictedText, ¤tRange, &predictionRange)) { + NSString *replacement = [textView.text substringWithRange:currentRange]; + [self textView:textView shouldChangeTextInRange:predictionRange replacementText:replacement]; + // JS will assume the selection changed based on the location of our shouldChangeTextInRange, so reset it. + [self textViewDidChangeSelection:textView]; + _predictedText = textView.text; + } + + _nativeUpdatesInFlight = NO; _nativeEventCount++; if (!self.reactTag || !_onChange) { diff --git a/Libraries/Text/RCTTextViewManager.m b/Libraries/Text/RCTTextViewManager.m index c3fe596f4..97fbe5e35 100644 --- a/Libraries/Text/RCTTextViewManager.m +++ b/Libraries/Text/RCTTextViewManager.m @@ -36,6 +36,7 @@ RCT_REMAP_VIEW_PROPERTY(keyboardAppearance, textView.keyboardAppearance, UIKeybo RCT_EXPORT_VIEW_PROPERTY(maxLength, NSNumber) RCT_EXPORT_VIEW_PROPERTY(onChange, RCTBubblingEventBlock) RCT_EXPORT_VIEW_PROPERTY(onSelectionChange, RCTDirectEventBlock) +RCT_EXPORT_VIEW_PROPERTY(onTextInput, RCTDirectEventBlock) RCT_EXPORT_VIEW_PROPERTY(placeholder, NSString) RCT_EXPORT_VIEW_PROPERTY(placeholderTextColor, UIColor) RCT_REMAP_VIEW_PROPERTY(returnKeyType, textView.returnKeyType, UIReturnKeyType)