Permalink
Browse files

Added support for auto-resizing text fields

Summary:
public
This diff adds support for auto-resizing multiline text fields. This has been a long-requested feature, with several native solutions having been proposed (see #1229 and D2846915).

Rather than making this a feature of the native component, this diff simply exposes some extra information in the `onChange` event that makes it easy to implement this in pure JS code. I think this is preferable, since it's simpler, works cross-platform, and avoids any controversy about what the API should look like, or how the props should be named. It also makes it easier to implement custom min/max-height logic.

Reviewed By: sahrens

Differential Revision: D2849889

fb-gh-sync-id: d9ddf4ba4037d388dac0558aa467d958300aa691
  • Loading branch information...
nicklockwood authored and facebook-github-bot-5 committed Jan 25, 2016
1 parent 0893d07 commit 481f560f64806ba3324cf722d6bf8c3f36ac74a5
@@ -72,6 +72,29 @@ var TextEventsExample = React.createClass({
}
});
+class AutoExpandingTextInput extends React.Component {
+ constructor(props) {
+ super(props);
+ this.state = {text: '', height: 0};
+ }
+ render() {
+ return (
+ <TextInput
+ {...this.props}
+ multiline={true}
+ onChange={(event) => {
+ this.setState({
+ text: event.nativeEvent.text,
+ height: event.nativeEvent.contentSize.height,
+ });
+ }}
+ style={[styles.default, {height: Math.max(35, this.state.height)}]}
+ value={this.state.text}
+ />
+ );
+ }
+}
+
class RewriteExample extends React.Component {
constructor(props) {
super(props);
@@ -385,6 +408,20 @@ exports.examples = [
);
}
},
+ {
+ title: 'Auto-expanding',
+ render: function() {
+ return (
+ <View>
+ <AutoExpandingTextInput
+ placeholder="height increases with content"
+ enablesReturnKeyAutomatically={true}
+ returnKeyType="done"
+ />
+ </View>
+ );
+ }
+ },
{
title: 'Attributed text',
render: function() {
@@ -96,6 +96,29 @@ var TextEventsExample = React.createClass({
}
});
+class AutoExpandingTextInput extends React.Component {
+ constructor(props) {
+ super(props);
+ this.state = {text: '', height: 0};
+ }
+ render() {
+ return (
+ <TextInput
+ {...this.props}
+ multiline={true}
+ onChange={(event) => {
+ this.setState({
+ text: event.nativeEvent.text,
+ height: event.nativeEvent.contentSize.height,
+ });
+ }}
+ style={[styles.default, {height: Math.max(35, this.state.height)}]}
+ value={this.state.text}
+ />
+ );
+ }
+}
+
class RewriteExample extends React.Component {
constructor(props) {
super(props);
@@ -630,6 +653,20 @@ exports.examples = [
);
}
},
+ {
+ title: 'Auto-expanding',
+ render: function() {
+ return (
+ <View>
+ <AutoExpandingTextInput
+ placeholder="height increases with content"
+ enablesReturnKeyAutomatically={true}
+ returnKeyType="done"
+ />
+ </View>
+ );
+ }
+ },
{
title: 'Attributed text',
render: function() {
@@ -61,6 +61,8 @@ @implementation RCTTextView
NSMutableArray<UIView *> *_subviews;
BOOL _blockTextShouldChange;
UITextRange *_previousSelectionRange;
+ NSUInteger _previousTextLength;
+ CGFloat _previousContentHeight;
UIScrollView *_scrollView;
}
@@ -437,12 +439,36 @@ - (void)textViewDidChange:(UITextView *)textView
[self updateContentSize];
[self _setPlaceholderVisibility];
_nativeEventCount++;
- [_eventDispatcher sendTextEventWithType:RCTTextEventTypeChange
- reactTag:self.reactTag
- text:textView.text
- key:nil
- eventCount:_nativeEventCount];
+ if (!self.reactTag) {
+ return;
+ }
+
+ // When the context size increases, iOS updates the contentSize twice; once
+ // with a lower height, then again with the correct height. To prevent a
+ // spurious event from being sent, we track the previous, and only send the
+ // update event if it matches our expectation that greater text length
+ // should result in increased height. This assumption is, of course, not
+ // necessarily true because shorter text might include more linebreaks, but
+ // in practice this works well enough.
+ NSUInteger textLength = textView.text.length;
+ CGFloat contentHeight = textView.contentSize.height;
+ if (textLength >= _previousTextLength) {
+ contentHeight = MAX(contentHeight, _previousContentHeight);
+ }
+ _previousTextLength = textLength;
+ _previousContentHeight = contentHeight;
+
+ NSDictionary *event = @{
+ @"text": self.text,
+ @"contentSize": @{
+ @"height": @(contentHeight),
+ @"width": @(textView.contentSize.width)
+ },
+ @"target": self.reactTag,
+ @"eventCount": @(_nativeEventCount),
+ };
+ [_eventDispatcher sendInputEventWithName:@"change" body:event];
}
- (void)textViewDidEndEditing:(UITextView *)textView

27 comments on commit 481f560

@satya164

This comment has been minimized.

Show comment
Hide comment
@satya164

satya164 Jan 25, 2016

Collaborator

Does it work on Android?

Collaborator

satya164 replied Jan 25, 2016

Does it work on Android?

@nicklockwood

This comment has been minimized.

Show comment
Hide comment
@nicklockwood

nicklockwood Jan 26, 2016

Contributor

@satya164 should do.

Contributor

nicklockwood replied Jan 26, 2016

@satya164 should do.

@satya164

This comment has been minimized.

Show comment
Hide comment
@satya164

satya164 Jan 26, 2016

Collaborator

@nicklockwood Just tested on Android in 0.18 and it works fine. There's a slight flicker though. I now feel stupid coz I was rendering a absolutely positioned phantom view to measure height, and then update the height of the TextInput!

Collaborator

satya164 replied Jan 26, 2016

@nicklockwood Just tested on Android in 0.18 and it works fine. There's a slight flicker though. I now feel stupid coz I was rendering a absolutely positioned phantom view to measure height, and then update the height of the TextInput!

@nicklockwood

This comment has been minimized.

Show comment
Hide comment
@nicklockwood

nicklockwood Jan 26, 2016

Contributor

Not sure what we can do about the flicker. Perhaps @andreicoman11 has some ideas?

Contributor

nicklockwood replied Jan 26, 2016

Not sure what we can do about the flicker. Perhaps @andreicoman11 has some ideas?

@andreicoman11

This comment has been minimized.

Show comment
Hide comment
@andreicoman11

andreicoman11 Jan 28, 2016

Contributor

This flickers on android because of the Java->JS->Java roundtrip that the height prop needs to take. We can't just accept the new height that the EditText receives, the source of truth is the shadow node hierarchy that will still have the old value until JS updates it.
I'm curious how come iOS isn't affected by this, it might help us find a solution for this.

Contributor

andreicoman11 replied Jan 28, 2016

This flickers on android because of the Java->JS->Java roundtrip that the height prop needs to take. We can't just accept the new height that the EditText receives, the source of truth is the shadow node hierarchy that will still have the old value until JS updates it.
I'm curious how come iOS isn't affected by this, it might help us find a solution for this.

@nicklockwood

This comment has been minimized.

Show comment
Hide comment
@nicklockwood

nicklockwood Jan 28, 2016

Contributor

Multiline text inputs on iOS contain a vertical scrollView. When you type, iOS automatically increases the height of the scrollView content, and it's that height that we send to JS in the change event.

In my autoresizing example, when JS receives the change event, it tells the component to update its height to match the height of the scrollView content. This keeps the height of the scrollview in sync with the height of its content.

The shadow node backing the textinput is completely unaware of the scrollView content height, it only manages the height of the component itself, so there's no conflict. Changing the height of the component doesn't affect the scrollView content height, and changing the height of the content doesn't affect the component height (except after the JS round trip, when my code tells it to update).

If there was any lag, the height of the content might briefly be taller than the scrollview, in which case you would be able to scroll the view vertically until it caught up, but there' no reason why that should make it flicker.

Contributor

nicklockwood replied Jan 28, 2016

Multiline text inputs on iOS contain a vertical scrollView. When you type, iOS automatically increases the height of the scrollView content, and it's that height that we send to JS in the change event.

In my autoresizing example, when JS receives the change event, it tells the component to update its height to match the height of the scrollView content. This keeps the height of the scrollview in sync with the height of its content.

The shadow node backing the textinput is completely unaware of the scrollView content height, it only manages the height of the component itself, so there's no conflict. Changing the height of the component doesn't affect the scrollView content height, and changing the height of the content doesn't affect the component height (except after the JS round trip, when my code tells it to update).

If there was any lag, the height of the content might briefly be taller than the scrollview, in which case you would be able to scroll the view vertically until it caught up, but there' no reason why that should make it flicker.

@dubert

This comment has been minimized.

Show comment
Hide comment
@dubert

dubert Jan 29, 2016

Contributor

Will there be a way to measure the initial height? So it doesn't start off with 35?

Contributor

dubert replied Jan 29, 2016

Will there be a way to measure the initial height? So it doesn't start off with 35?

@nicklockwood

This comment has been minimized.

Show comment
Hide comment
@nicklockwood

nicklockwood Jan 29, 2016

Contributor

@dubert as soon as you set the text, a change event should be sent with the content height, which is effectively the initial height. If you don't set any text, the initial content height would be zero.

I specified 35 as the starting (and minimum) height value because that looks about right for a single line of text, and a textfield with zero height isn't very useful.

Contributor

nicklockwood replied Jan 29, 2016

@dubert as soon as you set the text, a change event should be sent with the content height, which is effectively the initial height. If you don't set any text, the initial content height would be zero.

I specified 35 as the starting (and minimum) height value because that looks about right for a single line of text, and a textfield with zero height isn't very useful.

@dubert

This comment has been minimized.

Show comment
Hide comment
@dubert

dubert Jan 29, 2016

Contributor

@nicklockwood makes perfect sense. Can't wait use this! Right now I'm using a second <Text> component and measuring that to determine how tall the <TextInput> should be.

Contributor

dubert replied Jan 29, 2016

@nicklockwood makes perfect sense. Can't wait use this! Right now I'm using a second <Text> component and measuring that to determine how tall the <TextInput> should be.

@rgoldiez

This comment has been minimized.

Show comment
Hide comment
@rgoldiez

rgoldiez Jan 31, 2016

This is extremely useful and works very well.

This is extremely useful and works very well.

@satya164

This comment has been minimized.

Show comment
Hide comment
@satya164

satya164 Feb 3, 2016

Collaborator

Also noticed that when the text changes because of a setState/receive props/initial text, it does not emit a change event, so height is not set properly. (Android)

Collaborator

satya164 replied Feb 3, 2016

Also noticed that when the text changes because of a setState/receive props/initial text, it does not emit a change event, so height is not set properly. (Android)

@christopherabouabdo

This comment has been minimized.

Show comment
Hide comment
@christopherabouabdo

christopherabouabdo Feb 11, 2016

Not sure if this is the same thing people are seeing on Android, but I'm noticing a flicker on iOS. Looks like when a new line is added the text on the new line is anchored to the bottom of the input and pushes the existing lines up until the height is updated and then the entire block jumps down to where it should be.

I noticed @nicklockwood and @andreicoman11 mentioning a flicker on Android only, wondering if I'm doing something wrong here?

Not sure if this is the same thing people are seeing on Android, but I'm noticing a flicker on iOS. Looks like when a new line is added the text on the new line is anchored to the bottom of the input and pushes the existing lines up until the height is updated and then the entire block jumps down to where it should be.

I noticed @nicklockwood and @andreicoman11 mentioning a flicker on Android only, wondering if I'm doing something wrong here?

@rgoldiez

This comment has been minimized.

Show comment
Hide comment
@rgoldiez

rgoldiez Feb 11, 2016

Working fine for me on iOS. No flicker here.

Working fine for me on iOS. No flicker here.

@asamiller

This comment has been minimized.

Show comment
Hide comment
@asamiller

asamiller Feb 11, 2016

I noticed a flicker on iOS when I set the height of the TextInput to event.nativeEvent.contentSize.height. If I added a few extra pixels, height: event.nativeEvent.contentSize.height + 10, the flicker went away.

I noticed a flicker on iOS when I set the height of the TextInput to event.nativeEvent.contentSize.height. If I added a few extra pixels, height: event.nativeEvent.contentSize.height + 10, the flicker went away.

@cgilboy

This comment has been minimized.

Show comment
Hide comment
@cgilboy

cgilboy Feb 18, 2016

I also see the flicker on iOS in two situations:

  1. creating empty lines
  2. if the return key is used to accept an autocorrection

I also see the flicker on iOS in two situations:

  1. creating empty lines
  2. if the return key is used to accept an autocorrection
@kemcake

This comment has been minimized.

Show comment
Hide comment
@kemcake

kemcake Feb 29, 2016

@nicklockwood

as soon as you set the text, a change event should be sent with the content height, which is effectively the initial height.

That doesn't seem true, the onChange event is sent from textViewDidChange: method on iOS, which is triggered only when input is pressed.

@nicklockwood

as soon as you set the text, a change event should be sent with the content height, which is effectively the initial height.

That doesn't seem true, the onChange event is sent from textViewDidChange: method on iOS, which is triggered only when input is pressed.

@nicklockwood

This comment has been minimized.

Show comment
Hide comment
@nicklockwood

nicklockwood Feb 29, 2016

Contributor

@asamiller, @kemcake you're right - this seems to be an oversight. Please open a PR to fix it.

CC @dmmiller - do you know if this is the same on Android?

Contributor

nicklockwood replied Feb 29, 2016

@asamiller, @kemcake you're right - this seems to be an oversight. Please open a PR to fix it.

CC @dmmiller - do you know if this is the same on Android?

@satya164

This comment has been minimized.

Show comment
Hide comment
@dmmiller

This comment has been minimized.

Show comment
Hide comment
@dmmiller

dmmiller Mar 1, 2016

Contributor

@nicklockwood It looks like Android sends the height initially as opposed to just on textViewDidChange.

Contributor

dmmiller replied Mar 1, 2016

@nicklockwood It looks like Android sends the height initially as opposed to just on textViewDidChange.

@cubbuk

This comment has been minimized.

Show comment
Hide comment
@cubbuk

cubbuk Mar 1, 2016

@nicklockwood Seems like this does not work with a placeholder. Is there a way to set the height according to the initial placeholder?

@nicklockwood Seems like this does not work with a placeholder. Is there a way to set the height according to the initial placeholder?

@kinhunt

This comment has been minimized.

Show comment
Hide comment
@kinhunt

kinhunt Mar 9, 2016

@nicklockwood works fine on multiline=true as designed to be. Is it add support to single line mode also? So that it can work with the onSubmitEditing option.

I tried, but contentSize is undefined in single line mode.

@nicklockwood works fine on multiline=true as designed to be. Is it add support to single line mode also? So that it can work with the onSubmitEditing option.

I tried, but contentSize is undefined in single line mode.

@yonatanmn

This comment has been minimized.

Show comment
Hide comment
@yonatanmn

yonatanmn May 16, 2016

@nicklockwood @satya164 @kemcake - any advance with triggering on change for setting ios initial text ?

@nicklockwood @satya164 @kemcake - any advance with triggering on change for setting ios initial text ?

@danieldkim

This comment has been minimized.

Show comment
Hide comment
@danieldkim

danieldkim May 18, 2016

I think to be complete this solution needs a new property on TextInput that causes it to be rendered with a height large enough to contain its value.

In my case the lack of this is particularly acute as I'm re-rendering the input as the user types -- whenever she does an auto-complete (the autocompleted text is specially formatted and I'm using a child Text node to set the content). If the user is near the end of the line when she triggers the complete and the completion pushes text over a new line that new line is hidden.

danieldkim replied May 18, 2016

I think to be complete this solution needs a new property on TextInput that causes it to be rendered with a height large enough to contain its value.

In my case the lack of this is particularly acute as I'm re-rendering the input as the user types -- whenever she does an auto-complete (the autocompleted text is specially formatted and I'm using a child Text node to set the content). If the user is near the end of the line when she triggers the complete and the completion pushes text over a new line that new line is hidden.

@martinbigio

This comment has been minimized.

Show comment
Hide comment
@martinbigio

martinbigio May 25, 2016

Contributor

I think the +10 approach @asamiller proposed works because the magic number 10 is enough to guarantee that an additional newline would be shown. The unintended consequence of this is that the input would be bigger than what you actually need.

Maybe a better approach would be to wrap the TextInput into a View and set the height for both the input and the view. The input could have a + K height to avoid seeing the flickering effect but the parent view set would the correct height so that we don't see the next free line.

Contributor

martinbigio replied May 25, 2016

I think the +10 approach @asamiller proposed works because the magic number 10 is enough to guarantee that an additional newline would be shown. The unintended consequence of this is that the input would be bigger than what you actually need.

Maybe a better approach would be to wrap the TextInput into a View and set the height for both the input and the view. The input could have a + K height to avoid seeing the flickering effect but the parent view set would the correct height so that we don't see the next free line.

@tianjianchn

This comment has been minimized.

Show comment
Hide comment
@tianjianchn

tianjianchn Jun 1, 2016

@yonatanmn I use a hidden Text component to get the height of the initial text.

Put a hidden Text element(which style like position: 'absolute', top: 10000, left: 10000) aside the TextInput element, and in TextInput's componentDidMount method, use measure method to get the hidden Text element's height.

And for the newline flicker problem, currently I add an extra line height(like 18) to the contentSize.height.

Hope these helps.

tianjianchn replied Jun 1, 2016

@yonatanmn I use a hidden Text component to get the height of the initial text.

Put a hidden Text element(which style like position: 'absolute', top: 10000, left: 10000) aside the TextInput element, and in TextInput's componentDidMount method, use measure method to get the hidden Text element's height.

And for the newline flicker problem, currently I add an extra line height(like 18) to the contentSize.height.

Hope these helps.

@JumalDB

This comment has been minimized.

Show comment
Hide comment
@JumalDB

JumalDB Oct 21, 2016

For initial height I added onContentSizeChange on TextInput, it seems to work great:

onContentSizeChange = (event) => {
    if (this.state.multilineHeight === null) {
      this.setState({
        multilineHeight: event.nativeEvent.contentSize.height
      });
    }
  }

JumalDB replied Oct 21, 2016

For initial height I added onContentSizeChange on TextInput, it seems to work great:

onContentSizeChange = (event) => {
    if (this.state.multilineHeight === null) {
      this.setState({
        multilineHeight: event.nativeEvent.contentSize.height
      });
    }
  }
@Palisand

This comment has been minimized.

Show comment
Hide comment
@Palisand

Palisand Jul 21, 2017

THIS NO LONGER WORKS WITH onChange: bac84ce Use onContentSizeChange instead.

Palisand replied Jul 21, 2017

THIS NO LONGER WORKS WITH onChange: bac84ce Use onContentSizeChange instead.

Please sign in to comment.