Implement gesture relations#3664
Closed
m-bert wants to merge 45 commits into
Closed
Conversation
j-piasecki
reviewed
Aug 20, 2025
Comment on lines
+53
to
+68
| export const SingleGestureType = { | ||
| Tap: 'TapGestureHandler', | ||
| LongPress: 'LongPressGestureHandler', | ||
| Pan: 'PanGestureHandler', | ||
| Pinch: 'PinchGestureHandler', | ||
| Rotation: 'RotationGestureHandler', | ||
| Fling: 'FlingGestureHandler', | ||
| Manual: 'ManualGestureHandler', | ||
| Native: 'NativeGestureHandler', | ||
| } as const; | ||
|
|
||
| export const ComposedGestureType = { | ||
| Simultaneous: 'SimultaneousGesture', | ||
| Exclusive: 'ExclusiveGesture', | ||
| Race: 'RaceGesture', | ||
| } as const; |
Member
There was a problem hiding this comment.
These could also use a different name, Type suffix is a bit misleading.
|
|
||
| export function useGesture( | ||
| type: GestureType, | ||
| type: ValueOf<typeof HandlerType>, |
Member
There was a problem hiding this comment.
Is there a scenario where this is ComposedGestureType?
Collaborator
Author
There was a problem hiding this comment.
I don't think so, good catch.
| import { ComposedGesture, ComposedGestureType, NativeGesture } from '../types'; | ||
|
|
||
| // The tree consists of ComposedGestures and NativeGestures. NativeGestures are always leaf nodes. | ||
| export const dfs = ( |
Member
There was a problem hiding this comment.
This could also use a more descriptive name.
Collaborator
Author
There was a problem hiding this comment.
Yes, I know, I left it for now as there are some other things to do 😅
Collaborator
Author
|
Caution
|
Member
I think the word you're looking for is |
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
On hold
Caution
Warning
ReanimatedfromJS#3682 to be mergedDescription
Test plan
For simultaneous:
For Exclusive:
For Race:
For composed: