Skip to content

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

@ivanguimam

Description

@ivanguimam

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": "pnpm@10.17.0",
  "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
  • 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

final.mov

Expected behavior

Do not add this extra space to the page scroll.

Reproduction

This project is private

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions