Skip to content

Commit 2903b79

Browse files
authored
fix(iOS, Stack, Fabric): Pressable hitSlop in header subviews on Fabric (#3446)
## Description Fixes Pressable `hitSlop` in header subviews on Fabric, iOS. | before | after | | --- | --- | | <video src="https://github.com/user-attachments/assets/df8823fb-efb5-4424-9cd3-8a6fff0ca088" /> | <video src="https://github.com/user-attachments/assets/e661f723-6129-4c05-8f09-f47e6e9a2f69" /> Closes software-mansion/react-native-screens-labs#552. ## Changes - convert hit test point from `RNSScreenStack`'s coordinates to that of `RNSScreenStackHeaderConfig` (on Fabric, it has negative origin.y so that ShadowTree knows about navigation bar + subviews positioning) - fix logic in `RNSScreenStackHeaderConfig`'s hitTest: - previous implementation would always return after checking only one subview leading to inconsistent behavior - change order of iteration through`HeaderSubview`'s subviews and add early return, matching `RCTViewComponentView` implementation - add test screen - make `PressableWithFeedback` pass props to `Pressable` component ## Test code and steps to reproduce Run `Test3466`. ## Checklist - [x] Included code example that can be used to test this change - [x] Ensured that CI passes
1 parent c789812 commit 2903b79

File tree

5 files changed

+162
-8
lines changed

5 files changed

+162
-8
lines changed

apps/src/shared/PressableWithFeedback.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ const PressableWithFeedback = React.forwardRef((props: PressableProps, ref: Forw
4545
return (
4646
<View ref={ref} style={[contentsStyle]}>
4747
<Pressable
48+
{...props}
4849
onPressIn={onPressInCallback}
4950
onPress={onPressCallback}
5051
onPressOut={onPressOutCallback}

apps/src/tests/Test3446.tsx

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
import React, { useState, useEffect } from 'react';
2+
import { NavigationContainer, ParamListBase } from '@react-navigation/native';
3+
import {
4+
createNativeStackNavigator,
5+
NativeStackNavigationProp,
6+
} from '@react-navigation/native-stack';
7+
import PressableWithFeedback from '../shared/PressableWithFeedback';
8+
import { ScrollView, View } from 'react-native';
9+
import Colors from '../shared/styling/Colors';
10+
import { SettingsPicker, SettingsSwitch } from '../shared';
11+
12+
type RouteParamList = {
13+
Home: undefined;
14+
};
15+
16+
type NavigationProp<ParamList extends ParamListBase> = {
17+
navigation: NativeStackNavigationProp<ParamList>;
18+
};
19+
20+
type StackNavigationProp = NavigationProp<RouteParamList>;
21+
22+
const Stack = createNativeStackNavigator();
23+
24+
interface Configuration {
25+
size: 'sm' | 'md' | 'lg';
26+
hidesSharedBackground: boolean;
27+
hitSlop: '0' | '10' | '30';
28+
pressRetentionOffset: '0' | '20' | '50';
29+
}
30+
31+
function getPressableFromConfig(config: Configuration) {
32+
return (
33+
<PressableWithFeedback
34+
key={
35+
config.size +
36+
'_' +
37+
config.hidesSharedBackground +
38+
'_' +
39+
config.hitSlop +
40+
'_' +
41+
config.pressRetentionOffset
42+
}
43+
hitSlop={Number(config.hitSlop)}
44+
pressRetentionOffset={Number(config.pressRetentionOffset)}>
45+
<View
46+
style={{
47+
width: config.size === 'sm' ? 10 : config.size === 'md' ? 36 : 80,
48+
height: config.size === 'sm' ? 10 : config.size === 'md' ? 36 : 40,
49+
}}
50+
/>
51+
</PressableWithFeedback>
52+
);
53+
}
54+
55+
function Screen({ navigation }: StackNavigationProp) {
56+
const [config, setConfig] = useState<Configuration>({
57+
size: 'sm',
58+
hidesSharedBackground: true,
59+
hitSlop: '0',
60+
pressRetentionOffset: '0',
61+
});
62+
63+
useEffect(() => {
64+
navigation.setOptions({
65+
unstable_headerLeftItems: () => [
66+
{
67+
type: 'custom',
68+
element: getPressableFromConfig(config),
69+
hidesSharedBackground: config.hidesSharedBackground,
70+
},
71+
],
72+
unstable_headerRightItems: () => [
73+
{
74+
type: 'custom',
75+
element: getPressableFromConfig(config),
76+
hidesSharedBackground: config.hidesSharedBackground,
77+
},
78+
],
79+
});
80+
}, [config, navigation]);
81+
82+
return (
83+
<ScrollView
84+
contentInsetAdjustmentBehavior="automatic"
85+
contentContainerStyle={{ padding: 16, gap: 5 }}>
86+
<View
87+
style={{
88+
flex: 1,
89+
justifyContent: 'center',
90+
alignItems: 'center',
91+
width: '100%',
92+
height: 200,
93+
backgroundColor: Colors.BlueLight60,
94+
}}>
95+
{getPressableFromConfig(config)}
96+
</View>
97+
<SettingsSwitch
98+
label="hidesSharedBackground"
99+
value={config.hidesSharedBackground}
100+
onValueChange={value =>
101+
setConfig({ ...config, hidesSharedBackground: value })
102+
}
103+
/>
104+
<SettingsPicker<Configuration['size']>
105+
label="size"
106+
value={config.size}
107+
onValueChange={value =>
108+
setConfig({
109+
...config,
110+
size: value,
111+
})
112+
}
113+
items={['sm', 'md', 'lg']}
114+
/>
115+
<SettingsPicker<Configuration['hitSlop']>
116+
label="hitSlop"
117+
value={config.hitSlop}
118+
onValueChange={value =>
119+
setConfig({
120+
...config,
121+
hitSlop: value,
122+
})
123+
}
124+
items={['0', '10', '30']}
125+
/>
126+
<SettingsPicker<Configuration['pressRetentionOffset']>
127+
label="pressRetentionOffset"
128+
value={config.pressRetentionOffset}
129+
onValueChange={value =>
130+
setConfig({
131+
...config,
132+
pressRetentionOffset: value,
133+
})
134+
}
135+
items={['0', '20', '50']}
136+
/>
137+
</ScrollView>
138+
);
139+
}
140+
141+
export default function App() {
142+
return (
143+
<NavigationContainer>
144+
<Stack.Navigator>
145+
<Stack.Screen
146+
name="Home"
147+
component={Screen}
148+
options={{ title: 'Test Pressables' }}
149+
/>
150+
</Stack.Navigator>
151+
</NavigationContainer>
152+
);
153+
}

apps/src/tests/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,7 @@ export { default as Test3369 } from './Test3369';
166166
export { default as Test3379 } from './Test3379';
167167
export { default as Test3422 } from './Test3422';
168168
export { default as Test3425 } from './Test3425';
169+
export { default as Test3446 } from './Test3446';
169170
export { default as Test3450 } from './Test3450';
170171
export { default as TestScreenAnimation } from './TestScreenAnimation';
171172
// The following test was meant to demo the "go back" gesture using Reanimated

ios/RNSScreenStack.mm

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1129,7 +1129,8 @@ - (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event
11291129
RNSScreenView *topMostScreen = (RNSScreenView *)_reactSubviews.lastObject;
11301130
UIView *headerConfig = topMostScreen.findHeaderConfig;
11311131
if ([headerConfig isKindOfClass:[RNSScreenStackHeaderConfig class]]) {
1132-
UIView *headerHitTestResult = [headerConfig hitTest:point withEvent:event];
1132+
CGPoint convertedPoint = [self convertPoint:point toView:headerConfig];
1133+
UIView *headerHitTestResult = [headerConfig hitTest:convertedPoint withEvent:event];
11331134
if (headerHitTestResult != nil) {
11341135
return headerHitTestResult;
11351136
}

ios/RNSScreenStackHeaderConfig.mm

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -169,19 +169,17 @@ - (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event
169169
continue;
170170
}
171171

172-
// we wrap the headerLeft/Right component in a UIBarButtonItem
173-
// so we need to hit test subviews from left to right, because of the view flattening
174-
UIView *headerComponent = nil;
175-
for (UIView *headerComponentSubview in subview.subviews) {
172+
// We wrap the headerLeft/Right component in a UIBarButtonItem
173+
// so we need to hit test subviews, because of the view flattening
174+
// (we match RCTViewComponentView implementation).
175+
for (UIView *headerComponentSubview in [subview.subviews reverseObjectEnumerator]) {
176176
CGPoint convertedPoint = [self convertPoint:point toView:headerComponentSubview];
177177
UIView *hitTestResult = [headerComponentSubview hitTest:convertedPoint withEvent:event];
178178

179179
if (hitTestResult != nil) {
180-
headerComponent = hitTestResult;
180+
return hitTestResult;
181181
}
182182
}
183-
184-
return headerComponent;
185183
}
186184
}
187185
return nil;

0 commit comments

Comments
 (0)