Michael Lefkowitz

How to create custom forms with validation and scroll to invalid logic in React Native (Part two: Scroll to invalid)

July 08, 2019

Originally posted at: medium.com/lawnstarter-engineering

In the first part of this series, we walked through creating a simple form with some helper methods that enabled us to roll our own validation logic. In this part, we’ll walk through how we can make our forms automatically scroll up to the first invalid element.

Locating the elements

The first step needed to accomplish this will be to ensure our local state’s input objects are updated to store the Y value where each individual input lives. To store this, we will create a helper called setInputPosition that will add a yCoordinate key onto each of our inputs.

function setInputPosition({ ids, value }) {
  const { inputs } = this.state;

  const updatedInputs = {
    ...inputs
  };

  ids.forEach(id => {
    updatedInputs[id].yCoordinate = value;
  });

  this.setState({
    inputs: updatedInputs
  });
}

This helper will take an array of ids that sync up with the input objects living in our local state. The advantage of using an array here is that we could potentially have multiple inputs existing on the same row (like we’ve done already in our demo app with birthday month and year). Since both of these inputs will be sharing the same yCoordinate value, we can call this helper method one time and update both.

Now that we have our helper created, bind it to the constructor like many of the previous helper methods - since it will be interacting with our state.

To use it, we’ll need to tap into the onLayout method that is exposed on many React Native components. The onLayout method will be invoked on mount and after any layout changes and receive an object that contains details about that elements position in relation to its parent View (more on that later).

So, let’s test out calling this method on our form’s first input - first_name.

onLayout={({ nativeEvent }) => {
    this.setInputPosition({
        ids: ["first_name"],
        value: nativeEvent.layout.y
    });
}}

Now, when the form is loaded, we can take a look at the local state in our debugger and we should see this:

inputs: {
    first_name: {
        type: 'generic',
        value: '',
        yCoordinate: 17
    }
}

Our yCoordinate was successfully saved to our state and our form is now aware of the exact position of our input within the ScrollView.

Next, we’ll add the helper method onto the last_name input and our birthday_month / birthday_day inputs. For the birthday inputs though, we’ll add the helper only once on the outer View that contains both of these elements and include both keys in the ids array. At this point, our form demo app looks like this.

If we reload the page and check our debugger again, we’ll see our local state:

inputs: {
    first_name: {
        type: 'generic',
        value: '',
        yCoordinate: 17
    },
    last_name: {
        type: 'generic',
        value: '',
        yCoordinate: 17
    },
    birthday_day: {
        type: 'day',
        value: '',
        yCoordinate: 142
    },
    birthday_month: {
        type: 'month',
        value: '',
        yCoordinate: 142
    }
}

Wait, something looks off here… our birthday month and days should have the same values, but why do our first and last names share the same value? Shouldn’t our last_name input have a higher yCoordinate value since it’s lower on the screen?

If you take a look at line 75 in our demo app, you’ll see the following:

<View style={styles.container}>
  <ScrollView>

    // first_name inputs are here

    <View> // <--- LINE 75 HERE
      <Text>Last Name</Text>
      <TextInput
        style={styles.input}
        onChangeText={value => {
          this.onInputChange({ id: "last_name", value });
        }}
        onLayout={({ nativeEvent }) => {
          this.setInputPosition({
            ids: ["last_name"],
            value: nativeEvent.layout.y
          });
        }}
      />
      {this.renderError("last_name")}
    </View>

    // birthday_month and birthday_year inputs are here

  </ScrollView>
</View>

Can you spot the issue? Remember, the onLayout method returns the location of the element in relation to its parent View. So our last_name input is effectively telling us the height of the Text element here, instead of the location of this input on our screen. This also means our first_name input is making the same mistake.

How can we solve this? One of two ways. We could move the Text and TextInput out of the wrapping View so every element is a direct descendant of our parent ScrollView. Or, we can move our onLayout logic into the wrapping View. Let’s do the latter.

Now, when we reload and check our local state we should have a yCoordinate of 0 for first_name and 71 for last_name. That sounds more accurate.

Determining the first invalid element

All of our form elements currently fit on the screen, so let’s add some additional input and spacing so our form actually scrolls a bit.

Feel free to get creative here and practice what we’ve worked on up to this point - including testing out new types of validation. If you want to skip ahead, you can copy the updates I made here and here.

1
Our form, in its current form.

At this point, we have a long form that’s aware of each input’s position, properly validates all inputs, and marks the invalid ones for our users to fix. Now we need to determine which invalid element is the first one - meaning the input that is both invalid and has the lowest yCoordinate value.

To determine this, let’s write one more helper.

function getFirstInvalidInput({ inputs }) {
  let firstInvalidCoordinate = Infinity;

  for (const [key, input] of Object.entries(inputs)) {
    if (input.errorLabel && input.yCoordinate < firstInvalidCoordinate) {
      firstInvalidCoordinate = input.yCoordinate;
    }
  }

  if (firstInvalidCoordinate === Infinity) {
    firstInvalidCoordinate = null;
  }

  return firstInvalidCoordinate;
}

This method will take our entire input state after we’ve run it through our validation service and iterate through each invalid input, continually replacing the firstInvalidCoordinate value with a lower value if one is found.

We’ll also want to update our getFormValidation method to return the result of this helper by adding the following as the last line:

return getFirstInvalidInput({ inputs: updatedInputs });

Now in our submit method in our form, if we console.log out the result of calling this.getFormValidation() we should see the lowest yCoordinate value - representing the first invalid element on the screen.

Scrolling to the first invalid input

All this work so far has been to prepare us for the real purpose of this tutorial, actually automatically scrolling our user’s device to the first invalid element. This way, they know what they need to correct, and will be able to see any other invalid inputs as they scroll back down the screen.

To interact with our ScrollView programmatically - we’ll need to create a reference to the element on our constructor and attach it via the ref attribute. More details on that can be found here.

class MyComponent extends React.Component {
  constructor(props) {
    super(props);
    this.scrollView = React.createRef();
  }
  render() {
    return <ScrollView ref={this.scrollView} />;
  }
}

Now that we have a reference to it, we can call the scrollTo method if our form is invalid with the exact coordinates we want to scroll to. We can also utilize the animated flag to make our scrolling look professional.

submit() {
const firstInvalidCoordinate = this.getFormValidation();

    if (firstInvalidCoordinate !== null) {
        this.scrollView.current.scrollTo({
            x: 0,
            y: firstInvalidCoordinate,
            animated: true
        });

        return;
    }

    // if we make it to this point, we can actually submit the form
}

Alright, let’s see how it looks with everything hooked up:

Awesome! Our form has validation and is automatically scrolling to the first invalid input.

Check out our demo app at its current state if something isn’t quite working right on your end.

Next steps

In the third and final part of this series, we’ll go over some ways we can improve the validation experience for our users, attempt an alternate method at obtaining the our input’s coordinates, and share some insights we’ve learned from our experiences building out forms in React Native.