Skip to content

KeyboardAwareScrollView not consistent #1498

Description

@faljabi

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

Metadata

Metadata

Assignees

Labels

KeyboardAwareScrollView 📜Anything related to KeyboardAwareScrollView component🐛 bugSomething isn't working

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions