Skip to content

Commit

Permalink
Added support for auto-resizing text fields
Browse files Browse the repository at this point in the history
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 481f560
Show file tree
Hide file tree
Showing 3 changed files with 105 additions and 5 deletions.
37 changes: 37 additions & 0 deletions Examples/UIExplorer/TextInputExample.android.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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() {
Expand Down
37 changes: 37 additions & 0 deletions Examples/UIExplorer/TextInputExample.ios.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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() {
Expand Down
36 changes: 31 additions & 5 deletions Libraries/Text/RCTTextView.m
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ @implementation RCTTextView
NSMutableArray<UIView *> *_subviews;
BOOL _blockTextShouldChange;
UITextRange *_previousSelectionRange;
NSUInteger _previousTextLength;
CGFloat _previousContentHeight;
UIScrollView *_scrollView;
}

Expand Down Expand Up @@ -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
Expand Down

27 comments on commit 481f560

@satya164
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does it work on Android?

@nicklockwood
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@satya164 should do.

@satya164
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@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
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

@andreicoman11
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@felixakiragreen
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

@nicklockwood
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@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.

@felixakiragreen
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@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
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is extremely useful and works very well.

@satya164
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Working fine for me on iOS. No flicker here.

@asamiller
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

@remstos
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@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
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@dmmiller
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

@cubbuk
Copy link

@cubbuk cubbuk commented on 481f560 Mar 1, 2016

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

@kinhunt
Copy link

@kinhunt kinhunt commented on 481f560 Mar 9, 2016

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@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
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

@danieldkim
Copy link

@danieldkim danieldkim commented on 481f560 May 18, 2016

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
Copy link

@tianjianchn tianjianchn commented on 481f560 Jun 1, 2016

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@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
Copy link

@JumalDB JumalDB commented on 481f560 Oct 21, 2016

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
Copy link

@Palisand Palisand commented on 481f560 Jul 21, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Please sign in to comment.