Skip to content

Commit 3dca7f3

Browse files
authored
[Native] Fix Touchable behavior when nested within gesture handlers (#4106)
## Description See #4097 Fixes interactions of nested touchables/gestures: 1. Touchable was missing `disallowInterruption: true` prop, which caused it to misbehave when used alongside other gestures 2. Android keeps the list of touch targets internally in [each `ViewGroup`](https://android.googlesource.com/platform/frameworks/base/+/master/core/java/android/view/ViewGroup.java#220). Since RNGH can start intercepting events mid-stream, this list could contain stale values. The OS, trying to clean it up, would dispatch a synthetic `ACTION_CANCEL` event, which resulted in the touchable working every other time it's been pressed. To fix that, we do the cleanup by ourselves, by dispatching a synthetic `ACTION_CANCEL` event before the new event stream starts. ## Test plan Added a new example This doesn't work correctly on web, but it didn't work before either.
1 parent 778d4c2 commit 3dca7f3

4 files changed

Lines changed: 152 additions & 4 deletions

File tree

apps/common-app/src/new_api/index.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import RotationExample from './simple/rotation';
3535
import TapExample from './simple/tap';
3636
import NestedPressablesExample from './tests/nestedPressables';
3737
import NestedRootViewExample from './tests/nestedRootView';
38+
import NestedTouchablesExample from './tests/nestedTouchables';
3839
import PointerTypeExample from './tests/pointerType';
3940
import PressableExample from './tests/pressable';
4041
import ReattachingExample from './tests/reattaching';
@@ -124,6 +125,7 @@ export const NEW_EXAMPLES: ExamplesSection[] = [
124125
{ name: 'Reattaching', component: ReattachingExample },
125126
{ name: 'Modal with Nested Root View', component: NestedRootViewExample },
126127
{ name: 'Nested pressables', component: NestedPressablesExample },
128+
{ name: 'Nested touchables', component: NestedTouchablesExample },
127129
{ name: 'Pressable', component: PressableExample },
128130
],
129131
},
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import React, { useState } from 'react';
2+
import { StyleSheet, Text, View } from 'react-native';
3+
import {
4+
GestureDetector,
5+
Touchable,
6+
useTapGesture,
7+
} from 'react-native-gesture-handler';
8+
9+
import { COLORS } from '../../../common';
10+
11+
export default function NestedTouchablesExample() {
12+
const [log, setLog] = useState<string[]>([]);
13+
14+
const pushLog = (message: string) => {
15+
setLog((prev) =>
16+
[...prev, `[${new Date().toLocaleTimeString()}] ${message}`].slice(-6)
17+
);
18+
};
19+
20+
const outerTap = useTapGesture({
21+
runOnJS: true,
22+
onActivate: () => pushLog('outer tap gesture'),
23+
});
24+
25+
const innerTap = useTapGesture({
26+
runOnJS: true,
27+
onActivate: () => pushLog('inner tap gesture'),
28+
});
29+
30+
return (
31+
<View style={styles.container}>
32+
<Text style={styles.title}>Nested gestures & touchables</Text>
33+
<Text style={styles.hint}>
34+
Tap each colored layer. Every level fires its own handler.
35+
</Text>
36+
37+
<GestureDetector gesture={outerTap}>
38+
<View style={[styles.layer, styles.outerLayer]}>
39+
<Text style={styles.layerLabel}>Outer tap gesture</Text>
40+
41+
<Touchable
42+
style={[styles.layer, styles.outerTouchable]}
43+
onPress={() => pushLog('outer Touchable')}>
44+
<Text style={styles.layerLabel}>Outer Touchable</Text>
45+
46+
<GestureDetector gesture={innerTap}>
47+
<View style={[styles.layer, styles.innerLayer]}>
48+
<Text style={styles.layerLabel}>Inner tap gesture</Text>
49+
50+
<Touchable
51+
style={[styles.layer, styles.innerTouchable]}
52+
onPress={() => pushLog('inner Touchable')}>
53+
<Text style={styles.layerLabel}>Inner Touchable</Text>
54+
</Touchable>
55+
</View>
56+
</GestureDetector>
57+
</Touchable>
58+
</View>
59+
</GestureDetector>
60+
61+
<View style={styles.logBox}>
62+
<Text style={styles.logTitle}>Event log</Text>
63+
{Array.from({ length: 6 }).map((_, index) => (
64+
<Text key={index} style={styles.logEntry}>
65+
{log[index] ?? ' '}
66+
</Text>
67+
))}
68+
</View>
69+
</View>
70+
);
71+
}
72+
73+
const styles = StyleSheet.create({
74+
container: {
75+
flex: 1,
76+
alignItems: 'center',
77+
justifyContent: 'center',
78+
padding: 24,
79+
gap: 16,
80+
},
81+
title: {
82+
fontSize: 20,
83+
fontWeight: '700',
84+
},
85+
hint: {
86+
textAlign: 'center',
87+
opacity: 0.6,
88+
fontSize: 14,
89+
},
90+
layer: {
91+
alignItems: 'center',
92+
justifyContent: 'flex-start',
93+
borderRadius: 12,
94+
padding: 16,
95+
gap: 12,
96+
},
97+
outerLayer: {
98+
width: 300,
99+
backgroundColor: COLORS.KINDA_YELLOW,
100+
},
101+
outerTouchable: {
102+
width: 260,
103+
backgroundColor: COLORS.YELLOW,
104+
},
105+
innerLayer: {
106+
width: 220,
107+
backgroundColor: COLORS.KINDA_GREEN,
108+
},
109+
innerTouchable: {
110+
width: 180,
111+
backgroundColor: COLORS.KINDA_BLUE,
112+
},
113+
layerLabel: {
114+
fontSize: 14,
115+
fontWeight: '600',
116+
color: COLORS.NAVY,
117+
},
118+
logBox: {
119+
width: '100%',
120+
padding: 12,
121+
borderRadius: 8,
122+
backgroundColor: COLORS.offWhite,
123+
gap: 4,
124+
},
125+
logTitle: {
126+
fontSize: 13,
127+
fontWeight: '700',
128+
marginBottom: 4,
129+
},
130+
logEntry: {
131+
fontSize: 12,
132+
fontFamily: 'Menlo',
133+
},
134+
});

packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerRootView.kt

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -45,10 +45,21 @@ class RNGestureHandlerRootView(context: Context?) : ReactViewGroup(context) {
4545
rootHelper?.recordHandlerIfNotPresent(handler)
4646
}
4747

48-
override fun dispatchTouchEvent(event: MotionEvent) = if (rootViewEnabled && rootHelper!!.dispatchTouchEvent(event)) {
49-
true
50-
} else {
51-
super.dispatchTouchEvent(event)
48+
override fun dispatchTouchEvent(event: MotionEvent): Boolean {
49+
// When starting a new event stream, dispatch CANCEL event so the subtree
50+
// can clean up its internal state that may be stale due to Gesture Handler
51+
// starting to intercept events mid-stream.
52+
if (event.actionMasked == MotionEvent.ACTION_DOWN) {
53+
val cancelEvent = MotionEvent.obtain(event).apply { action = MotionEvent.ACTION_CANCEL }
54+
super.dispatchTouchEvent(cancelEvent)
55+
cancelEvent.recycle()
56+
}
57+
58+
return if (rootViewEnabled && rootHelper!!.dispatchTouchEvent(event)) {
59+
true
60+
} else {
61+
super.dispatchTouchEvent(event)
62+
}
5263
}
5364

5465
override fun dispatchGenericMotionEvent(ev: MotionEvent) =

packages/react-native-gesture-handler/src/v3/components/Touchable/Touchable.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ const TouchableButton = createNativeWrapper<
1616
>(GestureHandlerButton, {
1717
shouldCancelWhenOutside: true,
1818
shouldActivateOnStart: false,
19+
disallowInterruption: true,
1920
});
2021

2122
const isAndroid = Platform.OS === 'android';

0 commit comments

Comments
 (0)