TL;DR
Creation of React-Native forms with ability to scroll to error fields and focus on that input field.
Create forms with React Hook Form
There are simpler ways to implement forms in React-Native with error mapping and submission as a part of the screen state logic.
For our purposes we needed complex logic for validation and blur messages.
Hence we chose to use React Hook Form.
It has a rich API for form modes, setting errors explicitly, triggering validations etc.
The latest version also comes packed with a useFormContext which can be used for extended validation in case of custom React Hook Form components.
A very basic React-Native usage of the library will look like below:
import React from "react";
import { Text, View, TextInput, Button, Alert } from "react-native";
import { useForm, Controller } from "react-hook-form";
export default function App() {
const { control, handleSubmit, errors } = useForm();
const onSubmit = data => console.warn("Form Data",JSON.stringify(data));
return (
<View>
<Text>First name</Text>
<Controller
as={TextInput}
control={control}
name="firstName"
onChange={args => args[0].nativeEvent.text}
rules={{ required: true }}
defaultValue=""
/>
{errors.firstName && <Text>This is required.</Text>}
<Text>Last name</Text>
<Controller
as={TextInput}
control={control}
name="lastName"
onChange={args => args[0].nativeEvent.text}
defaultValue=""
/>
<Button title="Submit" onPress={handleSubmit(onSubmit)} />
</View>
);
}
We can choose to wrap a custom component with the Hooks library as well, the API exposes onChangeName and onBlurName, which can be used if the custom component has different names for these events.
These can be used to listen in to changes in custom components and trigger actions like display validation messages etc.
A sample for a custom component will look like below:
...
import { Controller, useForm } from 'react-hook-form';
...
const { control, errors, clearError, setError, triggerValidation, getValues, setValue } = useForm;
...
<Controller
name="mySampleInput"
control={control}
as={<CustomInput
label='My Custom Label'
maxLength={9}
validationMessage={
errors && errors.annualGrossIncome && 'My Validation error' }
testID="mySampleInput"
/>}
onBlurName="customOnBlur"
customOnBlur={handleCustomBlur}
rules={{
required: true,
}}
onChangeName="onValueChange"
onChange={args => {
clearError('mySampleInput');
return args[0].value;
}}
/>
...
If we want to create a React Hook Form custom component with its own custom validation logic then we need to ensure that the component receives the same useForm hook.
For this the library recently added useFormContext which can be used to drill the data to the nested React Hook Form-based component.
...
const useFormObj = useForm({ defaultValues:
{mySampleInput:"a sample value"},
});
const { control, errors, clearError, setError, triggerValidation, getValues, setValue } = useFormObj;
...
<FormContext {...useFormObj}>
// The form code here
</FormContext>
Auto scroll for Form
Due to limited screen real state, in case of a bigger form we want the screen to scroll to the first input field with the error.
In case of big forms even when a validation message is shown, a user might not know which fields are erroneous and might keep pressing the submit button in vain.
The first step to this is to wrap the form with a ScrollView.
Next, to achieve the scroll-to effect, we developed a useAutoScroll hook.
The Hook has four parts:
-
setScrollRef
: This is used to keep track of ref for the wrapping ScrollView. This is the ref against which we can trigger the scrollTo method with a y coordinate . -
scrollTracker
: This is a helper function which wraps the component with a View and then gets the absolute y coordinate of the View. -
scrollTo
: This exposed method takes in the errors key array from React Hook Forms and then computes the first error field. Then it scrolls to that field. -
captureRef
: A ref for components. We will come back to this.
Below we start with setScrollRef. This just captures the ref value for wrapping ScrollView.
...
const useAutoScroll = () => {
const yCoordinates = useRef({});
let scrollRef = null;
const setScrollRef = ref => {
if (ref) scrollRef = ref;
};
...
return {
setScrollRef
};
}
Then for each component we call a scrollTracker method. ScrollTracker takes in two arguments, the component and its name prop for Controller. This is then used to construct and object internally with the below structure:
const yCoorodinates = {
'mySampleInput': {
{y:'', ref:''}
}
}
We tried using onLayout for the View to get the y-coordinate of the component, and in most cases this might just be enough.
But the catch is, onLayout gives the y-coordinate with respect to its immediate View. In case we are using a styled View or a wrapper, the y-coordinate might be different than the absolute value needed with respect to the Screen.
To circumvent this we used the measure native method on the View ref.
const scrollTracker = (component, inputKey) => {
let viewRef;
// The below logic worked for our custom components.
// Might need tweaking to work for other set of components.
const getScrollToY = py => py - Dimensions.get('window').height / 5.5;
const getCoordinates = () => {
viewRef.measure((fx, fy, width, height, px, py) => {
if (yCoordinates.current[inputKey]) {
if (!yCoordinates.current[inputKey].y) {
yCoordinates.current[inputKey].y = getScrollToY(py);
}
}
else {
yCoordinates.current[inputKey] = {};
yCoordinates.current[inputKey].y = getScrollToY(py);
}
});
};
return (
<View
testID={`${inputKey}Wrapper`}
ref={ref => {
if (ref) viewRef = ref;
}}
onLayout={getCoordinates}
>
{component}
</View>);
};
Then we added a scrollTo method which takes in the errors object keys and then gets the FIRST error key. Meaning the key with least y coordinate value that happens to be in errors array.
We then scroll to that component.
const scrollTo = errors => {
// Util method to get First invalid key, this can be custom.
const firstInvalidKey = util.getFirstConditionalKey(yCoordinates.current, 'y', errors);
if (yCoordinates.current[firstInvalidKey].ref) {
yCoordinates.current[firstInvalidKey].ref.handleFocus({
nativeEvent: { text: 'Dummy name' },
});
}
scrollRef.scrollTo(0,yCoordinates.current[firstInvalidKey].y);
};
This looks great! But we can do more.
In case of text inputs it would be far better if we can focus on the field.
For this we added a captureRef which captures the ref of the component.
We can then add this to our coordinate object such that each input key will now have a y-coordinate and ref within it.
Then our scrollTo can be modified to call .focus() on the captured Ref if it exists.
Even custom components like Select, RadioButtons etc can be modified to accept a focus() callBack and action accordingly.
const captureRef = inputKey => ref => {
if (ref) {
if (yCoordinates.current[inputKey]) {
yCoordinates.current[inputKey].ref = ref;
} else {
yCoordinates.current[inputKey] = {};
yCoordinates.current[inputKey].ref = ref;
}
}
};
Our final version of useAutoScroll looks like below, with associated tests:
Usage:
And that's it. We have created a ref-heavy component which works with almost all ref-respecting elements.
Top comments (1)
Can we see the part with utils ?
const firstInvalidKey = util.getFirstConditionalKey(yCoordinates.current, 'y', errors);
this function