react-native-avoid-softinput
react-native-avoid-softinput copied to clipboard
IOS - Scroll bug when next input focus when onSubmitEditing is called
Environment
package.json
{
"name": "zstation",
"version": "1.0.0",
"private": true,
"scripts": {
"android:run": "react-native run-android",
"colors:update": "npx @klarna/platform-colors",
"ios:run": "react-native run-ios",
"lint:check": "eslint .",
"lint:fix": "eslint . --fix",
"start": "react-native start",
"supabase:types:generate": "source .env && pnpm supabase gen types typescript --project-id $SUPABASE_PROJECT_ID > src/@types/supabase/client.ts",
"test": "jest --config=./jest.config.ts",
"type:check": "tsc --noEmit --skipLibCheck"
},
"packageManager": "[email protected]",
"dependencies": {
"@callstack/liquid-glass": "0.4.1",
"@hookform/resolvers": "5.2.2",
"@klarna/platform-colors": "0.4.0",
"@react-native/new-app-screen": "0.81.4",
"@react-navigation/native": "7.1.17",
"@react-navigation/native-stack": "7.3.26",
"@supabase/supabase-js": "2.57.4",
"i18next": "25.5.2",
"jwt-decode": "4.0.0",
"lodash.debounce": "4.0.8",
"react": "19.1.0",
"react-hook-form": "7.63.0",
"react-i18next": "15.7.3",
"react-native": "0.81.4",
"react-native-advanced-input-mask": "1.4.5",
"react-native-avoid-softinput": "8.0.0",
"react-native-config": "1.5.9",
"react-native-edge-to-edge": "1.7.0",
"react-native-gesture-handler": "2.28.0",
"react-native-localize": "3.5.2",
"react-native-mmkv": "3.3.3",
"react-native-notifier": "2.0.0",
"react-native-safe-area-context": "5.6.1",
"react-native-screens": "4.16.0",
"react-native-svg": "15.13.0",
"react-native-url-polyfill": "2.0.0",
"react-native-video": "6.16.1",
"zod": "4.1.11"
},
"devDependencies": {
"@babel/core": "^7.25.2",
"@babel/plugin-transform-export-namespace-from": "7.27.1",
"@babel/preset-env": "^7.25.3",
"@babel/runtime": "^7.25.0",
"@react-native-community/cli": "20.0.0",
"@react-native-community/cli-platform-android": "20.0.0",
"@react-native-community/cli-platform-ios": "20.0.0",
"@react-native/babel-preset": "0.81.4",
"@react-native/eslint-config": "0.81.4",
"@react-native/metro-config": "0.81.4",
"@react-native/typescript-config": "0.81.4",
"@types/jest": "29.5.13",
"@types/lodash.debounce": "4.0.9",
"@types/react": "19.1.0",
"@types/react-test-renderer": "19.1.0",
"babel-plugin-module-resolver": "5.0.2",
"eslint": "8.57.0",
"eslint-plugin-import-helpers": "2.0.1",
"eslint-plugin-no-relative-import-paths": "1.6.1",
"eslint-plugin-perfectionist": "4.15.0",
"eslint-plugin-prettier": "5.5.4",
"eslint-plugin-sort-destructure-keys": "2.0.0",
"eslint-plugin-sort-keys-fix": "1.1.2",
"eslint-plugin-typescript-sort-keys": "3.3.0",
"jest": "29.6.3",
"prettier": "3.6.2",
"react-native-asset": "2.1.1",
"react-test-renderer": "19.1.0",
"supabase": "2.40.7",
"typescript": "5.8.3"
},
"engines": {
"node": ">=20"
}
}
Affected platforms
- [ ] Android
- [x] iOS
Current behavior
In my CodeForm component, I use the CodeInput component with 6 digits. In the onSubmitEditing function of each input, I focus on the next field, and this is pushing my screen up infinitely.
When next button is pressed, this code is called.
const onSubmitEditing = useCallback(
(event: TextInputSubmitEditingEvent, index: number) => {
if (index === length - 1) return props.onSubmitEditing(event);
inputRefs.current[index + 1]?.focus();
},
[length, props],
);
LoginTemplate
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { View, StyleSheet, ScrollView, TouchableOpacity, useWindowDimensions, StatusBar } from 'react-native';
import { AvoidSoftInputView } from 'react-native-avoid-softinput';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import Video from 'react-native-video';
import { LiquidView } from '~/components/LiquidView';
import { Logo } from '~/components/svg/Logo';
import { Typography } from '~/components/Typography';
import { black_8 } from '~/theme/colors';
import { Radius } from '~/theme/radius';
import { Spacing } from '~/theme/spacing';
import { CodeForm } from './CodeForm';
import type { CodeFormProps } from './CodeForm/types';
import { PhoneForm } from './PhoneForm';
import type { PhoneFormProps } from './PhoneForm/types';
import type { FormMode } from './types';
export function LoginTemplate() {
const { t } = useTranslation();
const { bottom, top } = useSafeAreaInsets();
const { height } = useWindowDimensions();
const [formMode, setFormMode] = useState<FormMode>('phone');
const [phone, setPhone] = useState('');
const onSubmitPhone: PhoneFormProps['onSuccess'] = async ({ phone: formPhone }) => {
setPhone(formPhone);
setFormMode('code');
};
const onSubmitCode: CodeFormProps['onSuccess'] = async () => {};
return (
<View style={[styles.container, { paddingBottom: bottom, paddingTop: top }]}>
<StatusBar barStyle="dark-content" />
<Video
ignoreSilentSwitch="obey"
muted
paused
playInBackground={false}
playWhenInactive={false}
pointerEvents="none"
repeat
resizeMode="cover"
source={require('~/assets/files/login_video.mp4')}
style={StyleSheet.absoluteFill}
/>
<AvoidSoftInputView avoidOffset={0} showAnimationDelay={0} showAnimationDuration={0} style={{ flex: 1 }}>
<ScrollView
bounces={false}
contentContainerStyle={{ height: height - top - bottom }}
contentInsetAdjustmentBehavior="always"
keyboardShouldPersistTaps="handled"
overScrollMode="never"
>
<View style={styles.content}>
<LiquidView colorScheme="dark" effect="clear" style={styles.liquidView} tintColor={black_8}>
<View style={styles.header}>
<Logo height={48} variant="logo_3" width={120} />
{formMode === 'code' && (
<TouchableOpacity onPress={() => setFormMode('phone')}>
<Typography color="red_zera" variant="caption" weight="regular">
{t('translation:login.switchPhone')}
</Typography>
</TouchableOpacity>
)}
</View>
<View style={styles.titleContainer}>
<Typography variant="title" weight="light">
{t('translation:login.description')}
</Typography>
</View>
<View style={styles.form}>
{formMode === 'phone' ? (
<CodeForm onSuccess={onSubmitCode} phone={phone} />
) : (
<PhoneForm onSuccess={onSubmitPhone} phone={phone} />
)}
</View>
</LiquidView>
</View>
</ScrollView>
</AvoidSoftInputView>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
content: {
flex: 1,
justifyContent: 'flex-end',
padding: Spacing.large,
paddingBottom: 0,
},
form: {
marginTop: Spacing.medium,
},
header: {
alignItems: 'flex-start',
flexDirection: 'row',
justifyContent: 'space-between',
},
liquidView: {
borderRadius: Radius.large,
padding: Spacing.large,
},
titleContainer: {
marginTop: Spacing.medium,
},
});
CodeInput
import React, { useCallback, useRef } from 'react';
import type { FC } from 'react';
import { StyleSheet, View } from 'react-native';
import type { TextInput, TextInputKeyPressEvent, TextInputSubmitEditingEvent } from 'react-native';
import { ErrorMessage } from '~/components/ErrorMessage';
import { Input, styles as inputStyles } from '~/components/Input';
import { Label } from '~/components/Label';
import { black_6 } from '~/theme/colors';
import { Spacing } from '~/theme/spacing';
import type { CodeInputProps } from './types';
export const CodeInput: FC<CodeInputProps> = ({
error,
label,
length,
onChangeText,
returnKeyType,
value,
...props
}) => {
const inputRefs = useRef<Array<TextInput>>(Array(length).fill(null));
const handleChangeText = useCallback(
(text: string, index: number) => {
const newValue = value.split('');
newValue[index] = text;
const combinedValue = newValue.join('');
onChangeText(combinedValue);
// Move to next input if there's a value
if (text && index < length - 1) inputRefs.current[index + 1]?.focus();
},
[length, onChangeText, value],
);
const handleKeyPress = useCallback(
(event: TextInputKeyPressEvent, index: number) => {
// Move to previous input on backspace if current input is empty
if (event.nativeEvent.key === 'Backspace' && !value[index] && index > 0) {
inputRefs.current[index - 1]?.focus();
}
},
[value],
);
const onSubmitEditing = useCallback(
(event: TextInputSubmitEditingEvent, index: number) => {
if (index === length - 1) return props.onSubmitEditing(event);
inputRefs.current[index + 1]?.focus();
},
[length, props],
);
return (
<View>
{label && <Label>{label}</Label>}
<View style={styles.container}>
{Array(length)
.fill(0)
.map((_, index) => (
<Input
{...props}
autoComplete="one-time-code"
containerStyle={styles.inputContainerStyle}
key={`input-code-${index}`}
keyboardType="number-pad"
maxLength={1}
onChangeText={text => handleChangeText(text, index)}
onKeyPress={e => handleKeyPress(e, index)}
onSubmitEditing={e => onSubmitEditing(e, index)}
ref={ref => {
inputRefs.current[index] = ref;
}}
returnKeyType={index === length - 1 ? returnKeyType : 'next'}
style={[styles.input, error ? inputStyles.inputError : undefined]}
textAlign="center"
value={value[index] || ''}
/>
))}
</View>
{!!error && <ErrorMessage style={inputStyles.errorContainer}>{error}</ErrorMessage>}
</View>
);
};
const styles = StyleSheet.create({
container: {
flexDirection: 'row',
gap: Spacing.small,
},
input: {
borderColor: black_6,
borderWidth: 1,
},
inputContainerStyle: {
flex: 1,
},
});
Video
https://github.com/user-attachments/assets/6ea8d07e-9866-4277-92c0-0b982a29a059
Expected behavior
Do not add this extra space to the page scroll.
Reproduction
This project is private