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

Animated.View: Touches are not working for positioned absolutely animated.view on Android only

Open khldonAlkateeh opened this issue 2 months ago • 18 comments

Description

https://github.com/user-attachments/assets/7bdc03a6-eaec-4df1-8ac7-8bfa2f3bd757

<Animated.View className="flex-1 items-center justify-center bg-red-600">
  <Animated.View className="h-40 w-48 bg-green-800">
    <Animated.View className="absolute top-96 h-20 bg-blue-800">
      <Switch onValueChange={setIsEnabled} value={isEnabled} />
    </Animated.View>
  </Animated.View>
</Animated.View>

When an Animated.View (the blue box) inside another Animated.View(the green box) is absolutely positioned so that it goes outside the layout of its parent, it stops receiving touches. therefore the < switch /> stops working

However, if I replace the parent Animated.View(the green box) with a normal View, everything works fine:

// it works as shown in the video below 
<Animated.View className="flex-1 items-center justify-center bg-red-600">
  <View className="h-40 w-48 bg-green-800">
    <Animated.View className="absolute top-96 h-20 bg-blue-800">
      <Switch onValueChange={setIsEnabled} value={isEnabled} />
    </Animated.View>
  </View>
</Animated.View>

https://github.com/user-attachments/assets/d2775f12-fe50-47f0-8e73-e96878facde1

Also, this issue does not happen on iOS. touches work perfectly fine there

Steps to reproduce

1- Create an Animated.View as the main container (for example, a full-screen red background).

2- Inside that container, create another Animated.View. this will act as the parent box (for example, a green box with fixed width and height).

3- Inside the green box, create a third Animated.View that has position: 'absolute' and a large top value so it goes outside the green box

4- Inside that absolutely positioned blue box, create a touchable element such as a Switch from react native

5- Run the app on Android and try interacting with the Switch:

You’ll notice that the Switch doesn’t respond to touches when it’s outside the green box (its parent).

6- Run the same setup on iOS, or replace the parent green Animated.View with a regular View: In both cases, the Switch works normally, even when positioned outside the parent’s layout.

Snack or a link to a repository

https://snack.expo.dev/@khldon123/ludicrous-yellow-cheese

Reanimated version

4.1.1

Worklets version

0.5.1

React Native version

0.81.4

Platforms

Android

JavaScript runtime

Hermes

Workflow

Expo Dev Client

Architecture

New Architecture (Fabric renderer)

Build type

Debug app & dev bundle

Device

Real device

Host machine

Windows

Device model

Xiaomi Redmi Note 13 Pro+

Acknowledgements

Yes

khldonAlkateeh avatar Oct 29 '25 14:10 khldonAlkateeh

Hey @khldonAlkateeh! Thanks for reporting the issue!

I just want to say that I was able to reproduce the issue and can confirm that it is caused by the Animated.View component from reanimated. I was able to track down the root cause, which is the collapsable: false setting on the view rendered internally and by passing a custom nativeID to the component.

Unfortunately, we need to use both of these props to ensure that Animated views aren't flattened (removed as a part of the RN optimization) as we don't want to remove views that have animations. We also need to pass the custom nativeID as it is needed by layout animations.

I think we should keep this issue open until we find a valid solution.

MatiPl01 avatar Oct 30 '25 14:10 MatiPl01

hey @MatiPl01 thanks for your time and the detailed explanation

Do you think there’s any workaround for this in the meantime? I’m kind of stuck because of it and can’t really move forward with my app until it’s resolved

khldonAlkateeh avatar Oct 30 '25 14:10 khldonAlkateeh

My only suggestion is not to use RN's touchable components and stick to ones from react-native-gesture-handler. You can either implement your own switch or use this trick where you wrap the RN's Switch component with the RNGH's GestureDetector:

function GHSwitch({ value, onValueChange, ...rest }: SwitchProps) {
  return (
    <GestureDetector
      gesture={Gesture.Tap()
        .onEnd(() => {
          onValueChange?.(!value);
        })
        .runOnJS(true)}>
      <Switch value={value} {...rest} />
    </GestureDetector>
  );
}

MatiPl01 avatar Oct 30 '25 14:10 MatiPl01

@MatiPl01

Just to clarify. in my app I’m not using a Switch. I only used it in the example to make the issue easier to show.

In fact, the element inside the absolutely positioned Animated.View is a react-native-youtube-iframe (which is basically a webview) . i used the Switch in the repro to confirm that the problem isn’t caused by the YouTube iframe itself. if i replace the Animated.View with a View every thing works fine(i can play, pause..etc the iframe)

So unfortunately this workaround won't work in my case. and as far as I know the touches inside a webview are handled by the native web engine

i hope this issue gets resolved soon thanks again

khldonAlkateeh avatar Oct 30 '25 15:10 khldonAlkateeh

Same issue. Did you find a solution?

Ghalia-saleh avatar Oct 31 '25 21:10 Ghalia-saleh

@Ghalia-saleh Unfortunately not yet..no solution. still struggling with this bug @MatiPl01 any update ?

khldonAlkateeh avatar Nov 01 '25 18:11 khldonAlkateeh

I'm facing the same issue

koteba avatar Nov 04 '25 11:11 koteba

@tjzel @patrycjakalinska @m-bert guys, can you help solve this bug?

khldonAlkateeh avatar Nov 09 '25 22:11 khldonAlkateeh

@MatiPl01

hey

I tried to patch the issue locally

Here’s what I did:

Image

I changed it manually and npx expo run:android

Unfortunately, nothing changed. the touch issue still happens

I assume this is happening on the native side (Java, C++)

Could you please point me to the exact file or native module where collapsable: false is being set(the one causes the issue)?

In my app I’m using Reanimated in a very simple way So even if I patch the collapsible behavior, it won’t break anything in my current app. i will patch it locally until we find a good solution

khldonAlkateeh avatar Nov 13 '25 20:11 khldonAlkateeh

@khldonAlkateeh I don't recommend making such changes as they may break reanimated in multiple places, especially when views which you animate are flattened by RN as a part of optimization and you won't see some animations as a result.

If you are doing this just for a testing purpose, you should:

  • remove the nativeID prop (here) - it will likely break Reanimated entering layout animations
  • remove the collapsable: false prop or the entire platformProps object (here) - it will break for views that are removed by RN as a part of optimization - you won't see these views, even if you attach the animated style to them and try to animate them

Again, I strongly discourage patching reanimated in such a way.

MatiPl01 avatar Nov 13 '25 21:11 MatiPl01

@MatiPl01

Image Image

i tested it the issue still happens the issue isn’t caused by collapsable=true or nativeID.

khldonAlkateeh avatar Nov 14 '25 13:11 khldonAlkateeh

Ok, thanks @khldonAlkateeh for letting me know. I will test it again on my end then after the weekend to make sure that this is not the cause.

MatiPl01 avatar Nov 15 '25 10:11 MatiPl01

@MatiPl01 Thank YOU Honestly, I felt relieved when I realized the issue isn’t caused by the collapsable or nativeID props. the root cause might be something else that’s actually fixable. anyway, I’m ready to test any potential solution you come up with Enjoy your weekend

khldonAlkateeh avatar Nov 15 '25 13:11 khldonAlkateeh

@khldonAlkateeh I checked it again and it seems that I was right that the switch can be pressed after removing these 2 props. Maybe you haven't re-opened the app to ensure that your JS changes were successfully applied?

Please take a look at this recording:

https://github.com/user-attachments/assets/8fcf1943-acc6-4f3b-a710-3e01d3364f74

Example source code
import { useState } from 'react';
import { Switch, StyleSheet } from 'react-native';
import Animated from 'react-native-reanimated';

export default function Example() {
  const [isEnabled, setIsEnabled] = useState(false);

  return (
    <Animated.View style={styles.container}>
      <Animated.View style={styles.innerBox}>
        <Animated.View style={styles.absoluteBox}>
          <Switch onValueChange={setIsEnabled} value={isEnabled} />
        </Animated.View>
      </Animated.View>
    </Animated.View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    alignItems: 'center',
    justifyContent: 'center',
    backgroundColor: '#dc2626',
  },
  innerBox: {
    height: 160,
    width: 192,
    backgroundColor: '#065f46',
  },
  absoluteBox: {
    position: 'absolute',
    top: 384,
    height: 80,
    backgroundColor: '#1e3a8a',
  },
});

MatiPl01 avatar Nov 15 '25 15:11 MatiPl01

really appreciate your time @MatiPl01 first of all: great. now i have two issues 🤣

You're totally right regarding root cause, but it seems like in my case there was multiple factors contributing at the same time(they all are from reanimated)

https://github.com/user-attachments/assets/385ac685-2288-4e6d-b73b-ee721fd505b7

notice in the video when i remove canvasAnimatedStyle it works fine.

For my specific case, I'm ok with applying the first workaround(removing collapsible and nativeID). in fact I'm planning to patch it locally and create a custom component called AnimatedCollapsableComponent and I'll use it only in this part of my code, where removing nativeID and collapsable won't affect anything, since here I'm simply using animatedStyle. but now we get this issue with animatedStyle

source code
import { YouTubeCard } from '@/components/cards/Item';
import { SCREEN_HEIGHT, SCREEN_WIDTH } from '@gorhom/bottom-sheet';
import { ContentPlatform } from '@/types';
import { View, StyleSheet, useWindowDimensions, Switch, Pressable, Text } from 'react-native';
import Animated, {
  clamp,
  useSharedValue,
  useAnimatedStyle,
  withDecay,
} from 'react-native-reanimated';
import { Gesture, GestureDetector, GestureHandlerRootView } from 'react-native-gesture-handler';
import YoutubePlayer from 'react-native-youtube-iframe';
import { useState } from 'react';

const BOX_SIZE = 400;
const MIN_SCALE = 0.1;
const MAX_SCALE = 3;

// ========================
// Screen: SearchScreen (Canvas)
// ========================
export default function App() {
  // Canvas transform shared values
  const translateX = useSharedValue(0);
  const translateY = useSharedValue(0);
  const scale = useSharedValue(1);
  const startTranslateX = useSharedValue(0);
  const startTranslateY = useSharedValue(0);
  const startScale = useSharedValue(1);
  const focalX = useSharedValue(0);
  const focalY = useSharedValue(0);
  const prevPanX = useSharedValue(0);
  const prevPanY = useSharedValue(0);
  const showFocalPoint = useSharedValue(false);
  const { width: screenWidth, height: screenHeight } = useWindowDimensions();

  const [isEnabled, setIsEnabled] = useState(false);

  // Canvas animated style (pan + zoom)
  const canvasAnimatedStyle = useAnimatedStyle(() => {
    return {
      transform: [
        { translateX: translateX.value },
        { translateY: translateY.value },
        { scale: scale.value },
      ],
    };
  });

  // Focal point indicator (for dev purposes)
  const focalPointStyle = useAnimatedStyle(() => {
    return {
      position: 'absolute',
      left: focalX.value - 10,
      top: focalY.value - 10,
      width: 20,
      height: 20,
      borderRadius: 10,
      backgroundColor: '#3b82f6',
      opacity: showFocalPoint.value ? 0.8 : 0,
      transform: [{ scale: showFocalPoint.value ? 1 : 0.5 }],
    };
  });

  // ========================
  // Gestures: Pan the canvas
  // ========================
  const panGesture = Gesture.Pan()
    .maxPointers(1)
    .minDistance(4)
    .onStart((event) => {
      prevPanX.value = event.translationX;
      prevPanY.value = event.translationY;
    })
    .onUpdate((event) => {
      const deltaX = event.translationX - prevPanX.value;
      const deltaY = event.translationY - prevPanY.value;
      translateX.value += deltaX;
      translateY.value += deltaY;
      prevPanX.value = event.translationX;
      prevPanY.value = event.translationY;
    })
    .onEnd((event) => {
      translateX.value = withDecay({ velocity: event.velocityX, deceleration: 0.995 });
      translateY.value = withDecay({ velocity: event.velocityY, deceleration: 0.995 });
    });

  // ========================
  // Gestures: Pinch to zoom (focal zoom)
  // ========================
  const pinchGesture = Gesture.Pinch()
    .onStart((event) => {
      'worklet';
      startScale.value = scale.value;
      startTranslateX.value = translateX.value;
      startTranslateY.value = translateY.value;

      focalX.value = event.focalX;
      focalY.value = event.focalY;
      showFocalPoint.value = true;
    })
    .onUpdate((event) => {
      'worklet';
      const zoomSensitivity = 1;
      const rawScale = 1 + (event.scale - 1) * zoomSensitivity;
      scale.value = clamp(startScale.value * rawScale, MIN_SCALE, MAX_SCALE);

      translateX.value =
        focalX.value - ((focalX.value - startTranslateX.value) / startScale.value) * scale.value;
      translateY.value =
        focalY.value - ((focalY.value - startTranslateY.value) / startScale.value) * scale.value;
    })
    .onEnd(() => {
      'worklet';
      showFocalPoint.value = false;
    });

  // ========================
  // Compose gestures (pan + pinch)
  // ========================
  const composedGesture = Gesture.Simultaneous(panGesture, pinchGesture);

  // ========================
  // Render
  // ========================

  // return (
  //   <Animated.View className="flex-1 items-center justify-center bg-red-600">
  //     <Animated.View className="h-40 w-48 bg-green-800">
  //       <Animated.View className="absolute top-96 h-20 bg-blue-800">
  //         <Switch onValueChange={setIsEnabled} value={isEnabled} />
  //       </Animated.View>
  //     </Animated.View>
  //   </Animated.View>
  // );
  return (
    <GestureHandlerRootView style={styles.root}>
      <GestureDetector gesture={composedGesture}>
        <View style={styles.canvasWrapper}>
          <Animated.View style={[styles.canvasTransform]}>
            <Animated.View
              style={{
                position: 'absolute',
                left: -100,
                top: 200,
                width: 500,
                height: 400,
                backgroundColor: 'green',
              }}>
              <Switch onValueChange={setIsEnabled} value={isEnabled} />
            </Animated.View>
          </Animated.View>
        </View>
      </GestureDetector>
    </GestureHandlerRootView>
  );
}

// ========================
// Styles
// ========================

const styles = StyleSheet.create({
  root: {
    flex: 1,
    width: SCREEN_WIDTH,
    height: SCREEN_HEIGHT,
    backgroundColor: 'green',
  },
  canvasWrapper: {
    flex: 1,
    // backgroundColor: '#e8ecf5',
    backgroundColor: 'blue',
  },
  canvasTransform: {
    width: '50%',
    height: '100%',

    transformOrigin: 'top left',
    backgroundColor: 'red',
  },
  canvasSurface: {
    width: BOX_SIZE,
    height: BOX_SIZE,

    borderRadius: 16,
    borderWidth: 1,
    borderColor: '#dfe4f3',
  },
});

khldonAlkateeh avatar Nov 16 '25 15:11 khldonAlkateeh

@MatiPl01

Image

After digging deeper into react native source code, here is what i found:

android strictly clips touch events based on the parent bounds. isTouchPointInView returns false as soon as the coords are outside the parent's bounds on IOS, this issue doesn't happen because UIKIT uses a different hit-testing model. UIView can receive touches outside its bounds

So the fix might need to happen in react native, not in reanimated. What do you think about this ?

khldonAlkateeh avatar Nov 22 '25 18:11 khldonAlkateeh

Hey @khldonAlkateeh!

That's an interesting finding. You may be right that the main issue is caused by the implementation of the touch gesture handling logic in react-native, because everything on iOS works fine and reanimated doesn't implement any different logic that may affect only Android phones.

I think that the main problem is that react native sometimes decides to flatten the view and then its bounds aren't taken into account, but it considers its parent and uses its bounds to constrain touch events. But, if that happens, it should also happen with React Native views under certain conditions, even without using Reanimated.

Can you possibly open an issue on the react native repo with your findings? I feel that the problem is caused by React Native and we can barely do anything on our end.

MatiPl01 avatar Nov 24 '25 18:11 MatiPl01

Hey @MatiPl01

it should also happen with React Native views under certain conditions, even without using Reanimated.

That’s a very good point

i think(i'm not sure) the reason is that the non-flattened structure changes which view becomes responsible for hit-testing, and that’s exactly where Android rejects touches that fall outside the parent bounds. In pure react native, when the view gets flattened, the child becomes the direct hit-target, so the issue doesn’t show up. An Animated.View is not flattened, the parent remains the hit-target, android rejects touches outside its bounds.

This is why the issue seems exclusive to reanimated, even though the underlying behavior comes from react native’s android hit-testing model. again, i'm not sure

i think adding a small opt-in prop on android (something like allowOverflowTouch) could be a good direction. it would let the developer decide whether touches should be allowed outside the parent bounds or not anyway, i will open an issue for that on react native repo

Also, thank you for your time. I know this issue has taken a bit of your time already, but I have one last question. In my previous video, when removing the animated style(useAnimatedStyle) the Switch works fine, but having the animated style breaks the switch touch.

khldonAlkateeh avatar Nov 24 '25 18:11 khldonAlkateeh