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
This commit is contained in:
Nick Lockwood 2015-11-06 07:25:19 -08:00 committed by facebook-github-bot-8
parent 8dac41b7f0
commit 7779e06a7f
2 changed files with 159 additions and 5 deletions

View File

@ -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({ var BlurOnSubmitExample = React.createClass({
focusNextField(nextField) { focusNextField(nextField) {
this.refs[nextField].focus() this.refs[nextField].focus()
@ -232,6 +286,10 @@ var styles = StyleSheet.create({
textAlign: 'right', textAlign: 'right',
width: 24, width: 24,
}, },
hashtag: {
color: 'blue',
fontWeight: 'bold',
},
}); });
exports.displayName = (undefined: ?string); exports.displayName = (undefined: ?string);
@ -498,6 +556,12 @@ exports.examples = [
); );
} }
}, },
{
title: 'Attributed text',
render: function() {
return <TokenizedTextExample />;
}
},
{ {
title: 'Blur on submit', title: 'Blur on submit',
render: function(): ReactElement { return <BlurOnSubmitExample />; }, render: function(): ReactElement { return <BlurOnSubmitExample />; },

View File

@ -11,6 +11,7 @@
#import "RCTConvert.h" #import "RCTConvert.h"
#import "RCTEventDispatcher.h" #import "RCTEventDispatcher.h"
#import "RCTText.h"
#import "RCTUtils.h" #import "RCTUtils.h"
#import "UIView+React.h" #import "UIView+React.h"
@ -38,6 +39,10 @@
UITextView *_placeholderView; UITextView *_placeholderView;
UITextView *_textView; UITextView *_textView;
NSInteger _nativeEventCount; NSInteger _nativeEventCount;
RCTText *_richTextView;
NSAttributedString *_pendingAttributedText;
NSMutableArray<UIView<RCTComponent> *> *_subviews;
BOOL _blockTextShouldChange;
} }
- (instancetype)initWithEventDispatcher:(RCTEventDispatcher *)eventDispatcher - (instancetype)initWithEventDispatcher:(RCTEventDispatcher *)eventDispatcher
@ -53,6 +58,8 @@
_textView.backgroundColor = [UIColor clearColor]; _textView.backgroundColor = [UIColor clearColor];
_textView.scrollsToTop = NO; _textView.scrollsToTop = NO;
_textView.delegate = self; _textView.delegate = self;
_subviews = [NSMutableArray new];
[self addSubview:_textView]; [self addSubview:_textView];
} }
return self; return self;
@ -61,6 +68,90 @@
RCT_NOT_IMPLEMENTED(- (instancetype)initWithFrame:(CGRect)frame) RCT_NOT_IMPLEMENTED(- (instancetype)initWithFrame:(CGRect)frame)
RCT_NOT_IMPLEMENTED(- (instancetype)initWithCoder:(NSCoder *)aDecoder) 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 - (void)updateFrames
{ {
// Adjust the insets so that they are as close as possible to single-line // 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 - (BOOL)textView:(RCTUITextView *)textView shouldChangeTextInRange:(NSRange)range replacementText:(NSString *)text
{ {
if (_blockTextShouldChange) {
return NO;
}
if (textView.textWasPasted) { if (textView.textWasPasted) {
textView.textWasPasted = NO; textView.textWasPasted = NO;
} else { } 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]; 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 @end