Skip to content

Commit cefb702

Browse files
j-piaseckim-bert
andauthored
[General] Allow gesture detector to have multiple children (#3981)
## Description Updates `GestureDetector` to accept more than a single child when using gestures other than native. On native platforms, this is done by changing the layout of the detector node from matching its only child to calculating the bounding box of all children. The logic on Android needed to be updated so it tries to hit test every detector child instead of just the first one. On web, this is done similarly, but since the detector node doesn't produce a box, the bounding box is calculated dynamically. This needed some updates in the custom `findNodeHandle` implementation to stop at `display: contents` nodes which have more than one child. ## Test plan ```jsx import React from 'react'; import { StyleSheet, View } from 'react-native'; import { GestureDetector, useTapGesture } from 'react-native-gesture-handler'; export default function EmptyExample() { const tapGesture = useTapGesture({ onActivate: () => { console.log('Tapped'); }, }); return ( <View style={styles.container}> <GestureDetector gesture={tapGesture} enableContextMenu={true}> <View style={styles.box} /> <View style={styles.box} /> </GestureDetector> </View> ); } const styles = StyleSheet.create({ box: { width: 100, height: 100, backgroundColor: 'red', }, container: { gap: 32, flex: 1, justifyContent: 'center', alignItems: 'center', }, }); ``` And example apps --------- Co-authored-by: Michał Bert <63123542+m-bert@users.noreply.github.com>
1 parent 75a6102 commit cefb702

9 files changed

Lines changed: 205 additions & 76 deletions

File tree

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

Lines changed: 27 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -390,19 +390,34 @@ open class GestureHandler {
390390
// TODO: this is likely wrong, and the transformed event itself should be
391391
// in the coordinate system of the child view, but I'm not sure of the
392392
// consequences
393-
if (view is RNGestureHandlerDetectorView && (view as RNGestureHandlerDetectorView).isNotEmpty()) {
394-
val detector = view as RNGestureHandlerDetectorView
393+
val detectorView = hostDetectorView
394+
if (detectorView != null && detectorView.isNotEmpty()) {
395395
val outPoint = PointF()
396-
GestureHandlerOrchestrator.transformPointToChildViewCoords(
397-
adaptedTransformedEvent.x,
398-
adaptedTransformedEvent.y,
399-
detector,
400-
detector.getChildAt(0),
401-
outPoint,
402-
)
403-
x = outPoint.x
404-
y = outPoint.y
405-
isWithinBounds = isWithinBounds(detector.getChildAt(0), x, y)
396+
var foundChild = false
397+
398+
for (i in 0 until detectorView.childCount) {
399+
val child = detectorView.getChildAt(i)
400+
GestureHandlerOrchestrator.transformPointToChildViewCoords(
401+
adaptedTransformedEvent.x,
402+
adaptedTransformedEvent.y,
403+
detectorView,
404+
child,
405+
outPoint,
406+
)
407+
if (isWithinBounds(child, outPoint.x, outPoint.y)) {
408+
x = outPoint.x
409+
y = outPoint.y
410+
isWithinBounds = true
411+
foundChild = true
412+
break
413+
}
414+
}
415+
416+
if (!foundChild) {
417+
x = adaptedTransformedEvent.x
418+
y = adaptedTransformedEvent.y
419+
isWithinBounds = false
420+
}
406421
} else {
407422
x = adaptedTransformedEvent.x
408423
y = adaptedTransformedEvent.y

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

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,12 @@ class RNGestureHandlerDetectorView(context: Context) : ReactViewGroup(context) {
108108
override fun addView(child: View, index: Int, params: LayoutParams?) {
109109
super.addView(child, index, params)
110110

111+
if (nativeHandlers.isNotEmpty()) {
112+
assert(childCount == 1) {
113+
"Cannot have more than one child view when native gesture handlers are attached to the detector"
114+
}
115+
}
116+
111117
tryAttachNativeHandlersToChildView(child)
112118
}
113119

@@ -134,6 +140,9 @@ class RNGestureHandlerDetectorView(context: Context) : ReactViewGroup(context) {
134140
if (shouldAttachGestureToChildView(tag) && actionType == GestureHandler.ACTION_TYPE_NATIVE_DETECTOR) {
135141
// It might happen that `attachHandlers` will be called before children are added into view hierarchy. In that case we cannot
136142
// attach `NativeViewGestureHandlers` here and we have to do it in `addView` method.
143+
assert(childCount <= 1) {
144+
"Cannot attach native gesture handlers when the detector has multiple children"
145+
}
137146
nativeHandlers.add(tag)
138147
} else {
139148
registry.attachHandlerToView(tag, viewTag, actionType, this)
@@ -191,6 +200,14 @@ class RNGestureHandlerDetectorView(context: Context) : ReactViewGroup(context) {
191200
}
192201

193202
private fun tryAttachNativeHandlersToChildView(child: View) {
203+
if (nativeHandlers.isEmpty()) {
204+
return
205+
}
206+
207+
assert(childCount == 1) {
208+
"Cannot have more than one child view when native gesture handlers are attached to the detector"
209+
}
210+
194211
val registry = RNGestureHandlerModule.registries[moduleId]
195212
?: throw Exception("Tried to access a non-existent registry")
196213

packages/react-native-gesture-handler/apple/RNGestureHandlerDetector.mm

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,12 @@ - (void)didAddSubview:(RNGHUIView *)view
158158
{
159159
[super didAddSubview:view];
160160

161+
if (_nativeHandlers.count > 0) {
162+
react_native_assert(
163+
self.subviews.count == 1 &&
164+
"Cannot have more than one child view when native gesture handlers are attached to the detector");
165+
}
166+
161167
[self tryAttachNativeHandlersToChildView];
162168
}
163169

@@ -201,6 +207,9 @@ - (void)attachHandlers:(const std::vector<int> &)handlerTags
201207
if ([self shouldAttachGestureToSubview:@(tag)] && actionType == RNGestureHandlerActionTypeNativeDetector) {
202208
// It might happen that `attachHandlers` will be called before children are added into view hierarchy. In that
203209
// case we cannot attach `NativeViewGestureHandlers` here and we have to do it in `didAddSubview` method.
210+
react_native_assert(
211+
self.subviews.count <= 1 &&
212+
"Cannot attach native gesture handlers when the detector has multiple children");
204213
[_nativeHandlers addObject:@(tag)];
205214
} else {
206215
if (actionType == RNGestureHandlerActionTypeVirtualDetector) {
@@ -294,6 +303,14 @@ - (void)updateVirtualChildren:(const std::vector<RNGestureHandlerDetectorVirtual
294303

295304
- (void)tryAttachNativeHandlersToChildView
296305
{
306+
if (_nativeHandlers.count == 0) {
307+
return;
308+
}
309+
310+
react_native_assert(
311+
self.subviews.count == 1 &&
312+
"Cannot have more than one child view when native gesture handlers are attached to the detector");
313+
297314
RNGestureHandlerManager *handlerManager = [RNGestureHandlerModule handlerManagerForModuleId:_moduleId];
298315

299316
RNGHUIView *view = self.subviews[0];

packages/react-native-gesture-handler/shared/shadowNodes/react/renderer/components/rngesturehandler_codegen/RNGestureHandlerDetectorShadowNode.cpp

Lines changed: 61 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111

1212
#include "RNGestureHandlerDetectorShadowNode.h"
1313

14+
#include <limits>
15+
1416
namespace facebook::react {
1517

1618
extern const char RNGestureHandlerDetectorComponentName[] =
@@ -20,16 +22,12 @@ void RNGestureHandlerDetectorShadowNode::initialize() {
2022
// Disable forcing view flattening
2123
ShadowNode::traits_.unset(ShadowNodeTraits::ForceFlattenView);
2224

23-
// When the detector is cloned and has a child node, the child node should be
24-
// cloned as well to ensure it is mutable.
25+
// When the detector is cloned and has child nodes, the children should be
26+
// cloned as well to ensure they are mutable.
2527
const auto &children = getChildren();
26-
if (!children.empty()) {
27-
react_native_assert(
28-
children.size() == 1 &&
29-
"RNGestureHandlerDetector received more than one child");
30-
28+
for (size_t i = 0; i < children.size(); i++) {
3129
// Will clone the child and ensure it's not flattened
32-
replaceChild(*children[0], children[0], 0);
30+
replaceChild(*children[i], children[i], i);
3331
}
3432
}
3533

@@ -47,39 +45,72 @@ void RNGestureHandlerDetectorShadowNode::replaceChild(
4745
}
4846

4947
void RNGestureHandlerDetectorShadowNode::layout(LayoutContext layoutContext) {
50-
// TODO: consider allowing more than one child and doing bounding box
51-
react_native_assert(getChildren().size() == 1);
52-
53-
auto child = std::static_pointer_cast<const YogaLayoutableShadowNode>(
54-
getChildren()[0]);
55-
auto childWithProtectedAccess =
56-
std::static_pointer_cast<const RNGestureHandlerDetectorShadowNode>(child);
57-
58-
auto shouldSkipCustomLayout =
59-
!childWithProtectedAccess->yogaNode_.getHasNewLayout();
48+
const auto &children = getChildren();
49+
react_native_assert(
50+
!children.empty() &&
51+
"GestureDetector must have at least one child node.");
52+
53+
// Check if any child has a new layout
54+
bool anyChildHasNewLayout = false;
55+
for (const auto &child : children) {
56+
auto yogaChild =
57+
std::static_pointer_cast<const YogaLayoutableShadowNode>(child);
58+
auto childWithProtectedAccess =
59+
std::static_pointer_cast<const RNGestureHandlerDetectorShadowNode>(
60+
yogaChild);
61+
if (childWithProtectedAccess->yogaNode_.getHasNewLayout()) {
62+
anyChildHasNewLayout = true;
63+
break;
64+
}
65+
}
6066

61-
// Do default layout after reading the new layout flag from the child.
62-
// Default layout will reset the flag on the child nodes.
67+
// Do default layout after reading the new layout flags from children.
68+
// Default layout will reset the flag on child nodes.
6369
YogaLayoutableShadowNode::layout(layoutContext);
6470

65-
// The child node did not have its layout changed, we can reuse previous
66-
// values
67-
if (shouldSkipCustomLayout) {
71+
// No child had its layout changed, we can reuse previous values
72+
if (!anyChildHasNewLayout) {
6873
react_native_assert(previousLayoutMetrics_.has_value());
6974
setLayoutMetrics(previousLayoutMetrics_.value());
7075
return;
7176
}
7277

73-
child->ensureUnsealed();
74-
auto mutableChild = std::const_pointer_cast<YogaLayoutableShadowNode>(child);
78+
// Calculate bounding box of all children
79+
Float minX = std::numeric_limits<Float>::infinity();
80+
Float minY = std::numeric_limits<Float>::infinity();
81+
Float maxX = -std::numeric_limits<Float>::infinity();
82+
Float maxY = -std::numeric_limits<Float>::infinity();
83+
84+
for (const auto &child : children) {
85+
auto yogaChild =
86+
std::static_pointer_cast<const YogaLayoutableShadowNode>(child);
87+
const auto &frame = yogaChild->getLayoutMetrics().frame;
88+
89+
minX = std::min(minX, frame.origin.x);
90+
minY = std::min(minY, frame.origin.y);
91+
maxX = std::max(maxX, frame.origin.x + frame.size.width);
92+
maxY = std::max(maxY, frame.origin.y + frame.size.height);
93+
}
7594

76-
auto metrics = child->getLayoutMetrics();
77-
metrics.frame = child->getLayoutMetrics().frame;
95+
// Set detector's metrics to the bounding box of all children
96+
auto metrics = getLayoutMetrics();
97+
metrics.frame.origin = Point{minX, minY};
98+
metrics.frame.size = Size{maxX - minX, maxY - minY};
7899
setLayoutMetrics(metrics);
79100

80-
auto childmetrics = child->getLayoutMetrics();
81-
childmetrics.frame.origin = Point{};
82-
mutableChild->setLayoutMetrics(childmetrics);
101+
// Shift all children so their positions are relative to the detector's origin
102+
for (const auto &child : children) {
103+
auto yogaChild =
104+
std::static_pointer_cast<const YogaLayoutableShadowNode>(child);
105+
yogaChild->ensureUnsealed();
106+
auto mutableChild =
107+
std::const_pointer_cast<YogaLayoutableShadowNode>(yogaChild);
108+
109+
auto childMetrics = yogaChild->getLayoutMetrics();
110+
childMetrics.frame.origin.x -= minX;
111+
childMetrics.frame.origin.y -= minY;
112+
mutableChild->setLayoutMetrics(childMetrics);
113+
}
83114
}
84115

85116
std::shared_ptr<const ShadowNode>

packages/react-native-gesture-handler/src/findNodeHandle.web.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,10 @@ export default function findNodeHandle(
1818
}
1919

2020
if (viewRef instanceof Element) {
21-
if (viewRef.style.display === 'contents') {
21+
if (
22+
viewRef.style.display === 'contents' &&
23+
viewRef.childElementCount === 1
24+
) {
2225
return findNodeHandle(viewRef.firstChild as HTMLElement);
2326
}
2427

@@ -31,9 +34,15 @@ export default function findNodeHandle(
3134

3235
// In new API, we receive ref object which `current` field points to wrapper `div` with `display: contents;`.
3336
// We want to return the first descendant (in DFS order) that doesn't have this property.
37+
// When a `display: contents` wrapper has multiple children (e.g. multi-child detector),
38+
// we stop traversal and return the wrapper itself since there is no single child to resolve to.
3439
let element = (viewRef as GestureHandlerRef)?.current;
3540

36-
while (element && element.style.display === 'contents') {
41+
while (
42+
element &&
43+
element.style.display === 'contents' &&
44+
element.childElementCount === 1
45+
) {
3746
element = element.firstChild as HTMLElement;
3847
}
3948

packages/react-native-gesture-handler/src/v3/detectors/HostGestureDetector.web.tsx

Lines changed: 8 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,14 @@ const HostGestureDetector = (props: GestureHandlerDetectorProps) => {
6565
).shouldAttachGestureToChildView() &&
6666
actionType === ActionType.NATIVE_DETECTOR
6767
) {
68+
if (viewRef.current!.childElementCount > 1) {
69+
throw new Error(
70+
tagMessage(
71+
'Cannot have more than one child view when native gesture handlers are attached to the detector'
72+
)
73+
);
74+
}
75+
6876
RNGestureHandlerModule.attachGestureHandler(
6977
tag,
7078
viewRef.current!.firstChild,
@@ -110,20 +118,6 @@ const HostGestureDetector = (props: GestureHandlerDetectorProps) => {
110118
}, [props]);
111119

112120
useEffect(() => {
113-
if (React.Children.count(children) !== 1) {
114-
throw new Error(
115-
tagMessage('Detector expected to have exactly one child element')
116-
);
117-
}
118-
}, [children]);
119-
120-
useEffect(() => {
121-
if (React.Children.count(children) !== 1) {
122-
throw new Error(
123-
tagMessage('Detector expected to have exactly one child element')
124-
);
125-
}
126-
127121
detachHandlers(handlerTagsSet, attachedHandlers.current);
128122

129123
attachHandlers(

0 commit comments

Comments
 (0)