[ReactNative] TextInput bug fixes and features
Summary: This introduces event counts to make sure JS doesn't set out of date values on native text inputs, which can cause dropped characters and can mess with autocomplete, and obviates the need for the input buffering which added lag and complexity to the component. Made sure to test simulated super-slow JS text event processing to make sure characters aren't dropped, as well as typing obviously correctable words and making sure autocomplete works as expected. TextInput is now a controlled input by default without causing any issues for most cases, so I removed the `controlled` prop. Fixes selection state jumping by restoring it after setting new text values, so highlighting the middle of some text in the new ReWrite example and hitting space will replace that selection with an underscore and keep the cursor at a sensible position as expected, instead of jumping to the end. Ads `maxLength` prop to support the most commonly needed syncronous behavior: preventing the user from typing too many characters. It can also be used to prevent users from continuing to type after entering special characters by changing it to the current length after a regex match. Made sure to verify it works well with pasted input (including in the middle of existing text), truncating it and collapsing the selection the same way it does on the web. Fixes bug in TextEventsExample where it wouldn't show the submit and end events, even though there were firing correctly.
This commit is contained in:
parent
4f904b5d68
commit
961c1eb429
|
@ -33,7 +33,7 @@ var WithLabel = React.createClass({
|
|||
{this.props.children}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
var TextEventsExample = React.createClass({
|
||||
|
@ -41,13 +41,17 @@ var TextEventsExample = React.createClass({
|
|||
return {
|
||||
curText: '<No Event>',
|
||||
prevText: '<No Event>',
|
||||
prev2Text: '<No Event>',
|
||||
};
|
||||
},
|
||||
|
||||
updateText: function(text) {
|
||||
this.setState({
|
||||
this.setState((state) => {
|
||||
return {
|
||||
curText: text,
|
||||
prevText: this.state.curText,
|
||||
prevText: state.curText,
|
||||
prev2Text: state.prevText,
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
|
@ -73,13 +77,43 @@ var TextEventsExample = React.createClass({
|
|||
/>
|
||||
<Text style={styles.eventLabel}>
|
||||
{this.state.curText}{'\n'}
|
||||
(prev: {this.state.prevText})
|
||||
(prev: {this.state.prevText}){'\n'}
|
||||
(prev2: {this.state.prev2Text})
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
class RewriteExample extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {text: ''};
|
||||
}
|
||||
render() {
|
||||
var limit = 20;
|
||||
var remainder = limit - this.state.text.length;
|
||||
var remainderColor = remainder > 5 ? 'blue' : 'red';
|
||||
return (
|
||||
<View style={styles.rewriteContainer}>
|
||||
<TextInput
|
||||
multiline={false}
|
||||
maxLength={limit}
|
||||
onChangeText={(text) => {
|
||||
text = text.replace(/ /g, '_');
|
||||
this.setState({text});
|
||||
}}
|
||||
style={styles.default}
|
||||
value={this.state.text}
|
||||
/>
|
||||
<Text style={[styles.remainder, {color: remainderColor}]}>
|
||||
{remainder}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
var styles = StyleSheet.create({
|
||||
page: {
|
||||
paddingBottom: 300,
|
||||
|
@ -125,12 +159,19 @@ var styles = StyleSheet.create({
|
|||
flex: 1,
|
||||
},
|
||||
label: {
|
||||
width: 120,
|
||||
justifyContent: 'flex-end',
|
||||
flexDirection: 'row',
|
||||
width: 115,
|
||||
alignItems: 'flex-end',
|
||||
marginRight: 10,
|
||||
paddingTop: 2,
|
||||
},
|
||||
rewriteContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
remainder: {
|
||||
textAlign: 'right',
|
||||
width: 24,
|
||||
},
|
||||
});
|
||||
|
||||
exports.displayName = (undefined: ?string);
|
||||
|
@ -143,6 +184,12 @@ exports.examples = [
|
|||
return <TextInput autoFocus={true} style={styles.default} />;
|
||||
}
|
||||
},
|
||||
{
|
||||
title: "Live Re-Write (<sp> -> '_') + maxLength",
|
||||
render: function() {
|
||||
return <RewriteExample />;
|
||||
}
|
||||
},
|
||||
{
|
||||
title: 'Auto-capitalize',
|
||||
render: function() {
|
||||
|
@ -276,7 +323,7 @@ exports.examples = [
|
|||
},
|
||||
{
|
||||
title: 'Event handling',
|
||||
render: function(): ReactElement { return <TextEventsExample /> },
|
||||
render: function(): ReactElement { return <TextEventsExample />; },
|
||||
},
|
||||
{
|
||||
title: 'Colored input text',
|
||||
|
|
|
@ -31,8 +31,8 @@ var invariant = require('invariant');
|
|||
var requireNativeComponent = require('requireNativeComponent');
|
||||
|
||||
var onlyMultiline = {
|
||||
onSelectionChange: true,
|
||||
onTextInput: true,
|
||||
onSelectionChange: true, // not supported in Open Source yet
|
||||
onTextInput: true, // not supported in Open Source yet
|
||||
children: true,
|
||||
};
|
||||
|
||||
|
@ -64,10 +64,6 @@ var viewConfigAndroid = {
|
|||
var RCTTextView = requireNativeComponent('RCTTextView', null);
|
||||
var RCTTextField = requireNativeComponent('RCTTextField', null);
|
||||
|
||||
type DefaultProps = {
|
||||
bufferDelay: number;
|
||||
};
|
||||
|
||||
type Event = Object;
|
||||
|
||||
/**
|
||||
|
@ -77,30 +73,29 @@ type Event = Object;
|
|||
* types, such as a numeric keypad.
|
||||
*
|
||||
* The simplest use case is to plop down a `TextInput` and subscribe to the
|
||||
* `onChangeText` events to read the user input. There are also other events, such
|
||||
* as `onSubmitEditing` and `onFocus` that can be subscribed to. A simple
|
||||
* `onChangeText` events to read the user input. There are also other events,
|
||||
* such as `onSubmitEditing` and `onFocus` that can be subscribed to. A simple
|
||||
* example:
|
||||
*
|
||||
* ```
|
||||
* <View>
|
||||
* <TextInput
|
||||
* style={{height: 40, borderColor: 'gray', borderWidth: 1}}
|
||||
* onChangeText={(text) => this.setState({input: text})}
|
||||
* onChangeText={(text) => this.setState({text})}
|
||||
* value={this.state.text}
|
||||
* />
|
||||
* <Text>{'user input: ' + this.state.input}</Text>
|
||||
* </View>
|
||||
* ```
|
||||
*
|
||||
* The `value` prop can be used to set the value of the input in order to make
|
||||
* the state of the component clear, but <TextInput> does not behave as a true
|
||||
* controlled component by default because all operations are asynchronous.
|
||||
* Setting `value` once is like setting the default value, but you can change it
|
||||
* continuously based on `onChangeText` events as well. If you really want to
|
||||
* force the component to always revert to the value you are setting, you can
|
||||
* set `controlled={true}`.
|
||||
* Note that some props are only available with multiline={true/false}:
|
||||
*
|
||||
* The `multiline` prop is not supported in all releases, and some props are
|
||||
* multiline only.
|
||||
* var onlyMultiline = {
|
||||
* onSelectionChange: true, // not supported in Open Source yet
|
||||
* onTextInput: true, // not supported in Open Source yet
|
||||
* children: true,
|
||||
* };
|
||||
*
|
||||
* var notMultiline = {
|
||||
* onSubmitEditing: true,
|
||||
* };
|
||||
*/
|
||||
|
||||
var TextInput = React.createClass({
|
||||
|
@ -179,6 +174,11 @@ var TextInput = React.createClass({
|
|||
'done',
|
||||
'emergency-call',
|
||||
]),
|
||||
/**
|
||||
* Limits the maximum number of characters that can be entered. Use this
|
||||
* instead of implementing the logic in JS to avoid flicker.
|
||||
*/
|
||||
maxLength: PropTypes.number,
|
||||
/**
|
||||
* If true, the keyboard disables the return key when there is no text and
|
||||
* automatically enables it when there is text. Default value is false.
|
||||
|
@ -236,22 +236,15 @@ var TextInput = React.createClass({
|
|||
*/
|
||||
selectionState: PropTypes.instanceOf(DocumentSelectionState),
|
||||
/**
|
||||
* The default value for the text input
|
||||
* The value to show for the text input. TextInput is a controlled
|
||||
* component, which means the native value will be forced to match this
|
||||
* value prop if provided. For most uses this works great, but in some
|
||||
* cases this may cause flickering - one common cause is preventing edits
|
||||
* by keeping value the same. In addition to simply setting the same value,
|
||||
* either set `editable={false}`, or set/update `maxLength` to prevent
|
||||
* unwanted edits without flicker.
|
||||
*/
|
||||
value: PropTypes.string,
|
||||
/**
|
||||
* This helps avoid drops characters due to race conditions between JS and
|
||||
* the native text input. The default should be fine, but if you're
|
||||
* potentially doing very slow operations on every keystroke then you may
|
||||
* want to try increasing this.
|
||||
*/
|
||||
bufferDelay: PropTypes.number,
|
||||
/**
|
||||
* If you really want this to behave as a controlled component, you can set
|
||||
* this true, but you will probably see flickering, dropped keystrokes,
|
||||
* and/or laggy typing, depending on how you process onChange events.
|
||||
*/
|
||||
controlled: PropTypes.bool,
|
||||
/**
|
||||
* When the clear button should appear on the right side of the text view
|
||||
*/
|
||||
|
@ -297,16 +290,9 @@ var TextInput = React.createClass({
|
|||
React.findNodeHandle(this.refs.input);
|
||||
},
|
||||
|
||||
getDefaultProps: function(): DefaultProps {
|
||||
return {
|
||||
bufferDelay: 100,
|
||||
};
|
||||
},
|
||||
|
||||
getInitialState: function() {
|
||||
return {
|
||||
mostRecentEventCounter: 0,
|
||||
bufferedValue: this.props.value,
|
||||
mostRecentEventCount: 0,
|
||||
};
|
||||
},
|
||||
|
||||
|
@ -346,52 +332,6 @@ var TextInput = React.createClass({
|
|||
}
|
||||
},
|
||||
|
||||
_bufferTimeout: (undefined: ?number),
|
||||
|
||||
componentWillReceiveProps: function(newProps: {value: any}) {
|
||||
if (newProps.value !== this.props.value) {
|
||||
if (!this.isFocused()) {
|
||||
// Set the value immediately if the input is not focused since that
|
||||
// means there is no risk of the user typing immediately.
|
||||
this.setState({bufferedValue: newProps.value});
|
||||
} else {
|
||||
// The following clear and setTimeout buffers the value such that if more
|
||||
// characters are typed in quick succession, generating new values, the
|
||||
// out of date values will get cancelled before they are ever sent to
|
||||
// native.
|
||||
//
|
||||
// If we don't do this, it's likely the out of date values will blow
|
||||
// away recently typed characters in the native input that JS was not
|
||||
// yet aware of (since it is informed asynchronously), then the next
|
||||
// character will be appended to the older value, dropping the
|
||||
// characters in between. Here is a potential sequence of events
|
||||
// (recall we have multiple independently serial, interleaved queues):
|
||||
//
|
||||
// 1) User types 'R' => send 'R' to JS queue.
|
||||
// 2) User types 'e' => send 'Re' to JS queue.
|
||||
// 3) JS processes 'R' and sends 'R' back to native.
|
||||
// 4) Native recieves 'R' and changes input from 'Re' back to 'R'.
|
||||
// 5) User types 'a' => send 'Ra' to JS queue.
|
||||
// 6) JS processes 'Re' and sends 'Re' back to native.
|
||||
// 7) Native recieves 'Re' and changes input from 'R' back to 'Re'.
|
||||
// 8) JS processes 'Ra' and sends 'Ra' back to native.
|
||||
// 9) Native recieves final 'Ra' from JS - 'e' has been dropped!
|
||||
//
|
||||
// This isn't 100% foolproop (e.g. if it takes longer than
|
||||
// `props.bufferDelay` ms to process one keystroke), and there are of
|
||||
// course other potential algorithms to deal with this, but this is a
|
||||
// simple solution that seems to reduce the chance of dropped characters
|
||||
// drastically without compromising native input responsiveness (e.g. by
|
||||
// introducing delay from a synchronization protocol).
|
||||
this.clearTimeout(this._bufferTimeout);
|
||||
this._bufferTimeout = this.setTimeout(
|
||||
() => this.setState({bufferedValue: newProps.value}),
|
||||
this.props.bufferDelay
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
getChildContext: function(): Object {
|
||||
return {isInAParentText: true};
|
||||
},
|
||||
|
@ -430,7 +370,8 @@ var TextInput = React.createClass({
|
|||
onBlur={this._onBlur}
|
||||
onChange={this._onChange}
|
||||
onSelectionChangeShouldSetResponder={() => true}
|
||||
text={this.state.bufferedValue}
|
||||
text={this.props.value}
|
||||
mostRecentEventCount={this.state.mostRecentEventCount}
|
||||
/>;
|
||||
} else {
|
||||
for (var propKey in notMultiline) {
|
||||
|
@ -459,14 +400,14 @@ var TextInput = React.createClass({
|
|||
ref="input"
|
||||
{...props}
|
||||
children={children}
|
||||
mostRecentEventCounter={this.state.mostRecentEventCounter}
|
||||
mostRecentEventCount={this.state.mostRecentEventCount}
|
||||
onFocus={this._onFocus}
|
||||
onBlur={this._onBlur}
|
||||
onChange={this._onChange}
|
||||
onSelectionChange={this._onSelectionChange}
|
||||
onTextInput={this._onTextInput}
|
||||
onSelectionChangeShouldSetResponder={emptyFunction.thatReturnsTrue}
|
||||
text={this.state.bufferedValue}
|
||||
text={this.props.value}
|
||||
/>;
|
||||
}
|
||||
|
||||
|
@ -516,7 +457,7 @@ var TextInput = React.createClass({
|
|||
password={this.props.password || this.props.secureTextEntry}
|
||||
placeholder={this.props.placeholder}
|
||||
placeholderTextColor={this.props.placeholderTextColor}
|
||||
text={this.state.bufferedValue}
|
||||
text={this.props.value}
|
||||
underlineColorAndroid={this.props.underlineColorAndroid}
|
||||
children={children}
|
||||
/>;
|
||||
|
@ -543,11 +484,20 @@ var TextInput = React.createClass({
|
|||
},
|
||||
|
||||
_onChange: function(event: Event) {
|
||||
if (this.props.controlled && event.nativeEvent.text !== this.props.value) {
|
||||
this.refs.input.setNativeProps({text: this.props.value});
|
||||
}
|
||||
var text = event.nativeEvent.text;
|
||||
var eventCount = event.nativeEvent.eventCount;
|
||||
this.props.onChange && this.props.onChange(event);
|
||||
this.props.onChangeText && this.props.onChangeText(event.nativeEvent.text);
|
||||
this.props.onChangeText && this.props.onChangeText(text);
|
||||
this.setState({mostRecentEventCount: eventCount}, () => {
|
||||
// This is a controlled component, so make sure to force the native value
|
||||
// to match. Most usage shouldn't need this, but if it does this will be
|
||||
// more correct but might flicker a bit and/or cause the cursor to jump.
|
||||
if (text !== this.props.value && typeof this.props.value === 'string') {
|
||||
this.refs.input.setNativeProps({
|
||||
text: this.props.value,
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
_onBlur: function(event: Event) {
|
||||
|
@ -567,10 +517,6 @@ var TextInput = React.createClass({
|
|||
|
||||
_onTextInput: function(event: Event) {
|
||||
this.props.onTextInput && this.props.onTextInput(event);
|
||||
var counter = event.nativeEvent.eventCounter;
|
||||
if (counter > this.state.mostRecentEventCounter) {
|
||||
this.setState({mostRecentEventCounter: counter});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
@ -11,13 +11,15 @@
|
|||
|
||||
@class RCTEventDispatcher;
|
||||
|
||||
@interface RCTTextField : UITextField
|
||||
@interface RCTTextField : UITextField<UITextFieldDelegate>
|
||||
|
||||
@property (nonatomic, assign) BOOL caretHidden;
|
||||
@property (nonatomic, assign) BOOL autoCorrect;
|
||||
@property (nonatomic, assign) BOOL selectTextOnFocus;
|
||||
@property (nonatomic, assign) UIEdgeInsets contentInset;
|
||||
@property (nonatomic, strong) UIColor *placeholderTextColor;
|
||||
@property (nonatomic, assign) NSInteger mostRecentEventCount;
|
||||
@property (nonatomic, strong) NSNumber *maxLength;
|
||||
|
||||
- (instancetype)initWithEventDispatcher:(RCTEventDispatcher *)eventDispatcher NS_DESIGNATED_INITIALIZER;
|
||||
|
||||
|
|
|
@ -19,6 +19,7 @@
|
|||
RCTEventDispatcher *_eventDispatcher;
|
||||
NSMutableArray *_reactSubviews;
|
||||
BOOL _jsRequestingFirstResponder;
|
||||
NSInteger _nativeEventCount;
|
||||
}
|
||||
|
||||
- (instancetype)initWithEventDispatcher:(RCTEventDispatcher *)eventDispatcher
|
||||
|
@ -31,6 +32,7 @@
|
|||
[self addTarget:self action:@selector(_textFieldEndEditing) forControlEvents:UIControlEventEditingDidEnd];
|
||||
[self addTarget:self action:@selector(_textFieldSubmitEditing) forControlEvents:UIControlEventEditingDidEndOnExit];
|
||||
_reactSubviews = [[NSMutableArray alloc] init];
|
||||
self.delegate = self;
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
@ -38,10 +40,40 @@
|
|||
RCT_NOT_IMPLEMENTED(-initWithFrame:(CGRect)frame)
|
||||
RCT_NOT_IMPLEMENTED(-initWithCoder:(NSCoder *)aDecoder)
|
||||
|
||||
- (BOOL)textField:(UITextField *)textField shouldChangeCharactersInRange:(NSRange)range replacementString:(NSString *)string
|
||||
{
|
||||
if (_maxLength == nil || [string isEqualToString:@"\n"]) { // Make sure forms can be submitted via return
|
||||
return YES;
|
||||
}
|
||||
NSUInteger allowedLength = _maxLength.integerValue - textField.text.length + range.length;
|
||||
if (string.length > allowedLength) {
|
||||
if (string.length > 1) {
|
||||
// Truncate the input string so the result is exactly maxLength
|
||||
NSString *limitedString = [string substringToIndex:allowedLength];
|
||||
NSMutableString *newString = textField.text.mutableCopy;
|
||||
[newString replaceCharactersInRange:range withString:limitedString];
|
||||
textField.text = newString;
|
||||
// Collapse selection at end of insert to match normal paste behavior
|
||||
UITextPosition *insertEnd = [textField positionFromPosition:textField.beginningOfDocument
|
||||
offset:(range.location + allowedLength)];
|
||||
textField.selectedTextRange = [textField textRangeFromPosition:insertEnd toPosition:insertEnd];
|
||||
[self _textFieldDidChange];
|
||||
}
|
||||
return NO;
|
||||
} else {
|
||||
return YES;
|
||||
}
|
||||
}
|
||||
|
||||
- (void)setText:(NSString *)text
|
||||
{
|
||||
if (![text isEqualToString:self.text]) {
|
||||
NSInteger eventLag = _nativeEventCount - _mostRecentEventCount;
|
||||
if (eventLag == 0 && ![text isEqualToString:self.text]) {
|
||||
UITextRange *selection = self.selectedTextRange;
|
||||
[super setText:text];
|
||||
self.selectedTextRange = selection; // maintain cursor position/selection - this is robust to out of bounds
|
||||
} else if (eventLag > RCTTextUpdateLagWarningThreshold) {
|
||||
RCTLogWarn(@"Native TextInput(%@) is %ld events ahead of JS - try to make your JS faster.", self.text, (long)eventLag);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -122,17 +154,29 @@ static void RCTUpdatePlaceholder(RCTTextField *self)
|
|||
return self.autocorrectionType == UITextAutocorrectionTypeYes;
|
||||
}
|
||||
|
||||
#define RCT_TEXT_EVENT_HANDLER(delegateMethod, eventName) \
|
||||
- (void)delegateMethod \
|
||||
{ \
|
||||
[_eventDispatcher sendTextEventWithType:eventName \
|
||||
reactTag:self.reactTag \
|
||||
text:self.text]; \
|
||||
- (void)_textFieldDidChange
|
||||
{
|
||||
_nativeEventCount++;
|
||||
[_eventDispatcher sendTextEventWithType:RCTTextEventTypeChange
|
||||
reactTag:self.reactTag
|
||||
text:self.text
|
||||
eventCount:_nativeEventCount];
|
||||
}
|
||||
|
||||
RCT_TEXT_EVENT_HANDLER(_textFieldDidChange, RCTTextEventTypeChange)
|
||||
RCT_TEXT_EVENT_HANDLER(_textFieldEndEditing, RCTTextEventTypeEnd)
|
||||
RCT_TEXT_EVENT_HANDLER(_textFieldSubmitEditing, RCTTextEventTypeSubmit)
|
||||
- (void)_textFieldEndEditing
|
||||
{
|
||||
[_eventDispatcher sendTextEventWithType:RCTTextEventTypeEnd
|
||||
reactTag:self.reactTag
|
||||
text:self.text
|
||||
eventCount:_nativeEventCount];
|
||||
}
|
||||
- (void)_textFieldSubmitEditing
|
||||
{
|
||||
[_eventDispatcher sendTextEventWithType:RCTTextEventTypeSubmit
|
||||
reactTag:self.reactTag
|
||||
text:self.text
|
||||
eventCount:_nativeEventCount];
|
||||
}
|
||||
|
||||
- (void)_textFieldBeginEditing
|
||||
{
|
||||
|
@ -143,11 +187,10 @@ RCT_TEXT_EVENT_HANDLER(_textFieldSubmitEditing, RCTTextEventTypeSubmit)
|
|||
}
|
||||
[_eventDispatcher sendTextEventWithType:RCTTextEventTypeFocus
|
||||
reactTag:self.reactTag
|
||||
text:self.text];
|
||||
text:self.text
|
||||
eventCount:_nativeEventCount];
|
||||
}
|
||||
|
||||
// TODO: we should support shouldChangeTextInRect (see UITextFieldDelegate)
|
||||
|
||||
- (BOOL)becomeFirstResponder
|
||||
{
|
||||
_jsRequestingFirstResponder = YES;
|
||||
|
@ -163,7 +206,8 @@ RCT_TEXT_EVENT_HANDLER(_textFieldSubmitEditing, RCTTextEventTypeSubmit)
|
|||
{
|
||||
[_eventDispatcher sendTextEventWithType:RCTTextEventTypeBlur
|
||||
reactTag:self.reactTag
|
||||
text:self.text];
|
||||
text:self.text
|
||||
eventCount:_nativeEventCount];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
|
|
@ -29,6 +29,7 @@ RCT_REMAP_VIEW_PROPERTY(editable, enabled, BOOL)
|
|||
RCT_EXPORT_VIEW_PROPERTY(placeholder, NSString)
|
||||
RCT_EXPORT_VIEW_PROPERTY(placeholderTextColor, UIColor)
|
||||
RCT_EXPORT_VIEW_PROPERTY(text, NSString)
|
||||
RCT_EXPORT_VIEW_PROPERTY(maxLength, NSNumber)
|
||||
RCT_EXPORT_VIEW_PROPERTY(clearButtonMode, UITextFieldViewMode)
|
||||
RCT_REMAP_VIEW_PROPERTY(clearTextOnFocus, clearsOnBeginEditing, BOOL)
|
||||
RCT_EXPORT_VIEW_PROPERTY(selectTextOnFocus, BOOL)
|
||||
|
@ -56,6 +57,7 @@ RCT_CUSTOM_VIEW_PROPERTY(fontFamily, NSString, RCTTextField)
|
|||
{
|
||||
view.font = [RCTConvert UIFont:view.font withFamily:json ?: defaultView.font.familyName];
|
||||
}
|
||||
RCT_EXPORT_VIEW_PROPERTY(mostRecentEventCount, NSInteger)
|
||||
|
||||
- (RCTViewManagerUIBlock)uiBlockToAmendWithShadowView:(RCTShadowView *)shadowView
|
||||
{
|
||||
|
|
|
@ -25,6 +25,8 @@
|
|||
@property (nonatomic, strong) UIColor *textColor;
|
||||
@property (nonatomic, strong) UIColor *placeholderTextColor;
|
||||
@property (nonatomic, strong) UIFont *font;
|
||||
@property (nonatomic, assign) NSInteger mostRecentEventCount;
|
||||
@property (nonatomic, strong) NSNumber *maxLength;
|
||||
|
||||
- (instancetype)initWithEventDispatcher:(RCTEventDispatcher *)eventDispatcher NS_DESIGNATED_INITIALIZER;
|
||||
|
||||
|
|
|
@ -21,6 +21,7 @@
|
|||
NSString *_placeholder;
|
||||
UITextView *_placeholderView;
|
||||
UITextView *_textView;
|
||||
NSInteger _nativeEventCount;
|
||||
}
|
||||
|
||||
- (instancetype)initWithEventDispatcher:(RCTEventDispatcher *)eventDispatcher
|
||||
|
@ -124,11 +125,41 @@ RCT_NOT_IMPLEMENTED(-initWithCoder:(NSCoder *)aDecoder)
|
|||
return _textView.text;
|
||||
}
|
||||
|
||||
- (BOOL)textView:(UITextView *)textView shouldChangeTextInRange:(NSRange)range replacementText:(NSString *)text
|
||||
{
|
||||
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];
|
||||
}
|
||||
return NO;
|
||||
} else {
|
||||
return YES;
|
||||
}
|
||||
}
|
||||
|
||||
- (void)setText:(NSString *)text
|
||||
{
|
||||
if (![text isEqualToString:_textView.text]) {
|
||||
NSInteger eventLag = _nativeEventCount - _mostRecentEventCount;
|
||||
if (eventLag == 0 && ![text isEqualToString:_textView.text]) {
|
||||
UITextRange *selection = _textView.selectedTextRange;
|
||||
[_textView setText:text];
|
||||
[self _setPlaceholderVisibility];
|
||||
_textView.selectedTextRange = selection; // maintain cursor position/selection - this is robust to out of bounds
|
||||
} else if (eventLag > RCTTextUpdateLagWarningThreshold) {
|
||||
RCTLogWarn(@"Native TextInput(%@) is %ld events ahead of JS - try to make your JS faster.", self.text, (long)eventLag);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -170,15 +201,18 @@ RCT_NOT_IMPLEMENTED(-initWithCoder:(NSCoder *)aDecoder)
|
|||
|
||||
[_eventDispatcher sendTextEventWithType:RCTTextEventTypeFocus
|
||||
reactTag:self.reactTag
|
||||
text:textView.text];
|
||||
text:textView.text
|
||||
eventCount:_nativeEventCount];
|
||||
}
|
||||
|
||||
- (void)textViewDidChange:(UITextView *)textView
|
||||
{
|
||||
[self _setPlaceholderVisibility];
|
||||
_nativeEventCount++;
|
||||
[_eventDispatcher sendTextEventWithType:RCTTextEventTypeChange
|
||||
reactTag:self.reactTag
|
||||
text:textView.text];
|
||||
text:textView.text
|
||||
eventCount:_nativeEventCount];
|
||||
|
||||
}
|
||||
|
||||
|
@ -186,7 +220,8 @@ RCT_NOT_IMPLEMENTED(-initWithCoder:(NSCoder *)aDecoder)
|
|||
{
|
||||
[_eventDispatcher sendTextEventWithType:RCTTextEventTypeEnd
|
||||
reactTag:self.reactTag
|
||||
text:textView.text];
|
||||
text:textView.text
|
||||
eventCount:_nativeEventCount];
|
||||
}
|
||||
|
||||
- (BOOL)becomeFirstResponder
|
||||
|
@ -204,7 +239,8 @@ RCT_NOT_IMPLEMENTED(-initWithCoder:(NSCoder *)aDecoder)
|
|||
if (result) {
|
||||
[_eventDispatcher sendTextEventWithType:RCTTextEventTypeBlur
|
||||
reactTag:self.reactTag
|
||||
text:_textView.text];
|
||||
text:_textView.text
|
||||
eventCount:_nativeEventCount];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
|
|
@ -29,6 +29,7 @@ RCT_REMAP_VIEW_PROPERTY(editable, textView.editable, BOOL)
|
|||
RCT_EXPORT_VIEW_PROPERTY(placeholder, NSString)
|
||||
RCT_EXPORT_VIEW_PROPERTY(placeholderTextColor, UIColor)
|
||||
RCT_EXPORT_VIEW_PROPERTY(text, NSString)
|
||||
RCT_EXPORT_VIEW_PROPERTY(maxLength, NSNumber)
|
||||
RCT_EXPORT_VIEW_PROPERTY(clearTextOnFocus, BOOL)
|
||||
RCT_EXPORT_VIEW_PROPERTY(selectTextOnFocus, BOOL)
|
||||
RCT_REMAP_VIEW_PROPERTY(keyboardType, textView.keyboardType, UIKeyboardType)
|
||||
|
@ -52,6 +53,7 @@ RCT_CUSTOM_VIEW_PROPERTY(fontFamily, NSString, RCTTextView)
|
|||
{
|
||||
view.font = [RCTConvert UIFont:view.font withFamily:json ?: defaultView.font.familyName];
|
||||
}
|
||||
RCT_EXPORT_VIEW_PROPERTY(mostRecentEventCount, NSInteger)
|
||||
|
||||
- (RCTViewManagerUIBlock)uiBlockToAmendWithShadowView:(RCTShadowView *)shadowView
|
||||
{
|
||||
|
|
|
@ -28,6 +28,8 @@ typedef NS_ENUM(NSInteger, RCTScrollEventType) {
|
|||
RCTScrollEventTypeEndAnimation,
|
||||
};
|
||||
|
||||
extern const NSInteger RCTTextUpdateLagWarningThreshold;
|
||||
|
||||
@protocol RCTEvent <NSObject>
|
||||
|
||||
@required
|
||||
|
@ -76,12 +78,14 @@ typedef NS_ENUM(NSInteger, RCTScrollEventType) {
|
|||
*/
|
||||
- (void)sendInputEventWithName:(NSString *)name body:(NSDictionary *)body;
|
||||
|
||||
|
||||
/**
|
||||
* Send a text input/focus event.
|
||||
*/
|
||||
- (void)sendTextEventWithType:(RCTTextEventType)type
|
||||
reactTag:(NSNumber *)reactTag
|
||||
text:(NSString *)text;
|
||||
text:(NSString *)text
|
||||
eventCount:(NSInteger)eventCount;
|
||||
|
||||
- (void)sendEvent:(id<RCTEvent>)event;
|
||||
|
||||
|
|
|
@ -12,6 +12,8 @@
|
|||
#import "RCTAssert.h"
|
||||
#import "RCTBridge.h"
|
||||
|
||||
const NSInteger RCTTextUpdateLagWarningThreshold = 3;
|
||||
|
||||
static NSNumber *RCTGetEventID(id<RCTEvent> event)
|
||||
{
|
||||
return @(
|
||||
|
@ -113,6 +115,7 @@ RCT_EXPORT_MODULE()
|
|||
- (void)sendTextEventWithType:(RCTTextEventType)type
|
||||
reactTag:(NSNumber *)reactTag
|
||||
text:(NSString *)text
|
||||
eventCount:(NSInteger)eventCount
|
||||
{
|
||||
static NSString *events[] = {
|
||||
@"topFocus",
|
||||
|
@ -124,8 +127,10 @@ RCT_EXPORT_MODULE()
|
|||
|
||||
[self sendInputEventWithName:events[type] body:text ? @{
|
||||
@"text": text,
|
||||
@"eventCount": @(eventCount),
|
||||
@"target": reactTag
|
||||
} : @{
|
||||
@"eventCount": @(eventCount),
|
||||
@"target": reactTag
|
||||
}];
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue