typescriptreact-nativescrollviewreact-native-flatlist

TextInput losing focus when keyboard opens in React Native


I'm working with React Native, and I have a part of my app that contains some input fields inside a scrollable screen. When I tapped on a field, it would gain focus, but after scrolling, the field would trigger onBlur and lose focus — although the keyboard remained open with nothing to type and the keyboardType reverted to "default".

I managed to partially fix this by dismissing the keyboard when scrolling the screen. However, I still have a related issue:

Sometimes, when the input field is too far down the screen, I tap on it, it scrolls up into view, but still immediately triggers onBlur and loses focus. I suspect this might be because, at the moment the keyboard is opening, the input gets briefly overlapped by the keyboard and loses visibility — and by the time it's back on screen, it has already lost focus.

The keyboard stays open even though there’s no input with focus anymore.

Here's a video showing the issue:

enter image description here

What I want is a way to solve this issue — I don’t want the input to lose focus while the keyboard is opening.

Here is my TextInput code:

 (
   {
     disabled = false,
     error = false,
     errorMessage,
     icon,
     iconPressed,
     inputId,
     isTextArea = false,
     label,
     maskType = "custom",
     onchangeText,
     onPressIcon,
     placeholder,
     required = false,
     styleInput,
     styleView,
     type = "string",
     variant = "default",
     width = "100%",
     infoToShow = "",
     ...props
   }: InputProps,
   ref: Ref<TextInput | TextInputMask>, //Passar Ref<TextInput> caso o campo não necessite de máscara. Caso precise, passar Ref<TextInputMask>
 ) => {
   const [focused, setFocused] = useState(false);

   const isDefaultSize = !isTextArea;
   const showIconPressed = false && iconPressed !== undefined;
   let options: TextInputMaskOptionProp;

   switch (maskType) {
     case "credit-card":
       options = {
         obfuscated: false,
         issuer: "visa-or-mastercard",
       };
       break;
     case "money":
       options = {
         unit: "R$",
       };
       break;
     case "cel-phone":
       options = {
         maskType: "BRL",
         withDDD: true,
         dddMask: "(99) ",
       };
       break;
     case "datetime":
       options = { format: "DD/MM/YYYY" };
   }

   const borderStyle = [
     variant === "default"
       ? {
         borderWidth: 1,
         borderRadius: 8,
       }
       : {
         borderBottomWidth: 1,
       },
     {
       borderColor: error
         ? Colors.v4RedError
         : disabled
           ? Colors.v7GrayDisabled
           : focused && variant === "bottom-line"
             ? Colors.primary
             : Colors.v3GraySubtitle,
     },
   ] as StyleProp<ViewStyle>;

   useEffect(() => {
     if (!focused) {
       hideKeyboard();
     }
   }, [focused])


   function textInputByMask() {
     if (maskType === "custom") {
       return (
         <TextInput
           {...props}
           ref={ref as Ref<TextInput>}
           placeholder={placeholder}
           cursorColor={Colors.primary}
           numberOfLines={isDefaultSize ? 1 : 7}
           multiline={!isDefaultSize}
           textAlignVertical={isDefaultSize ? "center" : "top"}
           placeholderTextColor={Colors.v2GrayLargeText}
           editable={!disabled}
           onFocus={val => {
             setFocused(true);
           }}
           onBlur={() => {
             setFocused(false);
           }}
           style={[
             globalStyles.textBody2,
             styleInput,
             { color: Colors.v2GrayLargeText, flex: 1, textAlign: "left" },
           ]}
         />
       );
     }

     return (
       <TextInputMask
         ref={ref as Ref<TextInputMask>}
         {...props}
         keyboardType="number-pad"
         placeholder={placeholder}
         options={options}
         cursorColor={Colors.primary}
         numberOfLines={isDefaultSize ? 1 : 7}
         multiline={!isDefaultSize}
         textAlignVertical={isDefaultSize ? "center" : "top"}
         placeholderTextColor={Colors.v2GrayLargeText}
         editable={!disabled}
         type={maskType}
         onFocus={val => {
           setFocused(true);
         }}
         onBlur={() => {
           setFocused(false)
         }}

         style={[
           globalStyles.textBody2,
           styleInput,
           { color: Colors.v2GrayLargeText, flex: 1, textAlign: "left" },
         ]}
       />
     );
   }

   return (
     <View style={{ gap: 5 }}>
       <LabelRequired
         label={label}
         color={error ? "v4RedError" : "v1GrayTitle"}
         required={required}
         infoToShow={infoToShow}
       />
       <View
         style={[
           allStyles.searchBarContainer,
           styleView,
           borderStyle,
           {
             width,
             backgroundColor: disabled
               ? Colors.v5GrayDivider
               : Colors.transparent,
           },
         ]}>
         {textInputByMask()}
         {icon && (
           <TouchableOpacity
             activeOpacity={onPressIcon ? undefined : 1}
             onPress={() => {
               if (onPressIcon) {
                 onPressIcon();
               }
             }}>
             <IconStyled
               icon={showIconPressed ? iconPressed.icon : icon.icon}
               color={error ? "v4RedError" : icon.color}
               size={icon.size}
             />
           </TouchableOpacity>
         )}
       </View>
       {errorMessage && (
         <Subtitle
           variant="subtitle3"
           color={error ? "v4RedError" : "v3GraySubtitle"}
           fontWeight="NotoSans Regular"
           leftText>
           {errorMessage}
         </Subtitle>
       )}
     </View>
   );
 },
);

And here is the code for the component that renders the list of inputs:

export default function EtapaExecucao({ etapa }: EtapaExecucaoProps) {

   const itensOrdenados = [...etapa.etapaItens].sort((a, b) => a.ordem - b.ordem);
   const firstExpandedControll = itensOrdenados.map((item, index) => false);
   const [opened, setOpened] = useState(firstExpandedControll);

   return (
       <View style={{ flex: 1, paddingVertical: 16 }}>
           <FlatList
               data={itensOrdenados}
               keyExtractor={(_, index) => index.toString()}
               showsVerticalScrollIndicator={false}
               keyboardShouldPersistTaps={true}
               keyboardDismissMode='on-drag'
               automaticallyAdjustContentInsets={false}
               horizontal={false}
               ItemSeparatorComponent={() => (
                   <View style={{ paddingVertical: 16 }}>
                       <Divider />
                   </View>
               )}
               renderItem={({ item, index }) => (
                   <EtapaItemExecucao
                       key={`${etapa.id}-${index}`} // key força a recriação do accordion
                       etapaItem={item}
                       etapaId={etapa.id!}
                       accordion={itensOrdenados.length > 1}
                       opened={opened[index]}
                       onExpand={(expanded) => {
                           setOpened(prev => prev.map((item, i) => i === index ? expanded : false));
                       }}
                   />
               )}
           />
       </View>
   );
}

It might seem like the two are unrelated, but the TextInput is a deep child of EtapaExecucao. I’m not including the rest of the code because it’s too long, but between them there’s no additional list or scrollable component. The hierarchy is: EtapaExecucao > EtapaItemExecucao > ItemChecklist > CheckListCampo > Input > TextInput

Also, I’ve discovered something important: in the top-level parent component, I have two fixed buttons at the bottom of the screen. When I temporarily remove these buttons, the input field no longer loses focus — meaning it doesn’t trigger onBlur anymore.

So it seems the issue happens because the input field gets briefly covered by these fixed bottom buttons while the keyboard is opening, causing it to lose visibility and focus.

However, I need to keep these buttons in the UI. Is there a way to elevate or adjust the input field so it’s never covered (even briefly) by the fixed buttons while the keyboard is opening? Here's the relevant code from the parent component showing the fixed buttons at the bottom:

const routes = etapas.map((_, index) => ({
    key: ${index},
  }));

  const renderScene = ({ route }: any) => {
    const index = parseInt(route.key);
    const etapa = etapasCompletasOrdenadas[index];

    if (etapa?.id === -1) {
      return <Revisao onNavigateToStep={(numEtapa) => setEtapaAtual(numEtapa)} />;
    }

    return <EtapaExecucao etapa={etapa} />;
  };


  const isUltimaEtapa = etapaAtual === etapas.length - 1;

  return (
    <View style={allStyles.fullscreen}>
      <View style={allStyles.defaultScreen}>
        <HeaderEtapasExecucao
          currentStep={etapaAtual}
          navigateToStep={setEtapaAtual}
          descricao={etapasCompletasOrdenadas[etapaAtual]?.descricao}
          stepsList={etapas.map((etapa, index) => ({
            id: index,
            descricao: Etapa ${index + 1}: ${etapa.descricao}
          }))}
        />
        <TabView
          navigationState={{ index: etapaAtual, routes }}
          renderScene={renderScene}
          onIndexChange={() => { }}
          renderTabBar={() => null}
          swipeEnabled={false}
        />
        <BottomEtapas
          etapaId={etapasCompletasOrdenadas[etapaAtual]?.id}
          currentStepFixed={etapaAtual + 1}
          stepDescription={etapasCompletasOrdenadas[etapaAtual + 1]?.descricao}
          onPressButtonNext={() => {
            if (!isUltimaEtapa) {
              setBotaoPressionadoEtapa();
              setEtapaAtual(prev => Math.min(prev + 1, etapas.length - 1));
            }
          }}
          revisao={isUltimaEtapa}
          onPressCancelButton={() => resetBaixa()}
          onPressButtonSubmit={() => finishOs()}
        />
      </View>
    </View>
  );

Solution

  • Try these =>

    1. Wrap your TextInput components in a KeyboardAvoidingView to ensure the input remains visible when the keyboard appears. Use a ScrollView with the keyboardShouldPersistTaps="handled" prop to prevent taps from dismissing the keyboard unnecessarily.

    2. Configure the behavior prop based on the platform (padding for iOS, height or undefined for Android) to adjust the view when the keyboard opens.

    3. If the TextInput is inside a FlatList or ScrollView, set removeClippedSubviews={false} to prevent the input from being unmounted when it goes off-screen, which can cause focus loss.

    4. Use a ref to programmatically manage focus if needed, ensuring the TextInput retains focus after the keyboard opens.