react-native-avoid-softinput icon indicating copy to clipboard operation
react-native-avoid-softinput copied to clipboard

IOS - Scroll bug when next input focus when onSubmitEditing is called

Open ivanguimam opened this issue 2 months ago • 2 comments

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

ivanguimam avatar Sep 25 '25 12:09 ivanguimam