react-native: [Android] TextInput doesn't scroll properly when restricting the height on an expanding TextInput

Description

I’m creating a InputText where I want to restrict the height to a maximum. When I do it the TextInput doesn’t automatically scroll when I write enough to add another line of text. That means I have to manually scroll down to see what I’m typing.

  • It works on iOS as expected
  • It works as expected if I’m setting a fixed height (style={{height: 200}})
  • It works as expected if I don’t restrict the height (style={{ height: Math.max(35, this.state.height) }})

Reproduction

class AutoExpandingTextInput extends React.Component {
    constructor(props) {
        super(props);
        this.state = {
            text: 'React Native enables you to build world-class application experiences on native platforms using a consistent developer experience based on JavaScript and React. The focus of React Native is on developer efficiency across all the platforms you care about — learn once, write anywhere. Facebook uses React Native in multiple production apps and will continue investing in React Native.',
            height: 0,
        };
    }
    render() {
        return (
            <TextInput
                {...this.props}
                multiline={true}
                onChange={(event) => {
                    // onContentSizeChange doesn't work on Android, use onChange instead https://github.com/facebook/react-native/issues/11692
                    this.setState({ height: event.nativeEvent.contentSize.height });
                }}
                onContentSizeChange={(event) => {
                    this.setState({ height: event.nativeEvent.contentSize.height });
                }}
                onChangeText={(text) => {
                    this.setState({ text });
                }}
                style={{ height: Math.min(200, Math.max(35, this.state.height)) }}
                value={this.state.text}
            />
        );
    }
}

Solution

?

Additional Information

  • React Native version: 0.42
  • Platform: Android
  • Operating System: MacOS

About this issue

  • Original URL
  • State: closed
  • Created 7 years ago
  • Reactions: 30
  • Comments: 64 (17 by maintainers)

Commits related to this issue

Most upvoted comments

Yes, I am still working on this.

@shergin Is a fix for this already on master? Basically I’m trying to reproduce the same behvaviour as iOS on Android. textinput

@guysegal use workaround as posted

0.51 is planned to be released sometime today (though unexpected things can happen, so give it a few extra days of buffer room).

Finally, 0bef872f3fc8b1cd78c574d03eacc886bef4e239 should fix this. If anyone can test it, I will appreciate that. 😄

@shergin @Palisand I’ve tracked the issue basing on RN 0.49-stable branch and this is a guilty method: https://github.com/facebook/react-native/blob/0.49-stable/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactEditText.java#L121

I’ve created my own implementation of ReactEditText which looks like this:

public class CustomTextInput extends ReactEditText {
    public CustomTextInput(Context context) {
        super(context);
    }

    @Override
    public boolean isLayoutRequested() {
        return false;
    }
}

Then I’ve overrided ReactTextInputManager like this:

public class CustomTextInputManager extends ReactTextInputManager {

    @Override
    public String getName() {
        return "CustomTextInput";
    }

    @Override
    public CustomTextInput createViewInstance(ThemedReactContext context) {
        CustomTextInput editText = new CustomTextInput(context);
        int inputType = editText.getInputType();
        editText.setInputType(inputType & (~InputType.TYPE_TEXT_FLAG_MULTI_LINE));
        editText.setReturnKeyType("done");
        editText.setTextSize(
                TypedValue.COMPLEX_UNIT_PX,
                (int) Math.ceil(PixelUtil.toPixelFromSP(ViewDefaults.FONT_SIZE_SP)));
        return editText;
    }
}

and then I’ve copy-pasted the TextInput class and put my CustomTextInput here https://github.com/facebook/react-native/blob/0.49-stable/Libraries/Components/TextInput/TextInput.js#L42

And the issue is gone! UPDATE the above method has some drawbacks, please continue reading if you need a better workaround (you may don’t need it)

This is surely a pretty short workaround but it may require proper implementation and testing on master RN branch. I’m not sure why someone would not want the automatic scroll on the TextInput so I don’t know what is the purpose of the original implementation and which use cases it handles. I thought that maybe setting initial very long text on TextInput with restricted height would cause the TextInput to be scrolled to the bottom but no, it didn’t break this behavior. (EDIT) it actually breaks this specific behavior. I’ve thought about setting some kind of ‘autoScroll’ prop on input field’s focus event and resetting it on input field’s blur event but I’m not sure if this is a proper way of doing this. Anyway, below you can find this implementation if anyone is interested on it. I’m not proud of it but hey, it works for basic use cases - not sure if this is a proper way of handling, especially after @shergin changes in master here https://github.com/facebook/react-native/commit/c550f27a4e82402b966567d7b79a8b1f1d1491a5 . Also @Palisand I’m notifying you here in case if you’re interested.

public class CustomTextInputManager extends ReactTextInputManager {

    @Override
    public String getName() {
        return "CustomTextInput";
    }

    @Override
    public CustomTextInput createViewInstance(ThemedReactContext context) {
        CustomTextInput editText = new CustomTextInput(context);
        int inputType = editText.getInputType();
        editText.setInputType(inputType & (~InputType.TYPE_TEXT_FLAG_MULTI_LINE));
        editText.setReturnKeyType("done");
        editText.setTextSize(
                TypedValue.COMPLEX_UNIT_PX,
                (int) Math.ceil(PixelUtil.toPixelFromSP(ViewDefaults.FONT_SIZE_SP)));
        return editText;
    }

    @ReactProp(name = "autoScroll", defaultBoolean = false)
    public void setAutoScroll(CustomTextInput view, boolean autoScroll) {
        view.setAutoScroll(autoScroll);
    }
}
public class CustomTextInput extends ReactEditText {
    private boolean autoScroll = false;

    public CustomTextInput(Context context) {
        super(context);
    }

    private boolean isMultiline() {
        return (getInputType() & InputType.TYPE_TEXT_FLAG_MULTI_LINE) != 0;
    }

    @Override
    public boolean isLayoutRequested() {
        if (isMultiline() && !autoScroll) {
            return true;
        }
        return false;
    }

    public void setAutoScroll(boolean autoScroll) {
        this.autoScroll = autoScroll;
    }
}
render() {
  return (
    <TextInputComponent
      multiline
      onFocus={() => {
        if (this.inputField && Platform.OS === 'android') {
          this.inputField.setNativeProps({
            autoScroll: true,
          });
        }
      }}
      onBlur={() => {
        if (this.inputField && Platform.OS === 'android') {
          this.inputField.setNativeProps({
            autoScroll: false,
          });
        }
      }}
      ref={(ref) => {
        this.inputField = ref;
      }}
    />
  )
}

@jonasb @shergin This is also happening on RN 0.46.1. Simply including

onContentSizeChange={() => {}}

is enough to cause what appears to be disabling automatic scrolling, even on inputs of fixed height!

Hi @dead23angel I dont know about that. It could require fixing the actual library code?

Somebody pointed out a flaw in my code though so here is an updated version that works in case a user scrolls up and is editing part of the text they wrote:

https://snack.expo.io/rJHCrEYef good for React Native >= 50.0 (to get this to work for React Native < 50.0 just add the onContentSizeChange method to the text input like the example above)


import React, { Component } from 'react';
import {
  StyleSheet,
  TextInput,
  View,
  ScrollView,
  KeyboardAvoidingView
} from 'react-native';

export default class App extends Component {

  state = {
      text: '',
      textYPosition: 0,
      textHeight: 0

  };

  updateScrollPosition(width, height){

    let yPositionDifference = (height - this.state.textHeight)
    let newYPosition = this.state.textYPosition + yPositionDifference

    this.scroll.scrollTo({x: 0, y: newYPosition, animated: false})

    this.setState({textHeight: height})
  }

  handleScroll(scrollEvent){
    let textYPosition = scrollEvent.nativeEvent.contentOffset.y
    this.setState({textYPosition})
  }
  render() {
    return (
      <KeyboardAvoidingView
        behavior = "position"
        keyboardVerticalOffset= {80}
        keyboardDismissMode = "on-drag"
      >
        <View
        style ={{height:500, backgroundColor: "blue"}}
        />
        <ScrollView
          ref={(scroll) => {this.scroll = scroll;}}
          onContentSizeChange = {(width, height) => this.updateScrollPosition(width, height)}
          style = {{height:80}}
          scrollEventThrottle = {1}
          onScroll = {nativeEvent => this.handleScroll(nativeEvent)}
        >
            <TextInput
              blurOnSubmit = {false}
              multiline = {true}
              style={styles.text}
              underlineColorAndroid = "transparent"
              onChangeText={text => this.setState({ text })}
              placeholder={"Start Typing..."}
            />
        </ScrollView>
      </KeyboardAvoidingView>
    );
  }
}

const styles = StyleSheet.create({
  text: {
    marginBottom: 16,
    paddingHorizontal: 8,
  },
});

This is obviously pretty messy and I actually like the simplicity of @konradkierus version, but since I am using Expo for react native I do not have the luxury of using native code. It needs to be 100% javascript.

The key here is updating the component’s state using the ScrollView’s onScroll method to capture the new current Y position of the TextInput any time the scroll position changes.

After that, anytime the onContentSizeChange method is called we save the new content height to state. We also use the scrollTo method to scroll to our new Y position. Our new Y position increments the current Y position by the difference of the new content height and the previous content height we saved in state.

Since this is only an Android problem, I will probably only use this component for my Android rendering and let my iOS rendering stay the same. I am kind of concerned about what performance issues if any could come from calling setState too much

An upstream fix would be super ideal 👍

Confirmed on RN 0.46.4, with:

  handleContentSizeChange = ({ nativeEvent }) => {
    const { height } = nativeEvent.contentSize
    this.setState({
      inputHeight: height > MAX_INPUT_HEIGHT ? MAX_INPUT_HEIGHT : height
    })
  }

  render () {
    return <View>
      <TextInput
        onContentSizeChange={this.handleContentSizeChange}
        style={ height: this.state.inputHeight } />
    </View>
  }

Hey @mannol, cherry-picking Android fixes is a fairly involved process because you first need to setup your project to build Android from source. It’s been a long time since I set up the Android build in my project, but all I remember is that it took a while to get working.

If you are building Android from source, the fastest way to ‘cherry-pick’ is to actually just copy/paste the changes to ReactEditText.java from this commit into your node_modules directory, and then use a tool like patch-package to preserve the changes across yarn/npm installs.

@ide Any idea when the 0.51 will be released? @jamesreggio Any idea how to cherrypick this commit into a project initialized with react-native init (react-native-git-upgrade doesn’t work with 0.51.0-rc.3 which should have this commit)?

Thanks!

@Palisand great comment, thanks for the heads up! Did not see that commit, i was on a old version 😉

@shukerullah After @shergin’s changes, TextInputs for both iOS and Android have an intrinsic size. You do not need to use onContentSizeChange or autoGrow to control multiline input size anymore if you’re on master; try out the RNTester app and rejoice! To restore Android’s cursor-following / auto-scrolling, you need to use @konradkierus’s changes which, at the very least, involve having ReactEditText’s isLayoutRequested method always return false. To add cursor-following to iOS it is a little more involved if you want to use my method (see my previous comment), but you also have the option of using @baijunjie’s react-native-input-scroll-view.

@stoffern the onChange callback no longer accepts an event that includes contentSize as of this commit. Which version of RN are you using?

seriously @shergin thank you so so much. This is such a save!

@shergin

It would be nice to make this commit to version 0.47, because I like the @riryjs code is great to update the packages

I found a solution that works for my purposes so figured I’d share

This code works for React Native >=50.0: https://snack.expo.io/r1y4tXVxz

import React, { Component } from 'react';
import {
  StyleSheet,
  TextInput,
  View,
  ScrollView,
  KeyboardAvoidingView
} from 'react-native';

export default class App extends Component {

  state = {
      text: '',
  };

  render() {
    return (
      <KeyboardAvoidingView
        behavior = "position"
        keyboardVerticalOffset= {80}
        keyboardDismissMode = "on-drag"
      >
        <View
        style ={{height:500, backgroundColor: "blue"}}
        />
        <ScrollView
          ref={(scroll) => {this.scroll = scroll;}}
          onContentSizeChange = {() => {this.scroll.scrollToEnd({animated: true})}}
          style = {{height:80}}
        >
            <TextInput
              blurOnSubmit = {false}
              multiline = {true}
              style={styles.text}
              onChangeText={text => this.setState({ text })}
              placeholder={"Start Typing..."}
            />
        </ScrollView>
      </KeyboardAvoidingView>
    );
  }
}

const styles = StyleSheet.create({
  text: {
    borderColor: 'gray',
    borderWidth: 1,
    marginBottom: 16,
    paddingHorizontal: 8,
  },
});

This code works for React Native <50.0: https://snack.expo.io/S1wXbNNxM

import React, { Component } from 'react';
import {
  StyleSheet,
  TextInput,
  View,
  ScrollView,
  KeyboardAvoidingView
} from 'react-native';

export default class App extends Component {

  state = {
      text: '',
      textareaHeight: 60,
  };
  
  _onContentSizeChange = ({nativeEvent:event}) => {
    this.setState({ textareaHeight: event.contentSize.height });
  };

  render() {
    return (
      <KeyboardAvoidingView
        behavior = "position"
        keyboardVerticalOffset= {80}
        keyboardDismissMode = "on-drag"
      >
        <View
        style ={{height:500, backgroundColor: "blue"}}
        />
        <ScrollView
          ref={(scroll) => {this.scroll = scroll;}}
          onContentSizeChange = {() => {this.scroll.scrollToEnd({animated: true})}}
          style = {{height:80}}
        >
            <TextInput
              blurOnSubmit = {false}
              multiline = {true}
              style={[styles.text, {height: this.state.textareaHeight}]}
              autogrow
              onChangeText={text => this.setState({ text })}
              onContentSizeChange={this._onContentSizeChange}
              placeholder={"Start Typing Plz..."}
            />
        </ScrollView>
      </KeyboardAvoidingView>
    );
  }
}

const styles = StyleSheet.create({
  text: {
    borderColor: 'gray',
    borderWidth: 1,
    marginBottom: 16,
    paddingHorizontal: 8,
  },
});

There are some nested ScrollViews since KeyboardAvoidingView is a ScrollView but it works pretty well as a workaround without having to do native code. The key is using the ScrollView’s scrollToEnd anytime the content size changes.

I only use KeyboardAvoidingView because I’m building a chat app and the message box is at the bottom of the screen.

Still there for me as well on 0.50.3

@LEEY19 read this https://facebook.github.io/react-native/docs/native-components-android.html Previously I gave you a link to writing a custom module, this link shows how to implement custom component (extending view managers). If you still won’t know how to properly do it then please ask for help in StackOveflow to not spam here.

@Palisand Well, I just tried to fix/enable it, but I failed. So, yes, the current state is: layout is working, (auto)scrolling is not (and I don’t know how to fix that, yet). But, it should be (easy) fixable. 😂

@Palisand I can confirm 100%.

By looking at the code in react-native-autogrow-input, I was able to create a Component that solves this problem for me (I’ve only tested it on Android). This is a messy hack because I use two TextInput. The user sees only one of them. The hidden TextInput handles onContentSizeChange() while the visible one receives user input, grows & scrolls appropriately. The two TextInput communicate with each other via the component state. The component requires two props: minHeight and maxHeight. Feel free to customize this for your use case or let me know if you have a better solution.

import React, { PureComponent } from 'react';
import { TextInput, View } from 'react-native';

import PropTypes from 'prop-types';

export default class AutoGrowInput extends PureComponent {

  componentWillMount() {
    this.setState({
      height: this.props.minHeight,
      inputValue: '',
    });
  }

  onContentSizeChange = (event) => {
    const { minHeight, maxHeight } = this.props;
    // Adding 30 to provide extra padding.
    let inputHeight = Math.max(minHeight, event.nativeEvent.contentSize.height + 30);
    if (inputHeight > maxHeight) {
      inputHeight = maxHeight;
    }
    if (this.state.height !== inputHeight) {
      this.setState({
        height: inputHeight,
      });
    }
  }

  clear = () => {
    if (this.inputRef) {
      this.inputRef.setNativeProps({ text: '' });
      this.setState({
        height: this.props.minHeight,
        inputValue: '',
      });
    }
  }

  value = () => {
    return this.state.inputValue;
  }

  focus = () => {
    if (this.inputRef && this.inputRef.focus) {
      this.inputRef.focus();
    }
  }

  render() {
    return (
      <View
        style={{
          flex: 1,
          marginTop: 5,
          marginBottom: 10,
          height: this.state.height,
          alignSelf: 'stretch',
          padding: 0,
        }}
      >
        {/*
        This is the TextInput the user sees. It does not have onContentSizeChange(), so it
        scrolls as expected. It receives the user input and stores it as this.state.inputValue
        */}
        <TextInput
          ref={ref => this.inputRef = ref}
          multiline
          {...this.props}
          style={[this.props.style, { height: this.state.height }]}
          onChangeText={val => this.setState({ inputValue: val })}
          value={this.state.inputValue}
        />
        {/*
        This is a hidden TextInput that the user does not see. It uses onContentSizeChange() to
        calculate the height of the input the user sees. It receives the user's input from
        this.state.inputValue.
        */}
        <TextInput
          multiline
          style={{ height: 0, margin: 0, padding: 0 }}
          onContentSizeChange={this.onContentSizeChange}
          value={this.state.inputValue}
        />
      </View>
    );
  }
}

AutoGrowInput.propTypes = {
  style: PropTypes.oneOfType([
    PropTypes.number,
    PropTypes.array,
    PropTypes.object,
  ]),
  minHeight: PropTypes.number,
  maxHeight: PropTypes.number,
};

@stoffern which workaround are you referring to? I didn’t find a workaround for the case where max height is defined