From 7779e06a7f2720b1fd4654c7b22b576a06a44a31 Mon Sep 17 00:00:00 2001 From: Nick Lockwood Date: Fri, 6 Nov 2015 07:25:19 -0800 Subject: [PATCH] Added rich text input support Summary: public It is now possible to display and edit rich text inside a multiline `` by nesting a `` node inside it. Note that this doesn't yet provide everything needed to build a full rich text editor (as there is no facility to capture or control the selected text range, or insert/remove text) but it does make it possible to apply token-based styling to text as the user types. See the 'Attributed text' example in the UIExplorer > TextInput demo for details. Reviewed By: javache Differential Revision: D2622493 fb-gh-sync-id: b6bc9a46005322c806934541966460edccb59e70 --- Examples/UIExplorer/TextInputExample.ios.js | 64 +++++++++++++ Libraries/Text/RCTTextView.m | 100 +++++++++++++++++++- 2 files changed, 159 insertions(+), 5 deletions(-) diff --git a/Examples/UIExplorer/TextInputExample.ios.js b/Examples/UIExplorer/TextInputExample.ios.js index df0407bdb..c7e196222 100644 --- a/Examples/UIExplorer/TextInputExample.ios.js +++ b/Examples/UIExplorer/TextInputExample.ios.js @@ -120,6 +120,60 @@ class RewriteExample extends React.Component { } } +class TokenizedTextExample extends React.Component { + constructor(props) { + super(props); + this.state = {text: 'Hello #World'}; + } + render() { + + //define delimiter + let delimiter = /\s+/; + + //split string + let _text = this.state.text; + let token, index, parts = []; + while (_text) { + delimiter.lastIndex = 0; + token = delimiter.exec(_text); + if (token === null) { + break; + } + index = token.index; + if (token[0].length === 0) { + index = 1; + } + parts.push(_text.substr(0, index)); + parts.push(token[0]); + index = index + token[0].length; + _text = _text.slice(index); + } + parts.push(_text); + + //highlight hashtags + parts = parts.map((text) => { + if (/^#/.test(text)) { + return {text}; + } else { + return text; + } + }); + + return ( + + { + this.setState({text}); + }}> + {parts} + + + ); + } +} + var BlurOnSubmitExample = React.createClass({ focusNextField(nextField) { this.refs[nextField].focus() @@ -232,6 +286,10 @@ var styles = StyleSheet.create({ textAlign: 'right', width: 24, }, + hashtag: { + color: 'blue', + fontWeight: 'bold', + }, }); exports.displayName = (undefined: ?string); @@ -498,6 +556,12 @@ exports.examples = [ ); } }, + { + title: 'Attributed text', + render: function() { + return ; + } + }, { title: 'Blur on submit', render: function(): ReactElement { return ; }, diff --git a/Libraries/Text/RCTTextView.m b/Libraries/Text/RCTTextView.m index 1080b4d92..7ad01b0ce 100644 --- a/Libraries/Text/RCTTextView.m +++ b/Libraries/Text/RCTTextView.m @@ -11,6 +11,7 @@ #import "RCTConvert.h" #import "RCTEventDispatcher.h" +#import "RCTText.h" #import "RCTUtils.h" #import "UIView+React.h" @@ -38,6 +39,10 @@ UITextView *_placeholderView; UITextView *_textView; NSInteger _nativeEventCount; + RCTText *_richTextView; + NSAttributedString *_pendingAttributedText; + NSMutableArray *> *_subviews; + BOOL _blockTextShouldChange; } - (instancetype)initWithEventDispatcher:(RCTEventDispatcher *)eventDispatcher @@ -53,6 +58,8 @@ _textView.backgroundColor = [UIColor clearColor]; _textView.scrollsToTop = NO; _textView.delegate = self; + + _subviews = [NSMutableArray new]; [self addSubview:_textView]; } return self; @@ -61,6 +68,90 @@ RCT_NOT_IMPLEMENTED(- (instancetype)initWithFrame:(CGRect)frame) RCT_NOT_IMPLEMENTED(- (instancetype)initWithCoder:(NSCoder *)aDecoder) +- (NSArray *> *)reactSubviews +{ + return _subviews; +} + +- (void)insertReactSubview:(UIView *)subview atIndex:(NSInteger)index +{ + if ([subview isKindOfClass:[RCTText class]]) { + if (_richTextView) { + RCTLogError(@"Tried to insert a second into - there can only be one."); + } + _richTextView = (RCTText *)subview; + [_subviews insertObject:_richTextView atIndex:index]; + } else { + [_subviews insertObject:subview atIndex:index]; + [self insertSubview:subview atIndex:index]; + } +} + +- (void)removeReactSubview:(UIView *)subview +{ + if (_richTextView == subview) { + [_subviews removeObject:_richTextView]; + _richTextView = nil; + } else { + [_subviews removeObject:subview]; + [subview removeFromSuperview]; + } +} + +- (void)setMostRecentEventCount:(NSInteger)mostRecentEventCount +{ + _mostRecentEventCount = mostRecentEventCount; + + // Props are set after uiBlockToAmendWithShadowViewRegistry, which means that + // at the time performTextUpdate is called, _mostRecentEventCount will be + // behind _eventCount, with the result that performPendingTextUpdate will do + // nothing. For that reason we call it again here after mostRecentEventCount + // has been set. + [self performPendingTextUpdate]; +} + +- (void)performTextUpdate +{ + if (_richTextView) { + _pendingAttributedText = _richTextView.textStorage; + [self performPendingTextUpdate]; + } else if (!self.text) { + _textView.attributedText = nil; + } +} + +- (void)performPendingTextUpdate +{ + if (!_pendingAttributedText || _mostRecentEventCount < _nativeEventCount) { + return; + } + + if ([_textView.attributedText isEqualToAttributedString:_pendingAttributedText]) { + _pendingAttributedText = nil; // Don't try again. + return; + } + + // When we update the attributed text, there might be pending autocorrections + // that will get accepted by default. In order for this to not garble our text, + // we temporarily block all textShouldChange events so they are not applied. + _blockTextShouldChange = YES; + + // We compute the new selectedRange manually to make sure the cursor is at the + // end of the newly inserted/deleted text after update. + NSRange range = _textView.selectedRange; + CGPoint contentOffset = _textView.contentOffset; + + _textView.attributedText = _pendingAttributedText; + _pendingAttributedText = nil; + _textView.selectedRange = range; + [_textView layoutIfNeeded]; + _textView.contentOffset = contentOffset; + + [self _setPlaceholderVisibility]; + + _blockTextShouldChange = NO; +} + - (void)updateFrames { // Adjust the insets so that they are as close as possible to single-line @@ -156,6 +247,10 @@ RCT_NOT_IMPLEMENTED(- (instancetype)initWithCoder:(NSCoder *)aDecoder) - (BOOL)textView:(RCTUITextView *)textView shouldChangeTextInRange:(NSRange)range replacementText:(NSString *)text { + if (_blockTextShouldChange) { + return NO; + } + if (textView.textWasPasted) { textView.textWasPasted = NO; } else { @@ -309,9 +404,4 @@ RCT_NOT_IMPLEMENTED(- (instancetype)initWithCoder:(NSCoder *)aDecoder) return [UIColor colorWithRed:0.0/255.0 green:0.0/255.0 blue:0.098/255.0 alpha:0.22]; } -- (void)performTextUpdate -{ - // Not used (yet) -} - @end