Skip to content

Conversation

@akwasniewski
Copy link
Contributor

@akwasniewski akwasniewski commented Aug 25, 2025

Description

This PR implements new component -> LogicDetector. It resolves the issue of attaching gestures to inner SVG components. LogicDetector communicates with a NativeDetector higher in the hierarchy, which will be responsible for attaching gestures.

Note: attaching Native gestures to LogicDetector will be a added in a follow up, as it is a niche feature - thus not a priority - and we don't want to block this PR.

Note 2: Reanimated handlers currently only work on web for reanimated: ^4.1

Test plan

tested on the following code

import React from 'react';
import { Text, View, StyleSheet } from 'react-native';
import { NativeDetector, LogicDetector, useGesture, SingleGestureName } from 'react-native-gesture-handler';

import Svg, { Circle, Rect } from 'react-native-svg';

export default function SvgExample() {
  const circleElementTap = useGesture(SingleGestureName.Tap, {
    onStart: () => {
      'worklet';
      console.log('RNGH: clicked circle')
    }
  });
  const rectElementTap = useGesture(SingleGestureName.Tap, {
    onStart: () => {
      'worklet';
      console.log('RNGH: clicked parallelogram')
    }
  });
  const containerTap = useGesture(SingleGestureName.Tap, {
    onStart: () => {
      'worklet';
      console.log('RNGH: clicked container')
    }
  });
  const vbContainerTap = useGesture(SingleGestureName.Tap, {
    onStart: () => {
      'worklet';
      console.log('RNGH: clicked viewbox container')
    }
  });
  const vbInnerContainerTap = useGesture(SingleGestureName.Tap, {
    onStart: () => {
      'worklet';
      console.log('RNGH: clicked inner viewbox container')
    }
  });
  const vbCircleTap = useGesture(SingleGestureName.Tap, {
    onStart: () => {
      'worklet';
      console.log('RNGH: clicked viewbox circle')
    }
  });

  return (
    <View>
      <View style={styles.container}>
        <Text style={styles.header}>
          Overlapping SVGs with gesture detectors
        </Text>
        <View style={{ backgroundColor: 'tomato' }}>
          <NativeDetector gesture={containerTap}>
            <Svg
              height="250"
              width="250"
              onPress={() => console.log('SVG: clicked container')}>
              <LogicDetector gesture={circleElementTap}>
                <Circle
                  cx="125"
                  cy="125"
                  r="125"
                  fill="green"
                  onPress={() => console.log('SVG: clicked circle')}
                />
              </LogicDetector>
              <LogicDetector gesture={rectElementTap}>
                <Rect
                  skewX="45"
                  width="125"
                  height="250"
                  fill="yellow"
                  onPress={() => console.log('SVG: clicked parallelogram')}
                />
              </LogicDetector>
            </Svg>
          </NativeDetector>
        </View>
        <Text>
          Tapping each color should read to a different console.log output
        </Text>
      </View>
      <View style={styles.container}>
        <Text style={styles.header}>SvgView with SvgView with ViewBox</Text>
        <View style={{ backgroundColor: 'tomato' }}>
          <NativeDetector gesture={vbContainerTap}>
            <Svg
              height="250"
              width="250"
              viewBox="-50 -50 150 150"
              onPress={() => console.log('SVG: clicked viewbox container')}>
              <LogicDetector gesture={vbInnerContainerTap}>
                <Svg
                  height="250"
                  width="250"
                  viewBox="-300 -300 600 600"
                  onPress={() =>
                    console.log('SVG: clicked inner viewbox container')
                  }>
                  <Rect
                    x="-300"
                    y="-300"
                    width="600"
                    height="600"
                    fill="yellow"
                  />
                  <LogicDetector gesture={vbCircleTap}>
                    <Circle
                      r="300"
                      fill="green"
                      onPress={() => console.log('SVG: clicked viewbox circle')}
                    />
                  </LogicDetector>
                </Svg>
              </LogicDetector>
            </Svg>
          </NativeDetector>
        </View>
        <Text>The viewBox property remaps SVG's coordinate space</Text>
      </View>
    </View >
  );
}

const styles = StyleSheet.create({
  container: {
    alignItems: 'center',
    justifyContent: 'center',
    marginBottom: 48,
  },
  header: {
    fontSize: 18,
    fontWeight: 'bold',
    margin: 10,
  },
});

@akwasniewski akwasniewski force-pushed the @akwasniewski/logic-detector branch from 5b65a19 to 81bf405 Compare September 1, 2025 13:47
Copy link
Member

@j-piasecki j-piasecki left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did a fist pass, but some stuff will likely change due to #3682 :/

Anyway, you have the hardest part figured out, now it's just a matter of polishing it up.

@akwasniewski akwasniewski force-pushed the @akwasniewski/logic-detector branch from 94984b2 to 4e718b3 Compare September 8, 2025 12:18
@akwasniewski akwasniewski requested a review from m-bert September 9, 2025 10:24
@m-bert m-bert dismissed their stale review September 19, 2025 11:30

I'll be on vacation and I don't want to block this PR - I believe it will be good to go when @j-piasecki approves it :D

Comment on lines 77 to 80
attachedHandlers.current = attachedHandlers.current.difference(
attachedNativeHandlers.current
);
detachHandlers(attachedHandlers.current, attachedNativeHandlers.current);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why are we doing that?

Copy link
Contributor Author

@akwasniewski akwasniewski Sep 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We have to reattach all native handlers when child changes, it might be obsolete after #3714 is merged

@j-piasecki
Copy link
Member

Could you also check if these changes have an impact on render time compared to the next branch?

);
const reanimatedTouchEventHandler = Reanimated?.useComposedEventHandler(
getHandlers('onReanimatedTouchEvent')
);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For some reason, the reanimated handlers don't work on web. I spend too much time trying to fix it, I'd appreciate any ideas why is that the case.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it possible that invokeNullableMethod isn't able to trigger whatever is returned by useComposedEventHandler?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not really, as the gesture asigned to NativeDetector seems to work, which also goes through this path. Only the gestures from LogicDetector don't work. I think there is an issue with updating the handlers, which I struggle to solve as useComposedEventHandler is a hook thus I can't simply rerun it in register. Also as @m-bert noticed, similar issue occurs on android in some cases.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For some reason it works on web, once reanimated and worklets are bumped to 4.1. The issue persists for some cases in android

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, I checked and rechecked, everything seems to work on all platforms after bumping reanimated to 4.1

@akwasniewski
Copy link
Contributor Author

akwasniewski commented Sep 30, 2025

Could you also check if these changes have an impact on render time compared to the next branch?

I tested, compared this branch with the next branch using DevTools profiler and this base example. Small examples were within margin of error. Difference emerges only for a lot of detectors, here are the result for 3000 detectors on iOS 16 Plus Simulator. I'm comparing load times for GestureLoadTest after enabled button is clicked. The control test series are load times for rectangles with no detector attached to them. I also compared with v2 architecture.

EDIT: added a test series with this base example, where each detector has its own gesture

Comment on lines 52 to 66
const logicProps =
Platform.OS === 'web'
? {
viewRef,
viewTag,
handlerTags: isComposedGesture(props.gesture)
? props.gesture.tags
: [props.gesture.tag],
}
: {
viewTag,
handlerTags: isComposedGesture(props.gesture)
? props.gesture.tags
: [props.gesture.tag],
};
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about

const logicProps = {
  viewTag,
  handlerTags: isComposedGesture(props.gesture)
    ? props.gesture.tags
    : [props.gesture.tag],
};

if (Platform.OS === 'web') {
  Object.assign(logicProps, { viewRef });
}

?

Copy link
Contributor Author

@akwasniewski akwasniewski Oct 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, I like it. Thanks 153385c

Copy link
Member

@j-piasecki j-piasecki left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's good to go 🚢🇮🇹.

Please also open a PR that will separate the logic-related logic to a separate detector component.

We also may want to rethink the names at this point.

@m-bert m-bert mentioned this pull request Oct 3, 2025
@akwasniewski akwasniewski merged commit 215968f into next Oct 6, 2025
8 checks passed
@akwasniewski akwasniewski deleted the @akwasniewski/logic-detector branch October 6, 2025 08:35
akwasniewski added a commit that referenced this pull request Oct 31, 2025
## Description

To simplify migration the `GestureDetector` will check the type of the
gesture it receives and render the detector accordingly it:
* Checks if the gesture it received is V2 or V2:
* If it's V2, it renders the old `GestureDetector` component
* If it's V3, it checks for the `InterceptingGestureDetector` context:
* If context is there, it's inside boundary and renders `LogicDetector`
* If the context is not there, it renders `NativeDetector`

### Why separate `NativeDetector` and `InterceptingGestureDetector`?

We did some [performance
tests](#3689 (comment))
on the `NativeDetector` after adding changes that handle attaching
`LogicDetector`, which revealed that the new logic adds quite a bit of
overhead, but only on the JS side. New logic on the native side does not
seem to have a significant effect.
We concluded that the best solution is to create a separate component
that will have all functionalities of `NativeDetector` and also allow
`LogicDetector` attachment, while NativeDetector will be reverted to how
it had been before implementing `LogicDetector`.

## Test plan

Rendering `LogicDetector`:
<details>

```tsx
import React from 'react';
import { Text, View, StyleSheet } from 'react-native';
import { GestureDetectorBoundary, GestureDetector, useTap, GestureHandlerRootView } from 'react-native-gesture-handler';

import Svg, { Circle, Rect } from 'react-native-svg';

export default function SvgExample() {
  const circleElementTap = useTap({
    onStart: () => {
      'worklet';
      console.log('RNGH: clicked circle')
    },
  });
  const rectElementTap = useTap({
    onStart: () => {
      'worklet';
      console.log('RNGH: clicked parallelogram')
    },
  });

  return (
    <GestureHandlerRootView>
      <View style={styles.container}>
        <Text style={styles.header}>
          Overlapping SVGs with gesture detectors
        </Text>
        <View style={{ backgroundColor: 'tomato' }}>
          <GestureDetectorBoundary>
            <Svg
              height="250"
              width="250"
              onPress={() => console.log('SVG: clicked container')}>
              <GestureDetector gesture={circleElementTap}>
                <Circle
                  cx="125"
                  cy="125"
                  r="125"
                  fill="green"
                  onPress={() => console.log('SVG: clicked circle')}
                />
              </GestureDetector>
              <GestureDetector gesture={rectElementTap}>
                <Rect
                  skewX="45"
                  width="125"
                  height="250"
                  fill="yellow"
                  onPress={() => console.log('SVG: clicked parallelogram')}
                />
              </GestureDetector>
            </Svg>
          </GestureDetectorBoundary>
        </View>
        <Text>
          Tapping each color should read to a different console.log output
        </Text>
      </View>
    </GestureHandlerRootView>
  );
}

const styles = StyleSheet.create({
  container: {
    alignItems: 'center',
    justifyContent: 'center',
    marginBottom: 48,
  },
  header: {
    fontSize: 18,
    fontWeight: 'bold',
    margin: 10,
  },
});
```

</details

Rendering `NativeDetector`:
<details>

```tsx
import React from 'react';
import { StyleSheet, View } from 'react-native';
import {
  GestureDetector,
  useTap,
} from 'react-native-gesture-handler';
import Animated, {
  useSharedValue,
  useAnimatedStyle,
  withTiming,
  Easing,
  interpolateColor,
} from 'react-native-reanimated';

export default function SimpleTap() {
  const colorValue = useSharedValue(0);

  const tapGesture = useTap({
    onStart: () => {
      'worklet';
      colorValue.value = withTiming(colorValue.value === 0 ? 1 : 0, {
        duration: 400,
        easing: Easing.inOut(Easing.ease),
      });
    },
  });

  const animatedStyle = useAnimatedStyle(() => {
    const backgroundColor = interpolateColor(
      colorValue.value,
      [0, 1],
      ['#b58df1', '#ff7f50'] // purple → coral
    );

    return {
      backgroundColor,
    };
  });

  return (
    <View style={styles.centerView}>
      <GestureDetector gesture={tapGesture}>
        <Animated.View style={[styles.box, animatedStyle]} />
      </GestureDetector>
    </View>
  );
}

const styles = StyleSheet.create({
  centerView: {
    flex: 1,
    justifyContent: 'center',
    alignItems: 'center',
  },
  box: {
    height: 120,
    width: 120,
    backgroundColor: '#b58df1',
    marginBottom: 30,
    borderRadius: 12,
  },
});
```

</details

Rendering old v2 `GestureDetector`:
<details>

```tsx
import React from 'react';
import { StyleSheet, View } from 'react-native';
import {
  Gesture,
  GestureDetector,
} from 'react-native-gesture-handler';
import Animated, {
  useSharedValue,
  useAnimatedStyle,
  withTiming,
  Easing,
  interpolateColor,
} from 'react-native-reanimated';

export default function SimpleTap() {
  const colorValue = useSharedValue(0);

  const tapGesture = Gesture.Tap()
    .onStart(() => {
      'worklet';
      colorValue.value = withTiming(colorValue.value === 0 ? 1 : 0, {
        duration: 400,
        easing: Easing.inOut(Easing.ease),
      });
    });

  const animatedStyle = useAnimatedStyle(() => {
    const backgroundColor = interpolateColor(
      colorValue.value,
      [0, 1],
      ['#b58df1', '#ff7f50'] // purple → coral
    );

    return {
      backgroundColor,
    };
  });

  return (
    <View style={styles.centerView}>
      <GestureDetector gesture={tapGesture}>
        <Animated.View style={[styles.box, animatedStyle]} />
      </GestureDetector>
    </View>
  );
}

const styles = StyleSheet.create({
  centerView: {
    flex: 1,
    justifyContent: 'center',
    alignItems: 'center',
  },
  box: {
    height: 120,
    width: 120,
    backgroundColor: '#b58df1',
    marginBottom: 30,
    borderRadius: 12,
  },
});

```

</details
akwasniewski added a commit that referenced this pull request Nov 4, 2025
## Description

This PR makes `VirtualDetector` compatible with native gestures. Back in
#3689 I decided to handle `VirtualDetector` compatibility with native
gestures for later, as it is a extremely niche feature and it seemed to
require a lot of work. Turns out it doesn't, beacause we use wrap in
VirtualDetector we can handle them as normal gestures.

## Test plan

Tested on:
<details>

```tsx
import * as React from 'react';
import { StyleSheet, Text, View, ScrollView, Button } from 'react-native';
import {
  GestureHandlerRootView,
  GestureDetector,
  useNative,
  useTap,
  InterceptingGestureDetector
} from 'react-native-gesture-handler';

export default function App() {
  const items = Array.from({ length: 100 }, (_, index) => `Item ${index + 1}`);
  const [enabled, setEnabled] = React.useState(true);
  const gesture = useNative({
    onStart: (e) => {
      'worklet';
      console.log('onStart');
    }
  });
  const outerGesture = useTap({
    onStart: (e) => {
      'worklet'
      console.log('onOuterStart');
    }
  });

  const SV1 = () => (
    <ScrollView style={styles.scrollView1}>
      {items.map((item, index) => (
        <View key={index} style={styles.item}>
          <Text style={styles.text}>{item}</Text>
        </View>
      ))}
    </ScrollView>
  );

  const SV2 = () => (
    <ScrollView style={styles.scrollView2}>
      {items.map((item, index) => (
        <View key={index} style={styles.item}>
          <Text style={styles.text}>{item}</Text>
        </View>
      ))}
    </ScrollView>
  );

  return (
    <GestureHandlerRootView style={styles.root}>
      <View style={styles.buttonContainer}>
        <Button
          title="Swap the child"
          onPress={() => setEnabled(!enabled)}
          color="#4a90e2"
        />
      </View>

      <InterceptingGestureDetector gesture={outerGesture}>
        <View style={styles.outerContainer}>
          <View style={styles.frame}>
            <GestureDetector gesture={gesture}>
              {enabled ? <SV1 /> : <SV2 />}
            </GestureDetector>
          </View>
        </View>
      </InterceptingGestureDetector>
    </GestureHandlerRootView>
  );
}

const styles = StyleSheet.create({
  scrollView1: {
    backgroundColor: 'pink',
    marginHorizontal: 20,
  },
  scrollView2: {
    backgroundColor: 'lightblue',
    marginHorizontal: 20,
  },

  item: {
    flexDirection: 'row',
    justifyContent: 'center',
    alignItems: 'center',
    padding: 20,
    margin: 2,
    backgroundColor: 'white',
    borderRadius: 10,
  },
  text: {
    fontSize: 20,
    color: 'black',
  },
  root: {
    flex: 1,
    backgroundColor: '#fafafa',
    paddingTop: 60,
    alignItems: 'center',
  },
  buttonContainer: {
    marginBottom: 20,
    width: '80%',
  },
  outerContainer: {
    padding: 14,
    backgroundColor: '#fff',
    borderRadius: 18,
    borderWidth: 1,
    borderColor: '#e0e0e0',
    shadowColor: '#000',
    shadowOffset: { width: 0, height: 4 },
    shadowOpacity: 0.12,
    shadowRadius: 8,
    elevation: 5,
  },
  frame: {
    borderRadius: 14,
    borderWidth: 1,
    borderColor: '#d6d6d6',
    backgroundColor: '#fdfdfd',
    overflow: 'hidden',
  },
  innerContainer: {
    paddingVertical: 24,
    paddingHorizontal: 20,
    borderRadius: 12,
    alignItems: 'center',
    justifyContent: 'center',
  },
  active: {
    backgroundColor: '#e9f7ef',
    borderColor: '#4caf50',
    borderWidth: 1.5,
  },
  inactive: {
    backgroundColor: '#fff8e1',
    borderColor: '#ffb300',
    borderWidth: 1,
  },
});
```

</details>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants