Skip to content

Commit ecf706c

Browse files
authored
Move hitSlop normalization to JS (#4388)
## Description This moves the parsing and validation of `hitSlop` into one shared JS module and reduces the platforms to reading a fixed shape. JS now sends a normalized [left, top, right, bottom, width, height] array, where null marks an unspecified edge. That layout matches what the platforms already stored internally (FloatArray(6) on Android, RNGHHitSlop on Apple), so the native parsers shrink to mapping null → NaN, plus the DIP→px conversion on Android. Applied at all four producers: filterConfig (v1 and v2), prepareConfigForNativeSide (v3), bindSharedValues (v3's UI-thread path) and the button's web wrapper. ## Test plan Tested on Android, iOS and Web
1 parent cc704f0 commit ecf706c

14 files changed

Lines changed: 603 additions & 199 deletions

File tree

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

Lines changed: 30 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -182,18 +182,6 @@ open class GestureHandler {
182182
hitSlop!![HIT_SLOP_BOTTOM_IDX] = bottomPad
183183
hitSlop!![HIT_SLOP_WIDTH_IDX] = width
184184
hitSlop!![HIT_SLOP_HEIGHT_IDX] = height
185-
require(!(hitSlopSet(width) && hitSlopSet(leftPad) && hitSlopSet(rightPad))) {
186-
"Cannot have all of left, right and width defined"
187-
}
188-
require(!(hitSlopSet(width) && !hitSlopSet(leftPad) && !hitSlopSet(rightPad))) {
189-
"When width is set one of left or right pads need to be defined"
190-
}
191-
require(!(hitSlopSet(height) && hitSlopSet(bottomPad) && hitSlopSet(topPad))) {
192-
"Cannot have all of top, bottom and height defined"
193-
}
194-
require(!(hitSlopSet(height) && !hitSlopSet(bottomPad) && !hitSlopSet(topPad))) {
195-
"When height is set one of top or bottom pads need to be defined"
196-
}
197185
}
198186

199187
fun setHitSlop(padding: Float?) {
@@ -968,68 +956,44 @@ open class GestureHandler {
968956
private const val KEY_MANUAL_ACTIVATION = "manualActivation"
969957
private const val KEY_MOUSE_BUTTON = "mouseButton"
970958
private const val KEY_HIT_SLOP = "hitSlop"
971-
private const val KEY_HIT_SLOP_LEFT = "left"
972-
private const val KEY_HIT_SLOP_TOP = "top"
973-
private const val KEY_HIT_SLOP_RIGHT = "right"
974-
private const val KEY_HIT_SLOP_BOTTOM = "bottom"
975-
private const val KEY_HIT_SLOP_VERTICAL = "vertical"
976-
private const val KEY_HIT_SLOP_HORIZONTAL = "horizontal"
977-
private const val KEY_HIT_SLOP_WIDTH = "width"
978-
private const val KEY_HIT_SLOP_HEIGHT = "height"
979959
private const val KEY_TEST_ID = "testID"
980960
private const val KEY_CANCELS_JS_RESPONDER = "cancelsJSResponder"
981961

962+
/**
963+
* `hitSlop` arrives already normalized by the JS side as
964+
* `[left, top, right, bottom, width, height]`, where `null` marks an edge that was not
965+
* specified. Validation of the `width`/`height` combinations happens in JS as well, so all
966+
* that is left here is converting the values from DIP to pixels.
967+
*/
982968
private fun handleHitSlopProperty(handler: GestureHandler, config: ReadableMap) {
983969
if (config.isNull(KEY_HIT_SLOP)) {
984970
handler.setHitSlop(null)
985-
} else if (config.getType(KEY_HIT_SLOP) == ReadableType.Number) {
986-
val hitSlop = PixelUtil.toPixelFromDIP(config.getDouble(KEY_HIT_SLOP))
987-
handler.setHitSlop(
988-
hitSlop,
989-
hitSlop,
990-
hitSlop,
991-
hitSlop,
992-
GestureHandler.HIT_SLOP_NONE,
993-
GestureHandler.HIT_SLOP_NONE,
994-
)
971+
return
972+
}
973+
974+
// A uniform hit slop stays a plain number on the wire, which spares the bridge the
975+
// wrapper it would allocate for an array.
976+
if (config.getType(KEY_HIT_SLOP) == ReadableType.Number) {
977+
handler.setHitSlop(PixelUtil.toPixelFromDIP(config.getDouble(KEY_HIT_SLOP)))
978+
return
979+
}
980+
981+
val hitSlop = config.getArray(KEY_HIT_SLOP)!!
982+
983+
fun edge(index: Int) = if (hitSlop.isNull(index)) {
984+
GestureHandler.HIT_SLOP_NONE
995985
} else {
996-
val hitSlop = config.getMap(KEY_HIT_SLOP)!!
997-
var left = GestureHandler.HIT_SLOP_NONE
998-
var top = GestureHandler.HIT_SLOP_NONE
999-
var right = GestureHandler.HIT_SLOP_NONE
1000-
var bottom = GestureHandler.HIT_SLOP_NONE
1001-
var width = GestureHandler.HIT_SLOP_NONE
1002-
var height = GestureHandler.HIT_SLOP_NONE
1003-
if (hitSlop.hasKey(KEY_HIT_SLOP_HORIZONTAL)) {
1004-
val horizontalPad = PixelUtil.toPixelFromDIP(hitSlop.getDouble(KEY_HIT_SLOP_HORIZONTAL))
1005-
right = horizontalPad
1006-
left = right
1007-
}
1008-
if (hitSlop.hasKey(KEY_HIT_SLOP_VERTICAL)) {
1009-
val verticalPad = PixelUtil.toPixelFromDIP(hitSlop.getDouble(KEY_HIT_SLOP_VERTICAL))
1010-
bottom = verticalPad
1011-
top = bottom
1012-
}
1013-
if (hitSlop.hasKey(KEY_HIT_SLOP_LEFT)) {
1014-
left = PixelUtil.toPixelFromDIP(hitSlop.getDouble(KEY_HIT_SLOP_LEFT))
1015-
}
1016-
if (hitSlop.hasKey(KEY_HIT_SLOP_TOP)) {
1017-
top = PixelUtil.toPixelFromDIP(hitSlop.getDouble(KEY_HIT_SLOP_TOP))
1018-
}
1019-
if (hitSlop.hasKey(KEY_HIT_SLOP_RIGHT)) {
1020-
right = PixelUtil.toPixelFromDIP(hitSlop.getDouble(KEY_HIT_SLOP_RIGHT))
1021-
}
1022-
if (hitSlop.hasKey(KEY_HIT_SLOP_BOTTOM)) {
1023-
bottom = PixelUtil.toPixelFromDIP(hitSlop.getDouble(KEY_HIT_SLOP_BOTTOM))
1024-
}
1025-
if (hitSlop.hasKey(KEY_HIT_SLOP_WIDTH)) {
1026-
width = PixelUtil.toPixelFromDIP(hitSlop.getDouble(KEY_HIT_SLOP_WIDTH))
1027-
}
1028-
if (hitSlop.hasKey(KEY_HIT_SLOP_HEIGHT)) {
1029-
height = PixelUtil.toPixelFromDIP(hitSlop.getDouble(KEY_HIT_SLOP_HEIGHT))
1030-
}
1031-
handler.setHitSlop(left, top, right, bottom, width, height)
986+
PixelUtil.toPixelFromDIP(hitSlop.getDouble(index))
1032987
}
988+
989+
handler.setHitSlop(
990+
edge(HIT_SLOP_LEFT_IDX),
991+
edge(HIT_SLOP_TOP_IDX),
992+
edge(HIT_SLOP_RIGHT_IDX),
993+
edge(HIT_SLOP_BOTTOM_IDX),
994+
edge(HIT_SLOP_WIDTH_IDX),
995+
edge(HIT_SLOP_HEIGHT_IDX),
996+
)
1033997
}
1034998
}
1035999
}

‎packages/react-native-gesture-handler/apple/RNGestureHandler.mm‎

Lines changed: 31 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,24 @@ - (RNGestureHandler *)gestureHandler
3333

3434
static RNGHHitSlop RNGHHitSlopEmpty = {NAN, NAN, NAN, NAN, NAN, NAN};
3535

36-
#define RNGH_HIT_SLOP_GET(key) (prop[key] == nil ? NAN : [prop[key] doubleValue])
36+
// `hitSlop` reaches the native side already normalized by JS into
37+
// `[left, top, right, bottom, width, height]`, where `null` marks an unspecified edge.
38+
typedef NS_ENUM(NSUInteger, RNGHHitSlopIndex) {
39+
RNGHHitSlopIndexLeft = 0,
40+
RNGHHitSlopIndexTop,
41+
RNGHHitSlopIndexRight,
42+
RNGHHitSlopIndexBottom,
43+
RNGHHitSlopIndexWidth,
44+
RNGHHitSlopIndexHeight,
45+
RNGHHitSlopIndexCount,
46+
};
47+
48+
static CGFloat RNGHHitSlopEdge(NSArray *hitSlop, RNGHHitSlopIndex index)
49+
{
50+
id value = hitSlop[index];
51+
return [value isKindOfClass:[NSNumber class]] ? [value doubleValue] : NAN;
52+
}
53+
3754
#define RNGH_HIT_SLOP_IS_SET(hitSlop) \
3855
(!isnan(hitSlop.left) || !isnan(hitSlop.right) || !isnan(hitSlop.top) || !isnan(hitSlop.bottom))
3956
#define RNGH_HIT_SLOP_INSET(key) (isnan(hitSlop.key) ? 0. : hitSlop.key)
@@ -166,32 +183,23 @@ - (void)updateConfig:(NSDictionary *)config
166183
_cancelsJSResponder = [RCTConvert BOOL:prop];
167184
}
168185

186+
// A cleared hit slop arrives as six unset slots rather than as `null`, because the TurboModule
187+
// bridge drops null-valued keys on this platform. A missing key still means the property was not
188+
// part of this update, and leaves the previous value alone.
169189
prop = config[@"hitSlop"];
170190
if ([prop isKindOfClass:[NSNumber class]]) {
171-
_hitSlop.left = _hitSlop.right = _hitSlop.top = _hitSlop.bottom = [prop doubleValue];
172-
} else if ([prop isKindOfClass:[NSNull class]]) {
191+
// A uniform hit slop stays a plain number on the wire, skipping the array wrapper.
173192
_hitSlop = RNGHHitSlopEmpty;
193+
_hitSlop.left = _hitSlop.right = _hitSlop.top = _hitSlop.bottom = [prop doubleValue];
194+
} else if ([prop isKindOfClass:[NSArray class]]) {
195+
_hitSlop.left = RNGHHitSlopEdge(prop, RNGHHitSlopIndexLeft);
196+
_hitSlop.top = RNGHHitSlopEdge(prop, RNGHHitSlopIndexTop);
197+
_hitSlop.right = RNGHHitSlopEdge(prop, RNGHHitSlopIndexRight);
198+
_hitSlop.bottom = RNGHHitSlopEdge(prop, RNGHHitSlopIndexBottom);
199+
_hitSlop.width = RNGHHitSlopEdge(prop, RNGHHitSlopIndexWidth);
200+
_hitSlop.height = RNGHHitSlopEdge(prop, RNGHHitSlopIndexHeight);
174201
} else if (prop != nil) {
175-
_hitSlop.left = _hitSlop.right = RNGH_HIT_SLOP_GET(@"horizontal");
176-
_hitSlop.top = _hitSlop.bottom = RNGH_HIT_SLOP_GET(@"vertical");
177-
_hitSlop.left = RNGH_HIT_SLOP_GET(@"left");
178-
_hitSlop.right = RNGH_HIT_SLOP_GET(@"right");
179-
_hitSlop.top = RNGH_HIT_SLOP_GET(@"top");
180-
_hitSlop.bottom = RNGH_HIT_SLOP_GET(@"bottom");
181-
_hitSlop.width = RNGH_HIT_SLOP_GET(@"width");
182-
_hitSlop.height = RNGH_HIT_SLOP_GET(@"height");
183-
if (isnan(_hitSlop.left) && isnan(_hitSlop.right) && !isnan(_hitSlop.width)) {
184-
RCTLogError(@"When width is set one of left or right pads need to be defined");
185-
}
186-
if (!isnan(_hitSlop.width) && !isnan(_hitSlop.left) && !isnan(_hitSlop.right)) {
187-
RCTLogError(@"Cannot have all of left, right and width defined");
188-
}
189-
if (isnan(_hitSlop.top) && isnan(_hitSlop.bottom) && !isnan(_hitSlop.height)) {
190-
RCTLogError(@"When height is set one of top or bottom pads need to be defined");
191-
}
192-
if (!isnan(_hitSlop.height) && !isnan(_hitSlop.top) && !isnan(_hitSlop.bottom)) {
193-
RCTLogError(@"Cannot have all of top, bottom and height defined");
194-
}
202+
_hitSlop = RNGHHitSlopEmpty;
195203
}
196204
}
197205

‎packages/react-native-gesture-handler/apple/RNGestureHandlerButtonComponentView.mm‎

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -290,12 +290,16 @@ - (NSDictionary *)buildManagedHandlerConfig:(const RNGestureHandlerButtonProps &
290290
// and the handler keeps its unset default instead of hit-testing against an identical frame.
291291
if (props.gestureHitSlop.top != 0 || props.gestureHitSlop.left != 0 || props.gestureHitSlop.bottom != 0 ||
292292
props.gestureHitSlop.right != 0) {
293-
config[@"hitSlop"] = @{
294-
@"top" : @(props.gestureHitSlop.top),
295-
@"left" : @(props.gestureHitSlop.left),
296-
@"bottom" : @(props.gestureHitSlop.bottom),
297-
@"right" : @(props.gestureHitSlop.right),
298-
};
293+
// Matches the normalized `[left, top, right, bottom, width, height]` layout the JS side sends;
294+
// the button only exposes the four edges, so width and height are always unset.
295+
config[@"hitSlop"] = @[
296+
@(props.gestureHitSlop.left),
297+
@(props.gestureHitSlop.top),
298+
@(props.gestureHitSlop.right),
299+
@(props.gestureHitSlop.bottom),
300+
[NSNull null],
301+
[NSNull null],
302+
];
299303
}
300304

301305
return config;
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
import type { HitSlop } from '../handlers/gestureHandlerCommon';
2+
import { normalizeHitSlop } from '../handlers/hitSlop';
3+
4+
describe('normalizeHitSlop', () => {
5+
test('keeps `undefined` out of the payload and sends `null` as unset slots', () => {
6+
// `undefined` keeps the property out of partial config updates. `null` has to
7+
// travel as six unset slots, since the iOS bridge drops null-valued keys.
8+
expect(normalizeHitSlop(undefined)).toBeUndefined();
9+
expect(normalizeHitSlop(null)).toEqual([
10+
null,
11+
null,
12+
null,
13+
null,
14+
null,
15+
null,
16+
]);
17+
});
18+
19+
test('is idempotent', () => {
20+
// Normalizing an already normalized value must not empty it out.
21+
const normalized = normalizeHitSlop({ horizontal: -10, top: -5 });
22+
23+
expect(normalizeHitSlop(normalized)).toEqual([
24+
-10,
25+
-5,
26+
-10,
27+
null,
28+
null,
29+
null,
30+
]);
31+
expect(normalizeHitSlop(normalizeHitSlop(-10))).toBe(-10);
32+
});
33+
34+
test('forwards a uniform hit slop as a plain number', () => {
35+
// Left as a number so the bridge does not allocate an array wrapper for it;
36+
// every platform expands it into four equal edges itself.
37+
expect(normalizeHitSlop(-10)).toBe(-10);
38+
expect(normalizeHitSlop(0)).toBe(0);
39+
});
40+
41+
test('marks unspecified edges as `null`', () => {
42+
expect(normalizeHitSlop({})).toEqual([null, null, null, null, null, null]);
43+
expect(normalizeHitSlop({ left: -10 })).toEqual([
44+
-10,
45+
null,
46+
null,
47+
null,
48+
null,
49+
null,
50+
]);
51+
});
52+
53+
test('treats an explicitly `undefined` edge as unspecified', () => {
54+
// Without this, the value would reach the platforms as `0` and shrink the view.
55+
expect(normalizeHitSlop({ left: undefined, top: -5 })).toEqual([
56+
null,
57+
-5,
58+
null,
59+
null,
60+
null,
61+
null,
62+
]);
63+
});
64+
65+
test('expands `horizontal` and `vertical`', () => {
66+
expect(normalizeHitSlop({ horizontal: -10 })).toEqual([
67+
-10,
68+
null,
69+
-10,
70+
null,
71+
null,
72+
null,
73+
]);
74+
expect(normalizeHitSlop({ vertical: -10 })).toEqual([
75+
null,
76+
-10,
77+
null,
78+
-10,
79+
null,
80+
null,
81+
]);
82+
expect(normalizeHitSlop({ horizontal: -10, vertical: -5 })).toEqual([
83+
-10,
84+
-5,
85+
-10,
86+
-5,
87+
null,
88+
null,
89+
]);
90+
});
91+
92+
test('lets an explicit edge win over the shorthand', () => {
93+
expect(normalizeHitSlop({ horizontal: -10, left: -20 })).toEqual([
94+
-20,
95+
null,
96+
-10,
97+
null,
98+
null,
99+
null,
100+
]);
101+
expect(normalizeHitSlop({ vertical: -10, bottom: -20 })).toEqual([
102+
null,
103+
-10,
104+
null,
105+
-20,
106+
null,
107+
null,
108+
]);
109+
});
110+
111+
test('carries `width` and `height` through', () => {
112+
expect(normalizeHitSlop({ left: 0, width: 20 })).toEqual([
113+
0,
114+
null,
115+
null,
116+
null,
117+
20,
118+
null,
119+
]);
120+
expect(normalizeHitSlop({ bottom: 0, height: 20 })).toEqual([
121+
null,
122+
null,
123+
null,
124+
0,
125+
null,
126+
20,
127+
]);
128+
});
129+
130+
test('rejects invalid `width` and `height` combinations', () => {
131+
expect(() =>
132+
normalizeHitSlop({ left: 0, right: 0, width: 20 } as HitSlop)
133+
).toThrow("cannot have all of 'left', 'right' and 'width' defined");
134+
135+
expect(() => normalizeHitSlop({ width: 20 } as HitSlop)).toThrow(
136+
"when 'width' is defined, either 'left' or 'right' has to be defined"
137+
);
138+
139+
expect(() =>
140+
normalizeHitSlop({ top: 0, bottom: 0, height: 20 } as HitSlop)
141+
).toThrow("cannot have all of 'top', 'bottom' and 'height' defined");
142+
143+
expect(() => normalizeHitSlop({ height: 20 } as HitSlop)).toThrow(
144+
"when 'height' is defined, either 'top' or 'bottom' has to be defined"
145+
);
146+
});
147+
148+
test('rejects a negative `width` or `height`', () => {
149+
expect(() => normalizeHitSlop({ left: 0, width: -20 } as HitSlop)).toThrow(
150+
"'width' cannot be negative"
151+
);
152+
153+
expect(() => normalizeHitSlop({ top: 0, height: -20 } as HitSlop)).toThrow(
154+
"'height' cannot be negative"
155+
);
156+
});
157+
158+
test('allows a zero `width` or `height`', () => {
159+
// An empty hit area is degenerate but coherent, and a hit slop animated
160+
// from zero upwards passes through it.
161+
expect(normalizeHitSlop({ left: 0, width: 0 })).toEqual([
162+
0,
163+
null,
164+
null,
165+
null,
166+
0,
167+
null,
168+
]);
169+
});
170+
171+
test('counts a shorthand as defining both of its edges', () => {
172+
// `horizontal` fills in both `left` and `right`, which conflicts with `width`.
173+
expect(() =>
174+
normalizeHitSlop({ horizontal: -10, width: 20 } as HitSlop)
175+
).toThrow("cannot have all of 'left', 'right' and 'width' defined");
176+
});
177+
});

0 commit comments

Comments
 (0)