Describe the bug
KeyboardAwareScrollView is not consistent where the bottomOffset is not respected sometimes and the view is pushed far more.
Code snippet
import Icon from '@react-native-vector-icons/material-design-icons';
import { useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Keyboard, StyleSheet, View } from 'react-native';
import { KeyboardAwareScrollView } from 'react-native-keyboard-controller';
import { TextInput, useTheme } from 'react-native-paper';
import validator from 'validator';
import { z } from 'zod';
import PasswordRecoveryArtwork from '#features/authentication/assets/illustrations/recoverPassword.svg';
import UsernameRecoveryArtwork from '#features/authentication/assets/illustrations/recoverUsername.svg';
import { AUTH_TRANSACTION_TYPE } from '#features/authentication/api/authenticatationApiConstants';
import { SCREEN } from '#features/authentication/constants/authenticationConstants';
import BurganPrimaryButton from '#components/buttons/BurganPrimaryButton';
import ErrorMessage from '#components/feedback/ErrorMessage';
import KeyboardAwareAnimatedView from '#components/keyboard/KeyboardAwareAnimatedView';
import ScreenHeader from '#components/layout/ScreenHeader';
import useSendOtp from '#features/authentication/hooks/useSendOtp';
import useFocusInput from '#hooks/useFocusInput';
import useGlobalStyles from '#hooks/useGlobalStyles';
import useOrientation from '#hooks/useOrientation';
const SendOtp = ({ navigation, route }) => {
const {
phoneNumber: initialPhoneNumber = '',
userId = '',
transactionType,
forgetPassword
} = route.params;
let title;
switch (transactionType) {
case AUTH_TRANSACTION_TYPE.REVEAL_USER_ID:
title = 'revealUserId'; break;
case AUTH_TRANSACTION_TYPE.RESET_USER_PASSWORD:
title = 'resetPassword'; break;
default: title = 'revealUserId'; break;
}
const { t } = useTranslation();
const theme = useTheme();
const styles = useStyles();
const globalStyles = useGlobalStyles();
const phoneNumberInputElRef = useRef(null);
const [phoneNumber, setPhoneNumber] = useState(initialPhoneNumber);
const [phoneNumberInputFocused, setPhoneNumberInputFocused] = useState(false);
const [validationResults, setValidationResults] = useState([]);
const [error, setError] = useState(null);
const { sendOtp, sendOtpPending } = useSendOtp();
const Artwork = useMemo(() => {
let Artwork;
switch (transactionType) {
case AUTH_TRANSACTION_TYPE.REVEAL_USER_ID:
Artwork = UsernameRecoveryArtwork; break;
case AUTH_TRANSACTION_TYPE.RESET_USER_PASSWORD:
Artwork = PasswordRecoveryArtwork; break;
default: Artwork = UsernameRecoveryArtwork; break;
}
return (
<Artwork {...styles.artwork} />
);
}, [transactionType, styles]);
const phoneNumberInputIcon = useMemo(() => {
return (
<TextInput.Icon
style={globalStyles.localizedIcon}
icon={phoneNumberInputFocused ? 'phone' : 'phone-outline'}
rippleColor='transparent'
/>
);
}, [phoneNumberInputFocused, globalStyles]);
const PhoneNumberInputClearButton = useMemo(() => {
if (phoneNumberInputFocused && !validator.isEmpty(phoneNumber))
return (
<TextInput.Icon
icon='close'
disabled={sendOtpPending}
onPress={e => setPhoneNumber('')}
/>
);
}, [phoneNumberInputFocused, phoneNumber, sendOtpPending]);
const PhoneNumberInputError = useMemo(() => {
const validationResult = validationResults.find(validationResult => validationResult.phoneNumber);
if (validationResult) {
return (
<ErrorMessage message={validationResult.phoneNumber} />
);
}
else if (error) {
return (
<ErrorMessage
displayMessage={error.displayMessage}
message={error.message}
/>
);
}
}, [validationResults, error]);
const validateFormInputs = () => {
const schema = z.object({
phoneNumber: z
.string()
.length(8, t('authentication.sendOtp.validPhoneNumberRequired'))
.refine(phoneNumber => validator.isMobilePhone(`+965${phoneNumber}`, 'ar-KW', { strictMode: true }), {
error: t('authentication.sendOtp.validPhoneNumberRequired')
})
});
const formData = schema.safeParse({ phoneNumber });
const validationResults = formData.error
? formData.error.issues.map(issue => ({ [issue.path[0]]: issue.message }))
: [];
setValidationResults(validationResults);
return validationResults;
};
const handleSubmit = async () => {
setError(null);
const validationResults = validateFormInputs();
if (validationResults.length > 0) return;
Keyboard.dismiss();
sendOtp({ phoneNumber, transactionType }, {
onSuccess: () => {
navigation.replace(SCREEN.VERIFY_OTP.NAME, {
transactionType,
phoneNumber,
userId,
forgetPassword
});
},
onError: setError
});
};
useFocusInput({ inputEl: phoneNumberInputElRef });
return (
<KeyboardAwareScrollView
bottomOffset={24}
extraKeyboardSpace={24}
contentContainerStyle={globalStyles.topInsetFreeScrollViewContainer}
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps='handled'>
<View style={styles.container}>
<KeyboardAwareAnimatedView>
{Artwork}
</KeyboardAwareAnimatedView>
<View style={styles.formContainer}>
<ScreenHeader
title={t(`authentication.sendOtp.${title}`)}
subtitle={t('authentication.sendOtp.subtitle')}
titleProps={{
style: styles.title
}}
subtitleProps={{
style: styles.subtitle
}}
/>
<View>
<TextInput
ref={phoneNumberInputElRef}
mode='outlined'
outlineColor={theme.colors.outlineVariant}
inputMode='tel'
autoComplete='tel'
placeholder={t('authentication.sendOtp.phoneNumberInputPlaceholder')}
enablesReturnKeyAutomatically
maxLength={8}
right={PhoneNumberInputClearButton}
left={phoneNumberInputIcon}
editable={!sendOtpPending}
value={phoneNumber}
error={!!validationResults.find(r => r.phoneNumber)}
onFocus={() => setPhoneNumberInputFocused(true)}
onBlur={() => setPhoneNumberInputFocused(false)}
onChangeText={text => setPhoneNumber(text.replace(/\D/g, ''))}
onSubmitEditing={handleSubmit}
/>
{PhoneNumberInputError}
</View>
<BurganPrimaryButton
uppercase
icon={props => (
<Icon
{...props}
style={globalStyles.localizedIcon}
name='arrow-right-thin'
allowFontScaling
/>
)}
disabled={sendOtpPending}
loading={sendOtpPending}
onPress={handleSubmit}>
{t('common.common.next')}
</BurganPrimaryButton>
</View>
</View>
</KeyboardAwareScrollView>
);
};
export default SendOtp;
const useStyles = () => {
const theme = useTheme();
const orientation = useOrientation();
return StyleSheet.create({
container: {
flex: 1,
flexDirection: orientation === 'landscape' ? 'row' : 'column',
alignItems: 'center',
justifyContent: 'center',
gap: theme.spacing(5)
},
artwork: {
width: 196,
height: 196
},
formContainer: {
width: '100%',
maxWidth: 400,
rowGap: theme.spacing(2)
},
title: {
textAlign: orientation === 'landscape' ? 'auto' : 'center'
},
subtitle: {
textAlign: orientation === 'landscape' ? 'auto' : 'center',
color: theme.colors.outline,
paddingBottom: theme.spacing(3)
}
});
};
Repo for reproducing
It is happening for all forms with KeyboardAwareScrollView.
To Reproduce
Video attached.
Expected behavior
KeyboardAwareScrollView consistent with bottomOffset.
Screenshots
Video attached
Smartphone (please complete the following information):
- Device: iPhone 16 ProMax
- OS: iOS 26.5
- RN version: 0.86
- RN architecture: New
- JS engine: Hermes
- lib version: "react-native-keyboard-controller": "^1.21.11"
RPReplay_Final1781308119.MP4.zip
Describe the bug
KeyboardAwareScrollView is not consistent where the bottomOffset is not respected sometimes and the view is pushed far more.
Code snippet
Repo for reproducing
It is happening for all forms with KeyboardAwareScrollView.
To Reproduce
Video attached.
Expected behavior
KeyboardAwareScrollView consistent with bottomOffset.
Screenshots
Video attached
Smartphone (please complete the following information):
RPReplay_Final1781308119.MP4.zip