react-native-reanimated icon indicating copy to clipboard operation
react-native-reanimated copied to clipboard

`useAnimatedStyle` causing focus and button press issues in `FlatList`

Open ravis-farooq opened this issue 1 year ago • 14 comments
trafficstars

Description

I am encountering a problem when using animations with FlatList. Specifically, when I use Animated or useAnimatedStyle from Reanimated for tab animations, the following issues occur:

The TextInput in the second tab (and onwards) doesn't gain focus when tapped. Any TouchableOpacity or button inside the FlatList items also becomes unresponsive. When I switched from Reanimated's useAnimatedStyle to Animated from React Native, the issue was resolved, and everything worked as expected. This appears to be a problem with how useAnimatedStyle interacts with FlatList.

Steps to reproduce

Copy the code below into a fresh React Native project. Run the app. Try focusing on the TextInput in the second tab or pressing any button inside the second tab. Comment out Reanimated usage and use React Native's Animated instead to observe the difference.

import React, { useState, useRef } from 'react';
import {
  View,
  TextInput,
  FlatList,
  StyleSheet,
  Dimensions,
  Animated,
  TouchableOpacity,
} from 'react-native';
import { Box, Typography } from '@shopify/restyle';

const { width } = Dimensions.get('window');
const spacing = 16;

type TabData = {
  id: number;
  title: string;
  placeholder: string;
};

const CustomTabView = () => {
  const [tabsData] = useState<TabData[]>([
    { id: 1, title: 'Tab 1', placeholder: 'Enter text for Tab 1' },
    { id: 2, title: 'Tab 2', placeholder: 'Enter text for Tab 2' },
    { id: 3, title: 'Tab 3', placeholder: 'Enter text for Tab 3' },
  ]);

  const [tabValues, setTabValues] = useState<{ [key: number]: string }>({
    1: '',
    2: '',
    3: '',
  });

  const [activeTab, setActiveTab] = useState(0);
  const flatListRef = useRef<FlatList>(null);
  const animatedIndicator = useRef(new Animated.Value(0)).current;

  const handleTabPress = (index: number) => {
    setActiveTab(index);

    // Animate the indicator to the selected tab
    Animated.timing(animatedIndicator, {
      toValue: index * (width / tabsData.length),
      duration: 250,
      useNativeDriver: false,
    }).start();

    flatListRef.current?.scrollToIndex({
      animated: true,
      index,
    });
  };

  const handleInputChange = (id: number, value: string) => {
    setTabValues((prevState) => ({ ...prevState, [id]: value }));
  };

  return (
    <View style={styles.container}>
      {/* Tab Buttons with Animated Indicator */}
      <Box
        flexDirection="row"
        justifyContent="space-around"
        bg="gray200"
        position="absolute"
        width={width - spacing}
        top={0}
        zIndex={1}
        borderRadius="s"
        overflow="hidden"
        style={{
          padding: spacing / 3,
          marginHorizontal: spacing / 2,
        }}
      >
        <Animated.View
          style={[
            styles.indicator,
            {
              width: width / tabsData.length,
              transform: [{ translateX: animatedIndicator }],
            },
          ]}
        />
        {tabsData.map((tab, index) => (
          <TouchableOpacity
            key={index}
            style={styles.tabButton}
            onPress={() => handleTabPress(index)}
          >
            <Typography
              textAlign="center"
              fontFamily="medium"
              color={activeTab === index ? 'globalWhite' : 'textSecondary'}
            >
              {tab.title}
            </Typography>
          </TouchableOpacity>
        ))}
      </Box>

      {/* Content for Each Tab */}
      <FlatList
        ref={flatListRef}
        data={tabsData}
        renderItem={({ item }) => (
          <View style={styles.tabContainer} key={item.id}>
            <TextInput
              style={styles.textInput}
              placeholder={item.placeholder}
              value={tabValues[item.id]}
              onChangeText={(text) => handleInputChange(item.id, text)}
              onFocus={() => console.log(`Focused on Tab ${item.id}`)}
            />
          </View>
        )}
        keyExtractor={(item) => item.id.toString()}
        horizontal
        showsHorizontalScrollIndicator={false}
        pagingEnabled
        keyboardShouldPersistTaps="always"
        removeClippedSubviews={false} // Important to ensure inputs offscreen remain interactive
        onMomentumScrollEnd={(event) => {
          const index = Math.floor(event.nativeEvent.contentOffset.x / width);
          setActiveTab(index);
          Animated.timing(animatedIndicator, {
            toValue: index * (width / tabsData.length),
            duration: 250,
            useNativeDriver: false,
          }).start();
        }}
      />
    </View>
  );
};

const styles = StyleSheet.create({
  container: {
    flex: 1,
    justifyContent: 'center',
    alignItems: 'center',
    marginTop: 100, // Space for tab bar at the top
  },
  tabContainer: {
    width,
    justifyContent: 'center',
    alignItems: 'center',
  },
  textInput: {
    width: '80%',
    borderColor: '#ccc',
    borderWidth: 1,
    padding: 10,
    borderRadius: 5,
  },
  tabButton: {
    flex: 1,
    alignItems: 'center',
  },
  indicator: {
    position: 'absolute',
    top: 0,
    bottom: 0,
    backgroundColor: 'blue',
    borderRadius: 10,
  },
});

export default CustomTabView;

Snack or a link to a repository

https://snack.expo.dev/_cgYFhPQvKDe1htoimwii

Reanimated version

3.16.1

React Native version

0.76

Platforms

Android

JavaScript runtime

None

Workflow

None

Architecture

None

Build type

None

Device

None

Device model

No response

Acknowledgements

Yes

ravis-farooq avatar Nov 15 '24 09:11 ravis-farooq

I've got the same problem, any update on it ?

NguyenDuyThang avatar Dec 02 '24 07:12 NguyenDuyThang

Same

alban-ameti avatar Dec 10 '24 03:12 alban-ameti

Same issue

DarkShtir avatar Dec 13 '24 12:12 DarkShtir

Same issue, some button unresponsive, Even not in the same screen.

wvq avatar Dec 25 '24 01:12 wvq

Same issue guys ! I can help to reproduce it also cause issues on Scrollview component.

mateoabrbt avatar Jan 23 '25 13:01 mateoabrbt

hi @ravis-farooq, I ran the provided snack (without shopify components, due to some problems with them), and text inputs focus and work. Do you still have the same problem? Is the tab animation the issue? Maybe you can provide me with a video or another snakc for better understanding of the problem c:

https://github.com/user-attachments/assets/e8301ae4-6e2a-4557-a74c-7a374f76c82a

patrycjakalinska avatar Jan 23 '25 16:01 patrycjakalinska

Hello, I can help reproduce this issue.

It appears to work fine on an emulator, but I discovered the problem when testing on a physical device. I’ve tested this on multiple Android devices running different versions of Android, and the issue persists.

To clarify, I used the TouchableOpacity component from React Native. It worked well when I wasn’t scrolling, but during scrolling (around 20% of the scroll position), the button stopped functioning properly. The animation of the button still triggered, but no functions were called.

I found a workaround by using the Pressable component from react-native-gesture-handler, which resolved the issue for me.

Here is a video of the issue:

https://github.com/user-attachments/assets/6e20fff6-ea5d-4145-943a-c7d6e8ffc95f

I've used the following versions for testing:

  • "react-native": "0.77.0"
  • "react-native-reanimated": "4.0.0-nightly-20250122"

However, the issue persists even with the latest stable release:

  • "react-native-reanimated": "3.16.7"
  • "react-native": "0.76.6"

I think this issue is related also.

Here is a part of my code also :

const imageAnimatedStyle = useAnimatedStyle(() => {
  return {
    height: interpolate(
      scrollHandlerOffset.value,
      [-height / 2, 0, height / 3],
      [IMG_HEIGHT * 2, IMG_HEIGHT, IMG_HEIGHT / 1.5],
      'clamp',
    ),
  };
});

return (
  <CustomSafeAreaView shouldNotUseTopInsets style={styles.container}>
    <Animated.View style={[{width: width}, imageAnimatedStyle]}>
      {loading ? (
        <>
          <SkeletonView style={styles.skeletonimage} />
          <HeaderRecipe
            data={data}
            loading={loading}
            handleShare={handleShare}
            optimisticFavorited={like}
            setOptimisticLike={setOptimisticLike}
            isEvent={navigation.getParent()?.getState().index === 3}
          />
        </>
      ) : (
        <CustomBackgroundImage
          key={data?.image}
          style={styles.image}
          resizeMode="cover"
          source={{
            uri: data?.image,
          }}
          fallbackProps={{
            resizeMode: 'contain',
            source: require('@assets/images/Plate.png'),
          }}>
          <View style={styles.layer}>
            <HeaderRecipe
              data={data}
              loading={loading}
              handleShare={handleShare}
              optimisticFavorited={like}
              setOptimisticLike={setOptimisticLike}
              isEvent={navigation.getParent()?.getState().index === 3}
            />
            <View style={styles.titlecontainer}>
              <CustomText
                numberOfLines={2}
                testID="headertitle"
                style={styles.title}>
                {loading ? t('recipeDetails.loading') : data?.title}
              </CustomText>
              <View style={styles.timercontainer}>
                <Ionicons size={25} color={WHITE} name={'stopwatch'} />
                <CustomText style={styles.timertext}>
                  {loading
                    ? t('SignUpScreen.LoadingUsernameCheck')
                    : getTime(data?.totalTime ?? 0)}
                </CustomText>
              </View>
            </View>
          </View>
        </CustomBackgroundImage>
      )}
    </Animated.View>
    <View style={[styles.borderview, {backgroundColor: colors.background}]} />
    <Animated.ScrollView
      ref={scrollRef}
      scrollEventThrottle={5}
      scrollEnabled={!loading}
      showsVerticalScrollIndicator={!loading}
      contentContainerStyle={{
        paddingBottom: 20,
        paddingHorizontal: 15,
      }}>
      <RecipeActionsView
        recipeId={data?.id}
        disabled={loading || !data}
        onPressLiveRecipe={RequestCameraPermission}
      />
      <View style={[styles.recipecontainer, {borderColor: colors.border}]}>
        <CustomText
          style={[
            styles.subtitle,
            {
              width: width - 150,
              color: colors.primary,
              borderBottomColor: colors.primary,
            },
          ]}>
          {t('recipeDetails.ingredients')}
        </CustomText>
        <View style={styles.modifyquantitycontainer}>
          {loading ? (
            <SkeletonView style={styles.modifyquantityskeleton} />
          ) : (
            <ModifyQuantityButton
              style={styles.modifyquantity}
              quantity={numberOfPeople}
              onPressLess={() => modifyItemQuantity(numberOfPeople - 1)}
              onPressMore={() => modifyItemQuantity(numberOfPeople + 1)}
              unit={
                numberOfPeople === 1
                  ? t('recipeDetails.person')
                  : t('recipeDetails.people')
              }
            />
          )}
        </View>
      </View>
    </Animated.ScrollView>
  </CustomSafeAreaView>
);

I would also like to mention that switching to ScrollView from React Native did not resolve the issue. The only effective solution I found, without using the Pressable component from react-native-gesture-handler, was to replace the Animated.View with a simple View.

mateoabrbt avatar Jan 23 '25 16:01 mateoabrbt

Hi @mateoabrbt, I put some items inside Animated.ScrollView, and also modifyQuantityButtons inside ScrollView as well. I tested either Button component and TouchableOpacity, and both of them work when scrolled. 😅 I'll attach my minimal reproduction of your code

https://github.com/user-attachments/assets/66e056c5-b89d-449e-815b-8f2874e38e1a

https://github.com/user-attachments/assets/2e02cf8c-0f6b-4c00-9c2e-4f1aee72434c

code
import React, { useState, useRef } from 'react';
import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
import Animated from 'react-native-reanimated';
import { SafeAreaView } from 'react-native-safe-area-context';

export default function App() {
  const [quantity, setQuantity] = useState(2);
  const scrollRef = useRef(null);

  const modifyItemQuantity = (quantity) => {
    if (quantity > 0) setQuantity(quantity);
  };

  return (
    <SafeAreaView style={styles.container}>
      <Animated.ScrollView
        ref={scrollRef}
        scrollEventThrottle={5}
        contentContainerStyle={styles.scrollContentContainer}>
        <View style={styles.recipeContainer}>
          <Text style={styles.subtitle}>Ingredients</Text>
          <View style={styles.modifyQuantityContainer}>
            {/* <View style={styles.modifyQuantityButtons}>
                 <Button
                   onPress={() => modifyItemQuantity(quantity - 1)}
                   title="Minus"
                 />
                 <Text style={styles.quantityText}>{quantity}</Text>
                 <Button
                   title="Plus"
                   onPress={() => modifyItemQuantity(quantity + 1)}
                 />
               </View> */}
            <View style={styles.modifyQuantityButtons}>
              <TouchableOpacity
                onPress={() => modifyItemQuantity(quantity - 1)}>
                <Text
                  style={{
                    borderColor: 'red',
                    borderWidth: 2,
                    padding: 8,
                    backgroundColor: 'pink',
                    margin: 8,
                  }}>
                  Minus
                </Text>
              </TouchableOpacity>
              <Text style={styles.quantityText}>{quantity}</Text>
              <TouchableOpacity
                onPress={() => modifyItemQuantity(quantity + 1)}>
                <Text
                  style={{
                    borderColor: 'green',
                    borderWidth: 2,
                    padding: 8,
                    backgroundColor: 'palegreen',
                    margin: 8,
                  }}>
                  Plus
                </Text>
              </TouchableOpacity>
            </View>
          </View>
        </View>
        {Array.from({ length: 20 }).map((_, index) => (
          <View key={index} style={styles.scrollItem}>
            <Text style={styles.scrollItemText}>Item {index + 1}</Text>
          </View>
        ))}
      </Animated.ScrollView>
    </SafeAreaView>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#fff',
  },
  layer: {
    flex: 1,
    justifyContent: 'flex-end',
    padding: 16,
  },
  scrollContentContainer: {
    padding: 16,
  },
  recipeContainer: {
    padding: 16,
    borderWidth: 1,
    borderColor: '#ddd',
    borderRadius: 8,
  },
  subtitle: {
    fontSize: 18,
    fontWeight: 'bold',
    marginBottom: 8,
  },
  modifyQuantityContainer: {
    flexDirection: 'row',
    alignItems: 'center',
  },
  modifyQuantityButtons: {
    flexDirection: 'row',
    alignItems: 'center',
  },
  button: {
    fontSize: 24,
    color: '#007BFF',
    marginHorizontal: 8,
  },
  quantityText: {
    fontSize: 18,
    fontWeight: 'bold',
  },
  scrollItem: {
    padding: 16,
    borderBottomWidth: 1,
    borderBottomColor: '#ddd',
  },
  scrollItemText: {
    fontSize: 16,
  },
});

patrycjakalinska avatar Jan 24 '25 11:01 patrycjakalinska

Hello @patrycjakalinska ! That’s interesting, but I believe I know why it didn’t work as expected. The issue with the code I sent wasn’t related to the Animated.ScrollView itself but rather to the Animated.View I added on top of the ScrollView to create the effect of the image shrinking in height before the scroll starts. When I removed that part, everything worked perfectly without any issues. However, adding the Animated.View—even with no children inside—caused the bug.

const imageAnimatedStyle = useAnimatedStyle(() => {
  return {
    height: interpolate(
      scrollHandlerOffset.value,
      [-height / 2, 0, height / 3],
      [IMG_HEIGHT * 2, IMG_HEIGHT, IMG_HEIGHT / 1.5],
      'clamp',
    ),
  };
});

return (
  <CustomSafeAreaView shouldNotUseTopInsets style={styles.container}>
    <Animated.View style={[{width: width}, imageAnimatedStyle]}>
      {children}
    </Animated.View>
  </CustomSafeAreaView>
);

mateoabrbt avatar Jan 25 '25 12:01 mateoabrbt

I found the problem. The animation caused the click event of the internal element of Animated.View to be abnormal.

Encapsulates a hook to obtain the translate style of the current panel

import { useEffect } from 'react';
import {
  Easing,
  WithTimingConfig,
  useAnimatedStyle,
  useSharedValue,
  withTiming,
} from 'react-native-reanimated';

const DEFAULT_TIME_CONFIG: WithTimingConfig = {
  duration: 300,
  easing: Easing.out(Easing.ease),
};

export default function useTranslateY(
  visible?: boolean,
  options?: {
    timeConfig?: WithTimingConfig;
    onComplete?: (value: number) => void;
  },
) {
  const translateY = useSharedValue(100);
  const timeConfig = options?.timeConfig || DEFAULT_TIME_CONFIG;

  useEffect(() => {
    if (visible) {
      translateY.value = withTiming(0, timeConfig, () =>
        options?.onComplete?.(0),
      );
    } else {
      translateY.value = withTiming(100, timeConfig, () =>
        options?.onComplete?.(100),
      );
    }
  }, [options, timeConfig, translateY, visible]);

  const animatedTranslateY = useAnimatedStyle(() => ({
    transform: [{ translateY: `${translateY.value}%` }], //  effect press event, all of press event and touch event dont work, event animation done
    // transform: [{ translateY: visible ? 0 : `${translateY.value}%` }],    // it work without animation
  }));

  return animatedTranslateY;
}

In Android, the Panel child elements cannot trigger press and touch events normally, but there seems to be no problem in iOS.

const PanelBase: React.FC<PropsWithChildren<IPanelBaseProps>> = ({
  visible,
  maxHeightPercent = 0.7,
  minHeightPercent = 0.3,
  children,
  containerStyle,
  ...modalBaseProps
}) => {
  const insets = useSafeAreaInsets();
  const animatedTranslateY = useTranslateY(visible);

  const heightStyle = useMemo(() => {
    let maxHeight = SCREEN_HEIGHT * maxHeightPercent + insets.bottom;
    if (maxHeight > SCREEN_HEIGHT) {
      maxHeight = SCREEN_HEIGHT;
    }
    let minHeight = SCREEN_HEIGHT * minHeightPercent + insets.bottom;
    if (minHeight > SCREEN_HEIGHT) {
      minHeight = SCREEN_HEIGHT;
    }
    if (minHeight > maxHeight) {
      minHeight = maxHeight;
    }
    return { maxHeight, minHeight };
  }, [insets.bottom, maxHeightPercent, minHeightPercent]);

  return (
    <ModalBase visible={visible} {...modalBaseProps}>
      <Animated.View style={[styles.content, animatedTranslateY]}>
        <View
          style={[
            heightStyle,
            { paddingBottom: insets.bottom || px2dp(16) },
            containerStyle,
          ]}>
          {children}
        </View>
      </Animated.View>
    </ModalBase>
  );
};

hec9527 avatar Mar 26 '25 02:03 hec9527

I see thanks for that update !

mateoabrbt avatar Mar 26 '25 08:03 mateoabrbt

Hi everyone, please check if the issue persists if following feature flags are added inside /android/gradle.propertites:

updateRuntimeShadowNodeReferencesOnCommit=true
useShadowNodeStateOnClone=true

If you want to dig more into this feature flags you can go to the related PR. Also make sure you are using RN version that enabled this flags.

The issue you're encountering might be from React Native itself. If it's true, it appears to be caused by outdated state being cloned, which results in an incorrect hitbox being applied to Pressable components - you can read more about it in this issue (big kudos to @bartlomiejbloniarz ).

Please let me know if it helped!

patrycjakalinska avatar May 08 '25 08:05 patrycjakalinska

@patrycjakalinska This fix does not work for us in all cases on Android using Animated.ScrollView. We switched to 0.79.2, enabled those flags, but after scroll (we also transform ScrollView translateY with useAnimatedStyle in this case to move it up which is actually breaking taps) we still don't receive onPress events on some elements but receive onPressIn and onPressOut.

Sky avatar May 09 '25 15:05 Sky

Same issue, any update on this ?

hoandm91 avatar Jun 14 '25 07:06 hoandm91

I also ran into another similar issue. I was using Animated.View from react-native-reanimated, and my Raw Bottom Sheet wasn’t working properly — the gestures and animations were buggy or didn’t trigger at all.

After some debugging, I switched to Animated.View from react-native instead, and that resolved the issue. If you're using reanimated, it might be worth checking if it's interfering with gesture handling or the sheet’s rendering.

Hope this helps someone else facing the same problem!

ravis-farooq avatar Jul 02 '25 09:07 ravis-farooq

Update Reanimated to 4.1.0 with RN 0.79 with the two flags fixed the issue on my side!

HugoGresse avatar Sep 18 '25 15:09 HugoGresse