From f0a3c560481125787f6cd02ab9c3f1b1a692b89c Mon Sep 17 00:00:00 2001 From: Janic Duplessis Date: Thu, 25 Aug 2016 17:18:05 -0700 Subject: [PATCH] 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 --- .../UIExplorer/js/TextInputExample.ios.js | 105 ++++++++++++++++++ Libraries/Components/TextInput/TextInput.js | 103 +++++++++++------ .../Text/RCTText.xcodeproj/project.pbxproj | 6 + Libraries/Text/RCTTextField.m | 35 +++++- Libraries/Text/RCTTextFieldManager.m | 1 + Libraries/Text/RCTTextSelection.h | 28 +++++ Libraries/Text/RCTTextSelection.m | 38 +++++++ Libraries/Text/RCTTextView.m | 23 +++- Libraries/Text/RCTTextViewManager.m | 1 + 9 files changed, 298 insertions(+), 42 deletions(-) create mode 100644 Libraries/Text/RCTTextSelection.h create mode 100644 Libraries/Text/RCTTextSelection.m diff --git a/Examples/UIExplorer/js/TextInputExample.ios.js b/Examples/UIExplorer/js/TextInputExample.ios.js index a845df091..1c6f47bde 100644 --- a/Examples/UIExplorer/js/TextInputExample.ios.js +++ b/Examples/UIExplorer/js/TextInputExample.ios.js @@ -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 ( + + 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} + /> + + + selection = {JSON.stringify(this.state.selection)} + + + Place at Start (0, 0) + + + Place at End ({length}, {length}) + + + Place at Random + + + Select All + + + Select Random + + + + ); + } +} + var styles = StyleSheet.create({ page: { paddingBottom: 300, @@ -728,4 +815,22 @@ exports.examples = [ return ; } }, + { + title: 'Text selection & cursor placement', + render: function() { + return ( + + + + + ); + } + } ]; diff --git a/Libraries/Components/TextInput/TextInput.js b/Libraries/Components/TextInput/TextInput.js index a92d42906..4f7942646 100644 --- a/Libraries/Components/TextInput/TextInput.js +++ b/Libraries/Components/TextInput/TextInput.js @@ -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 = ; @@ -617,14 +630,14 @@ const TextInput = React.createClass({ } textContainer = { - 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 = 0) { + this._inputRef.setNativeProps(nativeProps); + } + + if (this.props.selectionState && selection) { + this.props.selectionState.update(selection.start, selection.end); } }, diff --git a/Libraries/Text/RCTText.xcodeproj/project.pbxproj b/Libraries/Text/RCTText.xcodeproj/project.pbxproj index 3e9af6731..46959c630 100644 --- a/Libraries/Text/RCTText.xcodeproj/project.pbxproj +++ b/Libraries/Text/RCTText.xcodeproj/project.pbxproj @@ -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 = ""; }; 1362F0FE1B4D51F400E06D8C /* RCTTextFieldManager.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = RCTTextFieldManager.h; sourceTree = ""; }; 1362F0FF1B4D51F400E06D8C /* RCTTextFieldManager.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RCTTextFieldManager.m; sourceTree = ""; }; + 19FC5C841D41A4120090108F /* RCTTextSelection.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RCTTextSelection.m; sourceTree = ""; }; + 19FC5C861D41A4220090108F /* RCTTextSelection.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = RCTTextSelection.h; sourceTree = ""; }; 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 = ""; }; 58B511C71A9E6C5C00147676 /* RCTRawTextManager.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RCTRawTextManager.m; sourceTree = ""; }; @@ -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 */, diff --git a/Libraries/Text/RCTTextField.m b/Libraries/Text/RCTTextField.m index 71859ad4d..50b85241f 100644 --- a/Libraries/Text/RCTTextField.m +++ b/Libraries/Text/RCTTextField.m @@ -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 diff --git a/Libraries/Text/RCTTextFieldManager.m b/Libraries/Text/RCTTextFieldManager.m index 87329492a..c1d965509 100644 --- a/Libraries/Text/RCTTextFieldManager.m +++ b/Libraries/Text/RCTTextFieldManager.m @@ -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) diff --git a/Libraries/Text/RCTTextSelection.h b/Libraries/Text/RCTTextSelection.h new file mode 100644 index 000000000..dfb207187 --- /dev/null +++ b/Libraries/Text/RCTTextSelection.h @@ -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 diff --git a/Libraries/Text/RCTTextSelection.m b/Libraries/Text/RCTTextSelection.m new file mode 100644 index 000000000..f37d00662 --- /dev/null +++ b/Libraries/Text/RCTTextSelection.m @@ -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 diff --git a/Libraries/Text/RCTTextView.m b/Libraries/Text/RCTTextView.m index 3275a9e6f..1125084b2 100644 --- a/Libraries/Text/RCTTextView.m +++ b/Libraries/Text/RCTTextView.m @@ -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; diff --git a/Libraries/Text/RCTTextViewManager.m b/Libraries/Text/RCTTextViewManager.m index e9e4c1d31..0166cc622 100644 --- a/Libraries/Text/RCTTextViewManager.m +++ b/Libraries/Text/RCTTextViewManager.m @@ -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) {