Skip to content
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

[iOS] Gestures don't work on child transformed out of the parent bounds when it originally fit inside them #6832

Closed
j-piasecki opened this issue Dec 18, 2024 · 6 comments · Fixed by #7014
Labels
Maintainer issue Issue created by a maintainer

Comments

@j-piasecki
Copy link
Member

j-piasecki commented Dec 18, 2024

Description

Reanimated not committing non-layout changes to the shadow tree may break gestures on components outside parents (among other things). Here: https://github.com/facebook/react-native/blob/b8f3f919cc9ebbd086d9ac79c93fffd532c55b09/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm#L620 the insets wouldn't be updated since layout will be skipped.

Content from the original RNGH issue:

Description

When gesture detector is attached to a view A which has a child B which may be transformed, if the child starts fully inside the bounds of its parent, the gestures will not work on the child when it's moved outside the bounds. When the child doesn't fit inside the parent's bounds, the gesture will work on it when moved further away.

Child fits fully inside parent's bounds:

Screen.Recording.2024-12-18.at.13.37.00.mov

Child extends out of the parent's bounds

Screen.Recording.2024-12-18.at.13.37.18.mov

Steps to reproduce

See the videos, Reanimated used in repro: 3.16.1

Snack or a link to a repository

https://gist.github.com/j-piasecki/102a4d821059ae2bb4313207425d09f1

Gesture Handler version

2.20.2

React Native version

0.76.5

Platforms

iOS

JavaScript runtime

Hermes

Workflow

Expo bare workflow

Architecture

Fabric (New Architecture)

Build type

Debug mode

Device

iOS simulator

Device model

No response

Acknowledgements

Yes

@j-piasecki j-piasecki added the Maintainer issue Issue created by a maintainer label Dec 18, 2024
@j-piasecki
Copy link
Member Author

I always knew Gesture Handler wouldn't do anything like this. Adding margin: isPressed.value ? 0.01 : 0, to the animated styles fixes the problem by forcing Reanimated to commit its changes to the shadow tree.

Case closed.

@j-piasecki
Copy link
Member Author

I've been informed I can transfer issues between repos 🤯

@j-piasecki j-piasecki reopened this Dec 19, 2024
@j-piasecki j-piasecki transferred this issue from software-mansion/react-native-gesture-handler Dec 19, 2024
@jluisrojas
Copy link

Where you able to solve this @j-piasecki ?

@j-piasecki
Copy link
Member Author

Unfortunately, I didn't have time to look into this 😞

@hamdij0maa

This comment has been minimized.

@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 → 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
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Maintainer issue Issue created by a maintainer
Projects
None yet
4 participants