Commit 1a6e70f
authored
Gesture relations (#3693)
> [!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>1 parent 15478f6 commit 1a6e70f
36 files changed
Lines changed: 742 additions & 256 deletions
File tree
- apps/basic-example/src
- packages/react-native-gesture-handler
- android/src/main/java/com/swmansion/gesturehandler/react
- events
- apple
- src
- handlers/gestures
- specs
- v3
- NativeDetector
- hooks
- callbacks
- js
- reanimated
- relations
- utils
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
3 | 3 | | |
4 | 4 | | |
5 | 5 | | |
| 6 | + | |
6 | 7 | | |
7 | 8 | | |
8 | 9 | | |
| |||
17 | 18 | | |
18 | 19 | | |
19 | 20 | | |
20 | | - | |
| 21 | + | |
21 | 22 | | |
22 | 23 | | |
23 | 24 | | |
| |||
Lines changed: 9 additions & 2 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
97 | 97 | | |
98 | 98 | | |
99 | 99 | | |
100 | | - | |
101 | | - | |
102 | 100 | | |
103 | 101 | | |
104 | 102 | | |
| |||
111 | 109 | | |
112 | 110 | | |
113 | 111 | | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
114 | 121 | | |
115 | 122 | | |
116 | 123 | | |
| |||
Lines changed: 2 additions & 1 deletion
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
59 | 59 | | |
60 | 60 | | |
61 | 61 | | |
62 | | - | |
| 62 | + | |
| 63 | + | |
63 | 64 | | |
64 | 65 | | |
65 | 66 | | |
| |||
Lines changed: 1 addition & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
89 | 89 | | |
90 | 90 | | |
91 | 91 | | |
| 92 | + | |
92 | 93 | | |
93 | 94 | | |
94 | 95 | | |
| |||
Lines changed: 7 additions & 7 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
103 | 103 | | |
104 | 104 | | |
105 | 105 | | |
106 | | - | |
107 | | - | |
108 | | - | |
109 | 106 | | |
110 | 107 | | |
111 | 108 | | |
| |||
123 | 120 | | |
124 | 121 | | |
125 | 122 | | |
126 | | - | |
127 | | - | |
128 | | - | |
129 | | - | |
130 | 123 | | |
131 | 124 | | |
132 | 125 | | |
| |||
191 | 184 | | |
192 | 185 | | |
193 | 186 | | |
| 187 | + | |
| 188 | + | |
| 189 | + | |
| 190 | + | |
| 191 | + | |
| 192 | + | |
| 193 | + | |
194 | 194 | | |
195 | 195 | | |
196 | 196 | | |
| |||
Lines changed: 2 additions & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
28 | 28 | | |
29 | 29 | | |
30 | 30 | | |
| 31 | + | |
| 32 | + | |
31 | 33 | | |
32 | 34 | | |
33 | 35 | | |
| |||
Lines changed: 6 additions & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
191 | 191 | | |
192 | 192 | | |
193 | 193 | | |
| 194 | + | |
| 195 | + | |
| 196 | + | |
| 197 | + | |
| 198 | + | |
| 199 | + | |
194 | 200 | | |
195 | 201 | | |
196 | 202 | | |
| |||
Lines changed: 6 additions & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
159 | 159 | | |
160 | 160 | | |
161 | 161 | | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
162 | 168 | | |
163 | 169 | | |
164 | 170 | | |
| |||
Lines changed: 3 additions & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
59 | 59 | | |
60 | 60 | | |
61 | 61 | | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
62 | 65 | | |
63 | 66 | | |
64 | 67 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
162 | 162 | | |
163 | 163 | | |
164 | 164 | | |
165 | | - | |
166 | | - | |
| 165 | + | |
| 166 | + | |
167 | 167 | | |
168 | 168 | | |
| 169 | + | |
| 170 | + | |
| 171 | + | |
169 | 172 | | |
170 | 173 | | |
0 commit comments