Want to stay up-to-date? Check out React Native Now, the bi-weekly React Native newsletter
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.
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.
Top comments (3)
Hey
You should check github.com/slorber/react-native-sc...
You'd be able to achieve the same with much less wiring
I mention your library in part three
Thanks, didn't notice ❤️