Add TextInput controlled selection prop on iOS

Summary:
This adds support for a controlled `selection` prop on `TextInput` on iOS (Android PR coming soon). This is based on the work by ehd in #2668 which hasn't been updated for a while, kept the original commit and worked on fixing what was missing based on the feedback in the original PR.

What I changed is:
- Make the prop properly controlled by JS
- Add a RCTTextSelection class to map the JS object into and the corresponding RCTConvert category
- Make sure the selection change event is properly triggered when the input is focused
- Cleanup setSelection
- Changed TextInput to use function refs to appease the linter

** Test plan **
Tested using the TextInput selection example in UIExplorer on iOS.
Also tested that it doesn't break Android.
Closes https://github.com/facebook/react-native/pull/8958

Differential Revision: D3771229

Pulled By: javache

fbshipit-source-id: b8ede46b97fb3faf3061bb2dac102160c4b20ce7
This commit is contained in:
Janic Duplessis 2016-08-25 17:18:05 -07:00 committed by Facebook Github Bot 5
parent c40fee9405
commit f0a3c56048
9 changed files with 298 additions and 42 deletions

View File

@ -294,6 +294,93 @@ class BlurOnSubmitExample extends React.Component {
}
}
type SelectionExampleState = {
selection: {
start: number;
end?: number;
};
value: string;
};
class SelectionExample extends React.Component {
state: SelectionExampleState;
_textInput: any;
constructor(props) {
super(props);
this.state = {
selection: {start: 0, end: 0},
value: props.value
};
}
onSelectionChange({nativeEvent: {selection}}) {
this.setState({selection});
}
getRandomPosition() {
var length = this.state.value.length;
return Math.round(Math.random() * length);
}
select(start, end) {
this._textInput.focus();
this.setState({selection: {start, end}});
}
selectRandom() {
var positions = [this.getRandomPosition(), this.getRandomPosition()].sort();
this.select(...positions);
}
placeAt(position) {
this.select(position, position);
}
placeAtRandom() {
this.placeAt(this.getRandomPosition());
}
render() {
var length = this.state.value.length;
return (
<View>
<TextInput
multiline={this.props.multiline}
onChangeText={(value) => this.setState({value})}
onSelectionChange={this.onSelectionChange.bind(this)}
ref={textInput => (this._textInput = textInput)}
selection={this.state.selection}
style={this.props.style}
value={this.state.value}
/>
<View>
<Text>
selection = {JSON.stringify(this.state.selection)}
</Text>
<Text onPress={this.placeAt.bind(this, 0)}>
Place at Start (0, 0)
</Text>
<Text onPress={this.placeAt.bind(this, length)}>
Place at End ({length}, {length})
</Text>
<Text onPress={this.placeAtRandom.bind(this)}>
Place at Random
</Text>
<Text onPress={this.select.bind(this, 0, length)}>
Select All
</Text>
<Text onPress={this.selectRandom.bind(this)}>
Select Random
</Text>
</View>
</View>
);
}
}
var styles = StyleSheet.create({
page: {
paddingBottom: 300,
@ -728,4 +815,22 @@ exports.examples = [
return <TokenizedTextExample />;
}
},
{
title: 'Text selection & cursor placement',
render: function() {
return (
<View>
<SelectionExample
style={styles.default}
value="text selection can be changed"
/>
<SelectionExample
multiline
style={styles.multiline}
value={"multiline text selection\ncan also be changed"}
/>
</View>
);
}
}
];

View File

@ -46,6 +46,10 @@ if (Platform.OS === 'android') {
}
type Event = Object;
type Selection = {
start: number,
end?: number,
};
const DataDetectorTypes = [
'phoneNumber',
@ -386,6 +390,15 @@ const TextInput = React.createClass({
* @platform ios
*/
selectionState: PropTypes.instanceOf(DocumentSelectionState),
/**
* The start and end of the text input's selection. Set start and end to
* the same value to position the cursor.
* @platform ios
*/
selection: PropTypes.shape({
start: PropTypes.number.isRequired,
end: PropTypes.number,
}),
/**
* The value to show for the text input. `TextInput` is a controlled
* component, which means the native value will be forced to match this
@ -493,7 +506,7 @@ const TextInput = React.createClass({
*/
isFocused: function(): boolean {
return TextInputState.currentlyFocusedField() ===
ReactNative.findNodeHandle(this.refs.input);
ReactNative.findNodeHandle(this._inputRef);
},
contextTypes: {
@ -501,8 +514,10 @@ const TextInput = React.createClass({
focusEmitter: React.PropTypes.instanceOf(EventEmitter),
},
_inputRef: (undefined: any),
_focusSubscription: (undefined: ?Function),
_lastNativeText: (undefined: ?string),
_lastNativeSelection: (undefined: ?Selection),
componentDidMount: function() {
this._lastNativeText = this.props.value;
@ -563,22 +578,20 @@ const TextInput = React.createClass({
this.props.defaultValue;
},
_setNativeRef: function(ref: any) {
this._inputRef = ref;
},
_renderIOS: function() {
var textContainer;
var onSelectionChange;
if (this.props.selectionState || this.props.onSelectionChange) {
onSelectionChange = (event: Event) => {
if (this.props.selectionState) {
var selection = event.nativeEvent.selection;
this.props.selectionState.update(selection.start, selection.end);
}
this.props.onSelectionChange && this.props.onSelectionChange(event);
};
}
var props = Object.assign({}, this.props);
props.style = [styles.input, this.props.style];
if (props.selection && props.selection.end == null) {
props.selection = {start: props.selection.start, end: props.selection.start};
}
if (!props.multiline) {
if (__DEV__) {
for (var propKey in onlyMultiline) {
@ -592,12 +605,12 @@ const TextInput = React.createClass({
}
textContainer =
<RCTTextField
ref="input"
ref={this._setNativeRef}
{...props}
onFocus={this._onFocus}
onBlur={this._onBlur}
onChange={this._onChange}
onSelectionChange={onSelectionChange}
onSelectionChange={this._onSelectionChange}
onSelectionChangeShouldSetResponder={emptyFunction.thatReturnsTrue}
text={this._getText()}
/>;
@ -617,14 +630,14 @@ const TextInput = React.createClass({
}
textContainer =
<RCTTextView
ref="input"
ref={this._setNativeRef}
{...props}
children={children}
onFocus={this._onFocus}
onBlur={this._onBlur}
onChange={this._onChange}
onContentSizeChange={this.props.onContentSizeChange}
onSelectionChange={onSelectionChange}
onSelectionChange={this._onSelectionChange}
onTextInput={this._onTextInput}
onSelectionChangeShouldSetResponder={emptyFunction.thatReturnsTrue}
text={this._getText()}
@ -647,17 +660,6 @@ const TextInput = React.createClass({
},
_renderAndroid: function() {
var onSelectionChange;
if (this.props.selectionState || this.props.onSelectionChange) {
onSelectionChange = (event: Event) => {
if (this.props.selectionState) {
var selection = event.nativeEvent.selection;
this.props.selectionState.update(selection.start, selection.end);
}
this.props.onSelectionChange && this.props.onSelectionChange(event);
};
}
const props = Object.assign({}, this.props);
props.style = [this.props.style];
props.autoCapitalize =
@ -675,13 +677,13 @@ const TextInput = React.createClass({
const textContainer =
<AndroidTextInput
ref="input"
ref={this._setNativeRef}
{...props}
mostRecentEventCount={0}
onFocus={this._onFocus}
onBlur={this._onBlur}
onChange={this._onChange}
onSelectionChange={onSelectionChange}
onSelectionChange={this._onSelectionChange}
onTextInput={this._onTextInput}
text={this._getText()}
children={children}
@ -719,7 +721,7 @@ const TextInput = React.createClass({
_onChange: function(event: Event) {
// Make sure to fire the mostRecentEventCount first so it is already set on
// native when the text value is set.
this.refs.input.setNativeProps({
this._inputRef.setNativeProps({
mostRecentEventCount: event.nativeEvent.eventCount,
});
@ -727,7 +729,7 @@ const TextInput = React.createClass({
this.props.onChange && this.props.onChange(event);
this.props.onChangeText && this.props.onChangeText(text);
if (!this.refs.input) {
if (!this._inputRef) {
// calling `this.props.onChange` or `this.props.onChangeText`
// may clean up the input itself. Exits here.
return;
@ -737,14 +739,47 @@ const TextInput = React.createClass({
this.forceUpdate();
},
_onSelectionChange: function(event: Event) {
this.props.onSelectionChange && this.props.onSelectionChange(event);
if (!this._inputRef) {
// calling `this.props.onSelectionChange`
// may clean up the input itself. Exits here.
return;
}
this._lastNativeSelection = event.nativeEvent.selection;
if (this.props.selection || this.props.selectionState) {
this.forceUpdate();
}
},
componentDidUpdate: function () {
// This is necessary in case native updates the text and JS decides
// that the update should be ignored and we should stick with the value
// that we have in JS.
const nativeProps = {};
if (this._lastNativeText !== this.props.value && typeof this.props.value === 'string') {
this.refs.input.setNativeProps({
text: this.props.value,
});
nativeProps.text = this.props.value;
}
// Selection is also a controlled prop, if the native value doesn't match
// JS, update to the JS value.
const {selection} = this.props;
if (this._lastNativeSelection && selection &&
(this._lastNativeSelection.start !== selection.start ||
this._lastNativeSelection.end !== selection.end)) {
nativeProps.selection = this.props.selection;
}
if (Object.keys(nativeProps).length > 0) {
this._inputRef.setNativeProps(nativeProps);
}
if (this.props.selectionState && selection) {
this.props.selectionState.update(selection.start, selection.end);
}
},

View File

@ -11,6 +11,7 @@
131B6AC11AF0CD0600FFC3E0 /* RCTTextViewManager.m in Sources */ = {isa = PBXBuildFile; fileRef = 131B6ABF1AF0CD0600FFC3E0 /* RCTTextViewManager.m */; };
1362F1001B4D51F400E06D8C /* RCTTextField.m in Sources */ = {isa = PBXBuildFile; fileRef = 1362F0FD1B4D51F400E06D8C /* RCTTextField.m */; };
1362F1011B4D51F400E06D8C /* RCTTextFieldManager.m in Sources */ = {isa = PBXBuildFile; fileRef = 1362F0FF1B4D51F400E06D8C /* RCTTextFieldManager.m */; };
19FC5C851D41A4120090108F /* RCTTextSelection.m in Sources */ = {isa = PBXBuildFile; fileRef = 19FC5C841D41A4120090108F /* RCTTextSelection.m */; };
58B511CE1A9E6C5C00147676 /* RCTRawTextManager.m in Sources */ = {isa = PBXBuildFile; fileRef = 58B511C71A9E6C5C00147676 /* RCTRawTextManager.m */; };
58B511CF1A9E6C5C00147676 /* RCTShadowRawText.m in Sources */ = {isa = PBXBuildFile; fileRef = 58B511C91A9E6C5C00147676 /* RCTShadowRawText.m */; };
58B511D01A9E6C5C00147676 /* RCTShadowText.m in Sources */ = {isa = PBXBuildFile; fileRef = 58B511CB1A9E6C5C00147676 /* RCTShadowText.m */; };
@ -39,6 +40,8 @@
1362F0FD1B4D51F400E06D8C /* RCTTextField.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RCTTextField.m; sourceTree = "<group>"; };
1362F0FE1B4D51F400E06D8C /* RCTTextFieldManager.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = RCTTextFieldManager.h; sourceTree = "<group>"; };
1362F0FF1B4D51F400E06D8C /* RCTTextFieldManager.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RCTTextFieldManager.m; sourceTree = "<group>"; };
19FC5C841D41A4120090108F /* RCTTextSelection.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RCTTextSelection.m; sourceTree = "<group>"; };
19FC5C861D41A4220090108F /* RCTTextSelection.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = RCTTextSelection.h; sourceTree = "<group>"; };
58B5119B1A9E6C1200147676 /* libRCTText.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = libRCTText.a; sourceTree = BUILT_PRODUCTS_DIR; };
58B511C61A9E6C5C00147676 /* RCTRawTextManager.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = RCTRawTextManager.h; sourceTree = "<group>"; };
58B511C71A9E6C5C00147676 /* RCTRawTextManager.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RCTRawTextManager.m; sourceTree = "<group>"; };
@ -66,6 +69,8 @@
58B511921A9E6C1200147676 = {
isa = PBXGroup;
children = (
19FC5C861D41A4220090108F /* RCTTextSelection.h */,
19FC5C841D41A4120090108F /* RCTTextSelection.m */,
58B511C61A9E6C5C00147676 /* RCTRawTextManager.h */,
58B511C71A9E6C5C00147676 /* RCTRawTextManager.m */,
58B511C81A9E6C5C00147676 /* RCTShadowRawText.h */,
@ -157,6 +162,7 @@
58B511D11A9E6C5C00147676 /* RCTTextManager.m in Sources */,
131B6AC01AF0CD0600FFC3E0 /* RCTTextView.m in Sources */,
58B511CE1A9E6C5C00147676 /* RCTRawTextManager.m in Sources */,
19FC5C851D41A4120090108F /* RCTTextSelection.m in Sources */,
1362F1001B4D51F400E06D8C /* RCTTextField.m in Sources */,
58B512161A9E6EFF00147676 /* RCTText.m in Sources */,
1362F1011B4D51F400E06D8C /* RCTTextFieldManager.m in Sources */,

View File

@ -12,6 +12,7 @@
#import "RCTConvert.h"
#import "RCTEventDispatcher.h"
#import "RCTUtils.h"
#import "RCTTextSelection.h"
#import "UIView+React.h"
@implementation RCTTextField
@ -28,7 +29,6 @@
if ((self = [super initWithFrame:CGRectZero])) {
RCTAssert(eventDispatcher, @"eventDispatcher is a required parameter");
_eventDispatcher = eventDispatcher;
_previousSelectionRange = self.selectedTextRange;
[self addTarget:self action:@selector(textFieldDidChange) forControlEvents:UIControlEventEditingChanged];
[self addTarget:self action:@selector(textFieldBeginEditing) forControlEvents:UIControlEventEditingDidBegin];
[self addTarget:self action:@selector(textFieldEndEditing) forControlEvents:UIControlEventEditingDidEnd];
@ -64,6 +64,26 @@ RCT_NOT_IMPLEMENTED(- (instancetype)initWithCoder:(NSCoder *)aDecoder)
[super paste:sender];
}
- (void)setSelection:(RCTTextSelection *)selection
{
if (!selection) {
return;
}
UITextRange *currentSelection = self.selectedTextRange;
UITextPosition *start = [self positionFromPosition:self.beginningOfDocument offset:selection.start];
UITextPosition *end = [self positionFromPosition:self.beginningOfDocument offset:selection.end];
UITextRange *selectedTextRange = [self textRangeFromPosition:start toPosition:end];
NSInteger eventLag = _nativeEventCount - _mostRecentEventCount;
if (eventLag == 0 && ![currentSelection isEqual:selectedTextRange]) {
_previousSelectionRange = selectedTextRange;
self.selectedTextRange = selectedTextRange;
} else if (eventLag > RCTTextUpdateLagWarningThreshold) {
RCTLogWarn(@"Native TextInput(%@) is %zd events ahead of JS - try to make your JS faster.", self.text, eventLag);
}
}
- (void)setText:(NSString *)text
{
NSInteger eventLag = _nativeEventCount - _mostRecentEventCount;
@ -173,16 +193,19 @@ static void RCTUpdatePlaceholder(RCTTextField *self)
- (void)textFieldBeginEditing
{
if (_selectTextOnFocus) {
dispatch_async(dispatch_get_main_queue(), ^{
[self selectAll:nil];
});
}
[_eventDispatcher sendTextEventWithType:RCTTextEventTypeFocus
reactTag:self.reactTag
text:self.text
key:nil
eventCount:_nativeEventCount];
dispatch_async(dispatch_get_main_queue(), ^{
if (self->_selectTextOnFocus) {
[self selectAll:nil];
}
[self sendSelectionEvent];
});
}
- (BOOL)textFieldShouldEndEditing:(RCTTextField *)textField

View File

@ -79,6 +79,7 @@ RCT_EXPORT_VIEW_PROPERTY(autoCorrect, BOOL)
RCT_REMAP_VIEW_PROPERTY(editable, enabled, BOOL)
RCT_EXPORT_VIEW_PROPERTY(placeholder, NSString)
RCT_EXPORT_VIEW_PROPERTY(placeholderTextColor, UIColor)
RCT_EXPORT_VIEW_PROPERTY(selection, RCTTextSelection)
RCT_EXPORT_VIEW_PROPERTY(text, NSString)
RCT_EXPORT_VIEW_PROPERTY(maxLength, NSNumber)
RCT_EXPORT_VIEW_PROPERTY(clearButtonMode, UITextFieldViewMode)

View File

@ -0,0 +1,28 @@
/**
* Copyright (c) 2015-present, Facebook, Inc.
* All rights reserved.
*
* This source code is licensed under the BSD-style license found in the
* LICENSE file in the root directory of this source tree. An additional grant
* of patent rights can be found in the PATENTS file in the same directory.
*/
#import "RCTConvert.h"
/**
* Object containing information about a TextInput's selection.
*/
@interface RCTTextSelection : NSObject
@property (nonatomic, assign, readonly) NSInteger start;
@property (nonatomic, assign, readonly) NSInteger end;
- (instancetype)initWithStart:(NSInteger)start end:(NSInteger)end;
@end
@interface RCTConvert (RCTTextSelection)
+ (RCTTextSelection *)RCTTextSelection:(id)json;
@end

View File

@ -0,0 +1,38 @@
/**
* Copyright (c) 2015-present, Facebook, Inc.
* All rights reserved.
*
* This source code is licensed under the BSD-style license found in the
* LICENSE file in the root directory of this source tree. An additional grant
* of patent rights can be found in the PATENTS file in the same directory.
*/
#import "RCTTextSelection.h"
@implementation RCTTextSelection
- (instancetype)initWithStart:(NSInteger)start end:(NSInteger)end
{
if (self = [super init]) {
_start = start;
_end = end;
}
return self;
}
@end
@implementation RCTConvert (RCTTextSelection)
+ (RCTTextSelection *)RCTTextSelection:(id)json
{
if ([json isKindOfClass:[NSDictionary class]]) {
NSInteger start = [self NSInteger:json[@"start"]];
NSInteger end = [self NSInteger:json[@"end"]];
return [[RCTTextSelection alloc] initWithStart:start end:end];
}
return nil;
}
@end

View File

@ -14,6 +14,7 @@
#import "RCTShadowText.h"
#import "RCTText.h"
#import "RCTUtils.h"
#import "RCTTextSelection.h"
#import "UIView+React.h"
@interface RCTUITextView : UITextView
@ -103,8 +104,6 @@
_scrollView.scrollsToTop = NO;
[_scrollView addSubview:_textView];
_previousSelectionRange = _textView.selectedTextRange;
[self addSubview:_scrollView];
}
return self;
@ -452,6 +451,26 @@ static NSAttributedString *removeReactTagFromString(NSAttributedString *string)
return _textView.text;
}
- (void)setSelection:(RCTTextSelection *)selection
{
if (!selection) {
return;
}
UITextRange *currentSelection = _textView.selectedTextRange;
UITextPosition *start = [_textView positionFromPosition:_textView.beginningOfDocument offset:selection.start];
UITextPosition *end = [_textView positionFromPosition:_textView.beginningOfDocument offset:selection.end];
UITextRange *selectedTextRange = [_textView textRangeFromPosition:start toPosition:end];
NSInteger eventLag = _nativeEventCount - _mostRecentEventCount;
if (eventLag == 0 && ![currentSelection isEqual:selectedTextRange]) {
_previousSelectionRange = selectedTextRange;
_textView.selectedTextRange = selectedTextRange;
} else if (eventLag > RCTTextUpdateLagWarningThreshold) {
RCTLogWarn(@"Native TextInput(%@) is %zd events ahead of JS - try to make your JS faster.", self.text, eventLag);
}
}
- (void)setText:(NSString *)text
{
NSInteger eventLag = _nativeEventCount - _mostRecentEventCount;

View File

@ -45,6 +45,7 @@ RCT_REMAP_VIEW_PROPERTY(returnKeyType, textView.returnKeyType, UIReturnKeyType)
RCT_REMAP_VIEW_PROPERTY(secureTextEntry, textView.secureTextEntry, BOOL)
RCT_REMAP_VIEW_PROPERTY(selectionColor, tintColor, UIColor)
RCT_EXPORT_VIEW_PROPERTY(selectTextOnFocus, BOOL)
RCT_EXPORT_VIEW_PROPERTY(selection, RCTTextSelection)
RCT_EXPORT_VIEW_PROPERTY(text, NSString)
RCT_CUSTOM_VIEW_PROPERTY(fontSize, NSNumber, RCTTextView)
{