Permalink
Browse files

Added rich text input support

Summary: public

It is now possible to display and edit rich text inside a multiline `<textInput>` by nesting a `<Text>` 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
  • Loading branch information...
nicklockwood authored and facebook-github-bot-8 committed Nov 6, 2015
1 parent 8dac41b commit 7779e06a7f2720b1fd4654c7b22b576a06a44a31
Showing with 159 additions and 5 deletions.
  1. +64 −0 Examples/UIExplorer/TextInputExample.ios.js
  2. +95 −5 Libraries/Text/RCTTextView.m
@@ -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 style={styles.hashtag}>{text}</Text>;
+ } else {
+ return text;
+ }
+ });
+
+ return (
+ <View>
+ <TextInput
+ multiline={true}
+ style={styles.multiline}
+ onChangeText={(text) => {
+ this.setState({text});
+ }}>
+ <Text>{parts}</Text>
+ </TextInput>
+ </View>
+ );
+ }
+}
+
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 <TokenizedTextExample />;
+ }
+ },
{
title: 'Blur on submit',
render: function(): ReactElement { return <BlurOnSubmitExample />; },
@@ -11,6 +11,7 @@
#import "RCTConvert.h"
#import "RCTEventDispatcher.h"
+#import "RCTText.h"
#import "RCTUtils.h"
#import "UIView+React.h"
@@ -38,6 +39,10 @@ @implementation RCTTextView
UITextView *_placeholderView;
UITextView *_textView;
NSInteger _nativeEventCount;
+ RCTText *_richTextView;
+ NSAttributedString *_pendingAttributedText;
+ NSMutableArray<UIView<RCTComponent> *> *_subviews;
+ BOOL _blockTextShouldChange;
}
- (instancetype)initWithEventDispatcher:(RCTEventDispatcher *)eventDispatcher
@@ -53,6 +58,8 @@ - (instancetype)initWithEventDispatcher:(RCTEventDispatcher *)eventDispatcher
_textView.backgroundColor = [UIColor clearColor];
_textView.scrollsToTop = NO;
_textView.delegate = self;
+
+ _subviews = [NSMutableArray new];
[self addSubview:_textView];
}
return self;
@@ -61,6 +68,90 @@ - (instancetype)initWithEventDispatcher:(RCTEventDispatcher *)eventDispatcher
RCT_NOT_IMPLEMENTED(- (instancetype)initWithFrame:(CGRect)frame)
RCT_NOT_IMPLEMENTED(- (instancetype)initWithCoder:(NSCoder *)aDecoder)
+- (NSArray<UIView<RCTComponent> *> *)reactSubviews
+{
+ return _subviews;
+}
+
+- (void)insertReactSubview:(UIView<RCTComponent> *)subview atIndex:(NSInteger)index
+{
+ if ([subview isKindOfClass:[RCTText class]]) {
+ if (_richTextView) {
+ RCTLogError(@"Tried to insert a second <Text> into <TextInput> - 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<RCTComponent> *)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 @@ - (NSString *)text
- (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 @@ - (UIColor *)defaultPlaceholderTextColor
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

3 comments on commit 7779e06

@geirman

This comment has been minimized.

Show comment
Hide comment
@geirman

geirman Jan 16, 2016

Contributor

Looks like this will solve issue #4977

I'm confused by the tagging. Is this included in 18.0-rc or 15.0-rc?
image

Contributor

geirman replied Jan 16, 2016

Looks like this will solve issue #4977

I'm confused by the tagging. Is this included in 18.0-rc or 15.0-rc?
image

@mikollo

This comment has been minimized.

Show comment
Hide comment
@mikollo

mikollo Jan 21, 2016

When You nest text inside textinput letters show up with delay or misplaced.

When You nest text inside textinput letters show up with delay or misplaced.

@mobitar

This comment has been minimized.

Show comment
Hide comment
@mobitar

mobitar Sep 9, 2017

I've also noticed this. Text can take several seconds to render for a multi-paragraph note when Text is nested inside TextInput.

I've also noticed this. Text can take several seconds to render for a multi-paragraph note when Text is nested inside TextInput.

Please sign in to comment.