Skip to content
Permalink
Browse files

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 #8958

Differential Revision: D3771229

Pulled By: javache

fbshipit-source-id: b8ede46b97fb3faf3061bb2dac102160c4b20ce7
  • Loading branch information...
janicduplessis authored and Facebook Github Bot 5 committed Aug 26, 2016
1 parent c40fee9 commit f0a3c560481125787f6cd02ab9c3f1b1a692b89c
@@ -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>
);
}
}
];
@@ -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,16 +506,18 @@ const TextInput = React.createClass({
*/
isFocused: function(): boolean {
return TextInputState.currentlyFocusedField() ===
ReactNative.findNodeHandle(this.refs.input);
ReactNative.findNodeHandle(this._inputRef);
},

contextTypes: {
onFocusRequested: React.PropTypes.func,
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,15 +721,15 @@ 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,
});

var text = event.nativeEvent.text;
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);
}
},

@@ -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 */,

0 comments on commit f0a3c56

Please sign in to comment.
You can’t perform that action at this time.