Skip to content

[Android][New Arch] Buttons inside Animated.View with translate are not clickable #6676

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
delphinebugner opened this issue Nov 7, 2024 · 15 comments · Fixed by #7014
Closed
Labels
Platform: Android This issue is specific to Android Repro provided A reproduction with a snippet of code, snack or repo is provided

Comments

@delphinebugner
Copy link

delphinebugner commented Nov 7, 2024

Description

On my app, I have buttons inside Animated Views that translate up.
Right after the translation finished, the pressable action of the button is not executed on click.
If we execute another action (ex. navigating on an other tab and come back), the pressable action is executed on click.

This is what we see on the repro below:

ReproButtonReanimated

Additional notes:

  • New Arch + Android only
  • On real device only, not on emulator (😅 really weird)
  • Pressable and Buttons have the same error
  • Pressable and Buttons variable opacity are triggered when we click the first time; only the onPress callback is'nt triggered
  • On an animated view with only a variable opacity, no error; it's linked to the translation operation

Code

Code looks like this:

export const AnimatedButton = () => {
  const shiftAmount = useSharedValue(-100);
  const shiftDownStyle = useAnimatedStyle(() => ({
    transform: [{ translateY: shiftAmount.value }],
  }));

  if (shiftAmount.value !== 0) {
    shiftAmount.value = withTiming(0, { duration: 450 });
  }

  return (
    <Animated.View style={shiftDownStyle}>
      <Button
        title="Press me"
        onPress={() => Alert.alert("Im clicked")} // 🚨 not triggered the first time
      />
    </Animated.View>
  );

A full example can be found here: https://github.com/delphinebugner/repro

Further notes

Thanks for working on the lib 🙂

If the problem is linked to a messy Reanimated usage in the component, happy to correct my code!

Steps to reproduce

On the repro application:

  1. Run it on a real device (error does not occur on emulator)
  2. Just after mounting the Home, try to clic on a "Press me" button : nothing happens 🚨
  3. Click on "Explore" Tab
  4. Return to "Home" Tab
  5. Click on a "Press me" button: an alert shows up ✅
  6. Click on the "Toggle animated buttons" to unmount-remount them
  7. Click again a "Press me" button: nothing happens 🚨

Snack or a link to a repository

https://github.com/delphinebugner/repro

Reanimated version

3.16.1

React Native version

0.75.0

Platforms

Android

JavaScript runtime

Hermes

Workflow

Expo Dev Client

Architecture

Fabric (New Architecture)

Build type

Debug app & dev bundle

Device

Real device

Device model

Samsung Galaxy S22

Acknowledgements

Yes

@github-actions github-actions bot added Repro provided A reproduction with a snippet of code, snack or repo is provided Platform: Android This issue is specific to Android Missing info The user didn't precise the problem enough and removed Missing info The user didn't precise the problem enough labels Nov 7, 2024
@hamdij0maa

This comment has been minimized.

@JoaoMosmann
Copy link

I had a similar problem. To fix it I had to use the Pressable component exported by the react-native-gesture-handler library.

@smfunder
Copy link

I have a similar issue, basically I use 'react-native-popover-view' to list a menu with TouchableOpacity from react elements. When I try to press them after showing the popup which uses Animated.View elements inside (pure TypeScript, no native stuff) they are not pressable at all.
I tried with the @JoaoMosmann suggestion for the buttons, but they are not working in my case.
This is happening to me only in Android 13.
"react-native-reanimated": "^3.17.0-rc.0",
"react-native": "0.76.3" (New Arch + bridgeless mode)
Any ideas?

@DarkShtir

This comment has been minimized.

@mluksha

This comment has been minimized.

@tomekzaw
Copy link
Member

tomekzaw commented Feb 4, 2025

@j-piasecki reported another issue with root cause explanation: #6832

@tomekzaw
Copy link
Member

Should be fixed with #7014.

github-merge-queue bot pushed a commit that referenced this issue Feb 25, 2025
…ePropsOnUIThread` (#7014)

## Motivation

Currently, there are two ways to update native view props and styles in
Reanimated. The default path (so-called slow path) is to apply all props
changes to the ShadowTree via C++ API and let React Native mount the
changes. However, if all props updated in given batch are non-layout
props (i.e. those that don't require layout recalculation, like
background color or opacity) we use a fast path that calls
`synchronouslyUpdatePropsOnUIThread` from React Native and applies the
changes directly to platform views, without making changes to ShadowTree
in C++. Turns out, some features like view measurement or touch
detection system use C++ ShadowTree which is not consistent with what's
currently on the screen. Because of that, we're removing the fast path
(turns out it's not that fast, especially on iOS) to restore the
correctness of view measurement and touch detection for animated
components.

## Benchmarks

* Performance monitor example &rarr; Bokeh Example
* Android emulator / iPhone 14 Pro real device
* Debug mode
* Animating `transform` prop using `useAnimatedStyle`

| Platform | Before (main) | After (this PR) |
|:-:|:-:|:-:|
| Android (count=200) | 20 fps | 15 fps |
| iOS (count=500) | 22 fps | 22 fps |

<details>
<summary>App.tsx</summary>

```tsx
import React, { useState } from 'react';
import { Dimensions, StyleSheet, View } from 'react-native';
import Animated, {
  Easing,
  useAnimatedStyle,
  useReducedMotion,
  useSharedValue,
  withTiming,
} from 'react-native-reanimated';

const dimensions = Dimensions.get('window');

function randBetween(min: number, max: number) {
  return min + Math.random() * (max - min);
}

function Circle() {
  const shouldReduceMotion = useReducedMotion();

  const [power] = useState(randBetween(0, 1));
  const [duration] = useState(randBetween(2000, 3000));

  const size = 100 + power * 250;
  const width = size;
  const height = size;
  const hue = randBetween(100, 200);
  const backgroundColor = `hsl(${hue},100%,50%)`;

  const opacity = 0.1 + (1 - power) * 0.1;
  const config = { duration, easing: Easing.linear };

  const left = useSharedValue(randBetween(0, dimensions.width) - size / 2);
  const top = useSharedValue(randBetween(0, dimensions.height) - size / 2);

  const update = () => {
    left.value = withTiming(left.value + randBetween(-100, 100), config);
    top.value = withTiming(top.value + randBetween(-100, 100), config);
  };

  React.useEffect(() => {
    update();
    if (shouldReduceMotion) {
      return;
    }
    const id = setInterval(update, duration);
    return () => clearInterval(id);
  });

  const animatedStyle = useAnimatedStyle(
    () => ({
      transform: [{ translateX: left.value }, { translateY: top.value }],
    }),
    []
  );

  return (
    <Animated.View
      style={[
        styles.circle,
        { width, height, backgroundColor, opacity },
        animatedStyle,
      ]}
    />
  );
}

interface BokehProps {
  count: number;
}

function Bokeh({ count }: BokehProps) {
  return (
    <>
      {[...Array(count)].map((_, i) => (
        <Circle key={i} />
      ))}
    </>
  );
}

export default function App() {
  return (
    <View style={styles.container}>
      <Bokeh count={200} />
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    alignItems: 'center',
    justifyContent: 'center',
    backgroundColor: 'black',
    overflow: 'hidden',
  },
  circle: {
    position: 'absolute',
    borderRadius: 999,
  },
});
```

</details>

## Summary

* Fixes
#6832.
* Fixes
#6676.

## Test plan
@delphinebugner
Copy link
Author

Thanks for the fix @tomekzaw & @j-piasecki 🙏 Do you have an idea on when it will be released?

I have issues with KeyboardStickyView of react-native-keyboard-controller - Text input inside becomes un-clickable on Android (sometimes).

As the KeyboardStickyView is just a Reanimated View interpolating keyboard animation, could it have from the same root cause?

@tomekzaw
Copy link
Member

tomekzaw commented Feb 26, 2025

@delphinebugner We don't have an official timeline for beta.3 yet but you can always try a nightly build, e.g. [email protected].

As for KeyboardStickyView, this depends on the actual implementation (e.g. which props are animated). Telling from "moves the content along with keyboard and not resizing the inner view" I suspect that transform is used which is indeed a non-layout prop so #7014 should fix it. So yeah, this could be the cause, so it's definitely worth trying to update.

tomekzaw added a commit that referenced this issue Mar 27, 2025
…ePropsOnUIThread` (#7014)

Currently, there are two ways to update native view props and styles in
Reanimated. The default path (so-called slow path) is to apply all props
changes to the ShadowTree via C++ API and let React Native mount the
changes. However, if all props updated in given batch are non-layout
props (i.e. those that don't require layout recalculation, like
background color or opacity) we use a fast path that calls
`synchronouslyUpdatePropsOnUIThread` from React Native and applies the
changes directly to platform views, without making changes to ShadowTree
in C++. Turns out, some features like view measurement or touch
detection system use C++ ShadowTree which is not consistent with what's
currently on the screen. Because of that, we're removing the fast path
(turns out it's not that fast, especially on iOS) to restore the
correctness of view measurement and touch detection for animated
components.

* Performance monitor example &rarr; Bokeh Example
* Android emulator / iPhone 14 Pro real device
* Debug mode
* Animating `transform` prop using `useAnimatedStyle`

| Platform | Before (main) | After (this PR) |
|:-:|:-:|:-:|
| Android (count=200) | 20 fps | 15 fps |
| iOS (count=500) | 22 fps | 22 fps |

<details>
<summary>App.tsx</summary>

```tsx
import React, { useState } from 'react';
import { Dimensions, StyleSheet, View } from 'react-native';
import Animated, {
  Easing,
  useAnimatedStyle,
  useReducedMotion,
  useSharedValue,
  withTiming,
} from 'react-native-reanimated';

const dimensions = Dimensions.get('window');

function randBetween(min: number, max: number) {
  return min + Math.random() * (max - min);
}

function Circle() {
  const shouldReduceMotion = useReducedMotion();

  const [power] = useState(randBetween(0, 1));
  const [duration] = useState(randBetween(2000, 3000));

  const size = 100 + power * 250;
  const width = size;
  const height = size;
  const hue = randBetween(100, 200);
  const backgroundColor = `hsl(${hue},100%,50%)`;

  const opacity = 0.1 + (1 - power) * 0.1;
  const config = { duration, easing: Easing.linear };

  const left = useSharedValue(randBetween(0, dimensions.width) - size / 2);
  const top = useSharedValue(randBetween(0, dimensions.height) - size / 2);

  const update = () => {
    left.value = withTiming(left.value + randBetween(-100, 100), config);
    top.value = withTiming(top.value + randBetween(-100, 100), config);
  };

  React.useEffect(() => {
    update();
    if (shouldReduceMotion) {
      return;
    }
    const id = setInterval(update, duration);
    return () => clearInterval(id);
  });

  const animatedStyle = useAnimatedStyle(
    () => ({
      transform: [{ translateX: left.value }, { translateY: top.value }],
    }),
    []
  );

  return (
    <Animated.View
      style={[
        styles.circle,
        { width, height, backgroundColor, opacity },
        animatedStyle,
      ]}
    />
  );
}

interface BokehProps {
  count: number;
}

function Bokeh({ count }: BokehProps) {
  return (
    <>
      {[...Array(count)].map((_, i) => (
        <Circle key={i} />
      ))}
    </>
  );
}

export default function App() {
  return (
    <View style={styles.container}>
      <Bokeh count={200} />
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    alignItems: 'center',
    justifyContent: 'center',
    backgroundColor: 'black',
    overflow: 'hidden',
  },
  circle: {
    position: 'absolute',
    borderRadius: 999,
  },
});
```

</details>

* Fixes
#6832.
* Fixes
#6676.
tomekzaw added a commit that referenced this issue Mar 28, 2025
…ePropsOnUIThread` (#7014)

Currently, there are two ways to update native view props and styles in
Reanimated. The default path (so-called slow path) is to apply all props
changes to the ShadowTree via C++ API and let React Native mount the
changes. However, if all props updated in given batch are non-layout
props (i.e. those that don't require layout recalculation, like
background color or opacity) we use a fast path that calls
`synchronouslyUpdatePropsOnUIThread` from React Native and applies the
changes directly to platform views, without making changes to ShadowTree
in C++. Turns out, some features like view measurement or touch
detection system use C++ ShadowTree which is not consistent with what's
currently on the screen. Because of that, we're removing the fast path
(turns out it's not that fast, especially on iOS) to restore the
correctness of view measurement and touch detection for animated
components.

* Performance monitor example &rarr; Bokeh Example
* Android emulator / iPhone 14 Pro real device
* Debug mode
* Animating `transform` prop using `useAnimatedStyle`

| Platform | Before (main) | After (this PR) |
|:-:|:-:|:-:|
| Android (count=200) | 20 fps | 15 fps |
| iOS (count=500) | 22 fps | 22 fps |

<details>
<summary>App.tsx</summary>

```tsx
import React, { useState } from 'react';
import { Dimensions, StyleSheet, View } from 'react-native';
import Animated, {
  Easing,
  useAnimatedStyle,
  useReducedMotion,
  useSharedValue,
  withTiming,
} from 'react-native-reanimated';

const dimensions = Dimensions.get('window');

function randBetween(min: number, max: number) {
  return min + Math.random() * (max - min);
}

function Circle() {
  const shouldReduceMotion = useReducedMotion();

  const [power] = useState(randBetween(0, 1));
  const [duration] = useState(randBetween(2000, 3000));

  const size = 100 + power * 250;
  const width = size;
  const height = size;
  const hue = randBetween(100, 200);
  const backgroundColor = `hsl(${hue},100%,50%)`;

  const opacity = 0.1 + (1 - power) * 0.1;
  const config = { duration, easing: Easing.linear };

  const left = useSharedValue(randBetween(0, dimensions.width) - size / 2);
  const top = useSharedValue(randBetween(0, dimensions.height) - size / 2);

  const update = () => {
    left.value = withTiming(left.value + randBetween(-100, 100), config);
    top.value = withTiming(top.value + randBetween(-100, 100), config);
  };

  React.useEffect(() => {
    update();
    if (shouldReduceMotion) {
      return;
    }
    const id = setInterval(update, duration);
    return () => clearInterval(id);
  });

  const animatedStyle = useAnimatedStyle(
    () => ({
      transform: [{ translateX: left.value }, { translateY: top.value }],
    }),
    []
  );

  return (
    <Animated.View
      style={[
        styles.circle,
        { width, height, backgroundColor, opacity },
        animatedStyle,
      ]}
    />
  );
}

interface BokehProps {
  count: number;
}

function Bokeh({ count }: BokehProps) {
  return (
    <>
      {[...Array(count)].map((_, i) => (
        <Circle key={i} />
      ))}
    </>
  );
}

export default function App() {
  return (
    <View style={styles.container}>
      <Bokeh count={200} />
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    alignItems: 'center',
    justifyContent: 'center',
    backgroundColor: 'black',
    overflow: 'hidden',
  },
  circle: {
    position: 'absolute',
    borderRadius: 999,
  },
});
```

</details>

* Fixes
#6832.
* Fixes
#6676.
@HugoGresse
Copy link

would love to see this patch applied to v3. Having a beta dependencies in our app freak me out. I'll do a .patch but as it's cpp code I'm unsure it is a good idea.
For now I'll probably remove the animation, as Pressable from react-native-gesture-handler does not look identical from RN.

@tomekzaw
Copy link
Member

tomekzaw commented May 5, 2025

@HugoGresse This patch has been already backported to v3 (in 3.17.2).

Please try upgrading to the latest v3 release which currently is [email protected].

@HugoGresse
Copy link

HugoGresse commented May 5, 2025

Thank you Tomasz for the quick answer!
I was already on 3.17.5 but I still have the issue. Redone a rm -rf node_modules, npm start --reset-cache and install on android on my physical device and the issue persist.
Weirdly enough, the issue happen on a Samsung s23 running android 14, but not on the Pixel 8a emulated on android 16.
Should I do a repro?

@tomekzaw
Copy link
Member

tomekzaw commented May 5, 2025

@HugoGresse Okay, so this sounds like another issue, this time it's an issue in React Native itself. Can you please try the workaround described in this comment and let me know if it helps? #6935 (comment)

@HugoGresse
Copy link

sorry for my last comment, the issue persist. The touch is visually visible (ripple or over effect) but the onPress is never fired, only when the transform translationY is zero

@Sky
Copy link

Sky commented May 9, 2025

Same here, we upgraded to RN 0.79.2, enabled those flags but this does not fix the issue on Android. We use Animated.ScrollView and change translateY and after scrolling neither Pressable or TouchableOpacity from RN will not trigger onPress when tapped but onPressIn and onPressOut is triggered. We also encounter this in other places where we don't change translateY.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Platform: Android This issue is specific to Android Repro provided A reproduction with a snippet of code, snack or repo is provided
Projects
None yet
9 participants