Skip to content

Conversation

@m-bert
Copy link
Contributor

@m-bert m-bert commented Aug 21, 2025

Description

Currently we have 2 ways of handling events - Animated and JS/Reanimated. Handling both JS and Reanimated in the same places results in more complex codebase. It also introduces problems with composing gestures. We decided to split those implementations.

Test plan

Tested on the following code:
import * as React from 'react';
import { Animated, Button, useAnimatedValue } from 'react-native';
import {
  GestureHandlerRootView,
  NativeDetector,
  useGesture,
} from 'react-native-gesture-handler';

export default function App() {
  const [visible, setVisible] = React.useState(true);

  const value = useAnimatedValue(0);

  const event = Animated.event(
    [{ nativeEvent: { handlerData: { translationX: value } } }],
    {
      useNativeDriver: true,
    }
  );

  const gesture = useGesture('PanGestureHandler', {
    onBegin: (e: unknown) => {
      'worklet';
      console.log('onBegin', e);
    },
    onStart: (e: unknown) => {
      'worklet';
      console.log('onStart', e);
    },
    // onUpdate: event,
    onUpdate: (e: unknown) => {
      'worklet';
      console.log('onUpdate', e);
    },
    onEnd: (e: unknown) => {
      'worklet';
      console.log('onEnd', e);
    },
    onFinalize: (e: unknown) => {
      'worklet';
      console.log('onFinalize', e);
    },
    onTouchesDown: (e: unknown) => {
      'worklet';
      console.log('onTouchesDown', e);
    },
    onTouchesMove: (e: unknown) => {
      'worklet';
      console.log('onTouchesMoved', e);
    },
    onTouchesUp: (e: unknown) => {
      'worklet';
      console.log('onTouchesUp', e);
    },
    onTouchesCancelled: (e: unknown) => {
      'worklet';
      console.log('onTouchesCancelled', e);
    },
  });

  return (
    <GestureHandlerRootView
      style={{ flex: 1, backgroundColor: 'white', paddingTop: 8 }}>
      <Button
        title="Toggle visibility"
        onPress={() => {
          setVisible(!visible);
        }}
      />

      {visible && (
        <NativeDetector gesture={gesture}>
          <Animated.View
            style={[
              {
                width: 150,
                height: 150,
                backgroundColor: 'blue',
                opacity: 0.5,
                borderWidth: 10,
                borderColor: 'green',
                marginTop: 20,
                marginLeft: 40,
              },
              { transform: [{ translateX: value }] },
            ]}
          />
        </NativeDetector>
      )}
    </GestureHandlerRootView>
  );
}

@m-bert m-bert changed the title Separate Reanimated from JS Separate Reanimated from JS Aug 21, 2025
@m-bert m-bert marked this pull request as draft August 21, 2025 09:51
@m-bert m-bert marked this pull request as ready for review August 22, 2025 07:53
@m-bert m-bert requested a review from j-piasecki August 26, 2025 08:05
@m-bert m-bert mentioned this pull request Aug 26, 2025
64 tasks
@m-bert m-bert requested a review from j-piasecki September 4, 2025 12:18
@m-bert m-bert merged commit 15478f6 into next Sep 5, 2025
7 checks passed
@m-bert m-bert deleted the @mbert/extract-reanimated-handlers branch September 5, 2025 08:09
@j-piasecki j-piasecki mentioned this pull request Sep 5, 2025
m-bert added a commit that referenced this pull request Sep 15, 2025
> [!IMPORTANT]
> Supersede #3664.
>
> I've decided to create a separate PR since it was easier to start working on it directly than waiting for #3682 to be merged.

## Description

This PR introduces hooks to set relations between handlers. 

## API

New API replaces the old one as follows:

- `Gesture.Race(g1, g2)` $\rightarrow$ `useRace(g1, g2)`
- `Gesture.Exclusive(g1, g2)` $\rightarrow$ `useExclusive(g1, g2)`
- `Gesture.Simultaneous(g1, g2)` $\rightarrow$ `useSimultaneous(g1, g2)`

## Algorithm for populating relations

### Handling external relations
In order to properly handle gesture relations, we need to pass 3 arrays to the native side:

- `waitFor` - responsible for handling `Exclusive` and `requireExternalGestureToFail` relations
- `simultaneousHandlers` - responsible for `Simultaneous` and `simultaneousWithExternalGesture` relations
- `blocksHandlers` - responsible for `blocksExternalGesture` relation

At first, these arrays are filled with external relations in `useGesture` hook. Then we use `DFS` algorithm to add remaining relations, added with relation hooks. Since `Race` doesn't really change anything when it comes to gesture interactions, we can ignore it in our algorithm. 

### DFS overview

We use `DFS` because gesture relations form tree structure. 

<details>
<summary>The algorithm works as follows:</summary>

- Initialize two arrays: `waitFor` and `simultaneousHandlers`. If root node is `SimultaneousGesture`, we also add its handler tags into `simultaneousHandlers` array. This ensures that the algorithm works even if we have only `Simultaneous` as the root node (e.g. `useSimultaneous(g1, g1)`)
- Traverse the gesture tree:
  - If we are not in the `ComposedGesture`, it means that we reached leaf node. In that case we populate `waitFor` and `simultanoursHandlers` arrays into `node` arrays and then update relations on the native side
  - If we are in the `ComposedGesture`, then for each child:
    - If the child is not `ComposedGesture`:
      - We call `traverseGestureRelations` to reach stop condition and configure relations on the native side
      - If current node is `Exclusive`, then we add child `tag` to `waitFor` array
    - If the child is `ComposedGesture`:
      - On the way down:
        - Going from `non-simultaneous` gesture to `simultaneous` gesture we add all **child** tags into global `simulatneousHandlers` array
        - If we go from `simultaneous` to `non-simultaneous` gesture, we remove **child** tags instead of adding. 
      - We store length of `waitFor` to reset it later. 
      - We call `traverseGestureRelations`
      - On the way back:
        - if we go from `simultaneous` (child) to `non-simultaneous` (node) gesture, we remove **node** tags from `simultaneousHandlers`
        - Going from `non-simultaneous` (child) to `simultaneous` (node) gesture we add **node** tags instead of removing
        - Returning to `Exclusive` gesture means that we want to add all children tags into `waitFor`
        - If we return from `Exclusive` child to `non-exclusive` node, we want to reset `waitFor` to previous state, using `length` variable.

</details>

### Example 

Below you can see example of the algorithm. 

<details>
<summary>We use the following notation:</summary>

- Handlers and composition:
  - `E` - `Exclusive`
  - `S` - `Simultaneous`
  - `P` - `Pan`
  - `T` - `Tap`
- Relation arrays:
  - `SH` - `simultaneousHandlers`
  - `WF` - `waitFor`
- Operators:
  - `+=` -  adding tags
  - `-=` -  removing tags

</details>

_**Note:**_ vertex label in relation arrays expands to all tags in the composed gesture.

```mermaid
graph TB
    E1["E₁"] --> |SH += S₁| S1
    E1["E₁"] --> |SH += S₂| S2

    S1["S₁"] --> |SH -= S₁ <br/> WF += S₁| E1
    S1 --> |SH -= E₂| E2
    S1 --> P3
    E2["E₂"] --> |SH += E₂ <br/> WF -= E₂| S1
    P3["P₃ <br/> SH: {T₁, T₂, P₃}<br/>WF: #91;#93;"] --> S1

    E2 --> T1
    E2 --> T2
    T1["T₁ <br/> SH: {P₃}<br/>WF: #91;#93;"] --> |WF += T₁| E2
    T2["T₂ <br/> SH: {P₃}<br/>WF: #91;T₁#93;"] --> |WF += T₂| E2

    S2["S₂"] -->|SH -= S₂| E1
    S2 --> P4
    S2 --> P5
    P4["P₄ <br/> SH: {P₄, P₅}<br/>WF: #91;T₁, T₂, P₃#93;"] --> S2
    P5["P₅ <br/> SH: {P₄, P₅}<br/>WF: #91;T₁, T₂, P₃#93;"] --> S2

    style DFS fill-opacity:0,stroke-opacity:0,stroke-width:0px
```

## Limitations

Currently the following setup doesn't work on `android`:
```js
const composedGesture = useExclusive(tap1, useRace(pan1, pan2));
```

I've managed to find out what is the difference between this and using only `useRace`.

>[!WARNING]
> This problem seems to be present also on `main`, so I think it will be better to solve it in the follow-up PR.

For now, external relation props do not support composed gestures. Let me know if this should be done in this PR, or in a follow-up.

## Test plan

### Same detector interactions

Verified that the following relations work:

<details>
<summary>Android</summary>

- [x] `Simultaneous`
  - [x] Only JS
  - [x] Only Reanimated
  - [x] JS + Reanimated 
  - [x] JS + Animated
- [x] `Exclusive` 
  - [x] Only JS
  - [x] Only Reanimated
  - [x] JS + Reanimated
  - [x] JS + Animated
- [x] `Race` 
  - [x] Only JS
  - [x] Only Reanimated
  - [x] JS + Reanimated
  - [x] JS + Animated
- [x] Simple composition
  - [x] `Exclusive` + `Simultaneous` 

</details>

<details>
<summary>iOS</summary>

- [x] `Simultaneous`
  - [x] Only JS
  - [x] Only Reanimated
  - [x] JS + Reanimated 
  - [x] JS + Animated
- [x] `Exclusive` 
  - [x] Only JS
  - [x] Only Reanimated
  - [x] JS + Reanimated
  - [x] JS + Animated
- [x] `Race` 
  - [x] Only JS
  - [x] Only Reanimated
  - [x] JS + Reanimated
  - [x] JS + Animated
- [x] Simple composition
  - [x] `Exclusive` + `Simultaneous` 

</details>

<details>
<summary>Base code used for testing:</summary>

```tsx
import * as React from 'react';
import { Animated, Button, useAnimatedValue } from 'react-native';
import {
  GestureHandlerRootView,
  NativeDetector,
  useSimultaneous,
  useGesture,
  useExclusive,
  useRace,
} from 'react-native-gesture-handler';

export default function App() {
  const [visible, setVisible] = React.useState(true);

  const value = useAnimatedValue(0);
  const event = Animated.event(
    [{ nativeEvent: { handlerData: { translationX: value } } }],
    {
      useNativeDriver: true,
    }
  );

  const tap1 = useGesture('TapGestureHandler', {
    onEnd: () => {
      // 'worklet';
      console.log('Tap 1');
    },
    numberOfTaps: 1,
    disableReanimated: true,
  });

  const tap2 = useGesture('TapGestureHandler', {
    onEnd: () => {
      // 'worklet';
      console.log('Tap 2');
    },
    numberOfTaps: 2,
    disableReanimated: true,
  });

  const pan1 = useGesture('PanGestureHandler', {
    // onUpdate: event,
    onUpdate: (e) => {
      // 'worklet';
      console.log('Pan 1');
    },
    disableReanimated: true,
  });

  const pan2 = useGesture('PanGestureHandler', {
    onUpdate: (e) => {
      // 'worklet';
      console.log('Pan 2');
    },
    disableReanimated: true,
  });

  const composedGesture = useSimultaneous(pan1, pan2);

  // const composedGesture = useExclusive(tap2, tap1);
  // const composedGesture = useExclusive(pan2, pan1); // For Animtaed.Event
  // const composedGesture = useExclusive(pan1, pan2); // For Animtaed.Event

  // const composedGesture = useRace(pan1, pan2);
  // const composedGesture = useRace(pan2, pan1);

  // const composedGesture = useExclusive(tap1, useSimultaneous(pan1, pan2));

  return (
    <GestureHandlerRootView
      style={{ flex: 1, backgroundColor: 'white', paddingTop: 8 }}>
      <Button
        title="Toggle visibility"
        onPress={() => {
          setVisible(!visible);
        }}
      />

      {visible && (
        <NativeDetector gesture={composedGesture}>
          <Animated.View
            style={[
              {
                width: 150,
                height: 150,
                backgroundColor: 'blue',
                opacity: 0.5,
                borderWidth: 10,
                borderColor: 'green',
                marginTop: 20,
                marginLeft: 40,
              },
              { transform: [{ translateX: value }] },
            ]}
          />
        </NativeDetector>
      )}
    </GestureHandlerRootView>
  );
}
```

</details>

### Cross detector interactions

Verified that the following relations work:

<details>
<summary>Android</summary>

- [x] `simultaneousWithExternalGesture`
  - [x] Only JS
  - [x] Only Reanimated
  - [x] JS + Reanimated 
  - [x] JS + Animated
- [x] `requireExternalGestureToFail` 
  - [x] Only JS
  - [x] Only Reanimated
  - [x] JS + Reanimated
  - [x] JS + Animated
- [x] `blocksExternalGesture` 
  - [x] Only JS
  - [x] Only Reanimated
  - [x] JS + Reanimated
  - [x] JS + Animated

</details>

<details>
<summary>iOS</summary>

- [x] `simultaneousWithExternalGesture`
  - [x] Only JS
  - [x] Only Reanimated
  - [x] JS + Reanimated 
  - [x] JS + Animated
- [x] `requireExternalGestureToFail` 
  - [x] Only JS
  - [x] Only Reanimated
  - [x] JS + Reanimated
  - [x] JS + Animated
- [x] `blocksExternalGesture` 
  - [x] Only JS
  - [x] Only Reanimated
  - [x] JS + Reanimated
  - [x] JS + Animated

</details>

<details>
<summary>Base code used for testing:</summary>

```tsx
import * as React from 'react';
import { Animated, Button, useAnimatedValue } from 'react-native';
import {
  GestureHandlerRootView,
  NativeDetector,
  useSimultaneous,
  useGesture,
  useExclusive,
  useRace,
} from 'react-native-gesture-handler';

export default function App() {
  const [visible, setVisible] = React.useState(true);

  const value = useAnimatedValue(0);
  const event = Animated.event(
    [{ nativeEvent: { handlerData: { translationX: value } } }],
    {
      useNativeDriver: true,
    }
  );

  const tap1 = useGesture('TapGestureHandler', {
    onEnd: () => {
      // 'worklet';
      console.log('Tap 1');
    },
    numberOfTaps: 1,
    disableReanimated: true,
  });

  const tap2 = useGesture('TapGestureHandler', {
    onEnd: () => {
      // 'worklet';
      console.log('Tap 2');
    },
    numberOfTaps: 2,
    disableReanimated: true,
    blocksExternalGesture: tap1,
  });

  // const tap1 = useGesture('TapGestureHandler', {
  //   onEnd: () => {
  //     'worklet';
  //     console.log('Tap 1');
  //   },
  //   numberOfTaps: 1,
  //   // disableReanimated: true,
  //   requireExternalGestureToFail: tap2,
  // });

  const pan1 = useGesture('PanGestureHandler', {
    // onUpdate: event,
    onUpdate: (e) => {
      'worklet';
      console.log('Pan 1');
    },
    // disableReanimated: true,
  });

  const pan2 = useGesture('PanGestureHandler', {
    onUpdate: (e) => {
      'worklet';
      console.log('Pan 2');
    },
    simultaneousWithExternalGesture: pan1,
    // requireExternalGestureToFail: pan1,
    // blocksExternalGesture: pan1,
    // disableReanimated: true,
  });

  // const composedGesture = useSimultaneous(pan1, pan2);

  // const composedGesture = useExclusive(tap2, tap1);
  // const composedGesture = useExclusive(pan2, pan1); // For Animated.Event
  // const composedGesture = useExclusive(pan1, pan2); // For Animated.Event

  // const composedGesture = useRace(pan1, pan2);
  // const composedGesture = useRace(pan2, pan1);

  // const composedGesture = useExclusive(tap1, useSimultaneous(pan1, pan2));

  return (
    <GestureHandlerRootView
      style={{ flex: 1, backgroundColor: 'white', paddingTop: 8 }}>
      <Button
        title="Toggle visibility"
        onPress={() => {
          setVisible(!visible);
        }}
      />

      {visible && (
        <NativeDetector gesture={pan1}>
          <Animated.View
            style={[
              {
                width: 150,
                height: 150,
                backgroundColor: 'blue',
                opacity: 0.5,
                borderWidth: 10,
                borderColor: 'green',
                marginTop: 20,
                marginLeft: 40,
                display: 'flex',
                alignItems: 'center',
                justifyContent: 'space-around',
              },
              { transform: [{ translateX: value }] },
            ]}>
            <NativeDetector gesture={pan2}>
              <Animated.View
                style={{ width: 100, height: 100, backgroundColor: 'green' }}
              />
            </NativeDetector>
          </Animated.View>
        </NativeDetector>
      )}
    </GestureHandlerRootView>
  );
}
```

</details>
akwasniewski added a commit that referenced this pull request Sep 18, 2025
## Description

Web portion of functionality from PR #3682 
> Currently we have 2 ways of handling events - Animated and
JS/Reanimated. Handling both JS and Reanimated in the same places
results in more complex codebase. It also introduces problems with
composing gestures. We decided to spit those implementations.


## Test plan
```tsx
import * as React from 'react';
import { Animated, Button } from 'react-native';
import {
  GestureHandlerRootView,
  NativeDetector,
  useGesture,
} from 'react-native-gesture-handler';

export default function App() {
  const [visible, setVisible] = React.useState(true);

  const av = React.useRef(new Animated.Value(0)).current

  const event = Animated.event(
    [{ handlerData: { translationX: av } }],
    {
      useNativeDriver: false,
    }
  );

  const gesture = useGesture('PanGestureHandler', {
    onBegin: (e: unknown) => {
      'worklet';
      console.log('onBegin', e);
    },
    onStart: (e: unknown) => {
      'worklet';
      console.log('onStart', e);
    },
    // onUpdate: event,
    onUpdate: (e: unknown) => {
      'worklet';
      console.log('onUpdate', e);
    },
    onEnd: (e: unknown) => {
      'worklet';
      console.log('onEnd', e);
    },
    onFinalize: (e: unknown) => {
      'worklet';
      console.log('onFinalize', e);
    },
    onTouchesDown: (e: unknown) => {
      'worklet';
      console.log('onTouchesDown', e);
    },
    onTouchesMove: (e: unknown) => {
      'worklet';
      console.log('onTouchesMoved', e);
    },
    onTouchesUp: (e: unknown) => {
      'worklet';
      console.log('onTouchesUp', e);
    },
    onTouchesCancelled: (e: unknown) => {
      'worklet';
      console.log('onTouchesCancelled', e);
    },
  });

  return (
    <GestureHandlerRootView
      style={{ flex: 1, backgroundColor: 'white', paddingTop: 8 }}>
      <Button
        title="Toggle visibility"
        onPress={() => {
          setVisible(!visible);
        }}
      />

      {visible && (
        <NativeDetector gesture={gesture}>
          <Animated.View
            style={[
              {
                width: 150,
                height: 150,
                backgroundColor: 'blue',
                opacity: 0.5,
                borderWidth: 10,
                borderColor: 'green',
                marginTop: 20,
                marginLeft: 40,
              },
              { transform: [{ translateX: av }] },
            ]}
          />
        </NativeDetector>
      )}
    </GestureHandlerRootView>
  );
}
```
@m-bert m-bert mentioned this pull request Oct 14, 2025
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.

3 participants