-
Notifications
You must be signed in to change notification settings - Fork 2.9k
/
ScreenWrapper.tsx
333 lines (289 loc) · 15.7 KB
/
ScreenWrapper.tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
import {UNSTABLE_usePreventRemove, useIsFocused, useNavigation, useRoute} from '@react-navigation/native';
import type {StackNavigationProp} from '@react-navigation/stack';
import type {ForwardedRef, ReactNode} from 'react';
import React, {createContext, forwardRef, useEffect, useMemo, useRef, useState} from 'react';
import type {StyleProp, ViewStyle} from 'react-native';
import {Keyboard, NativeModules, PanResponder, View} from 'react-native';
import {PickerAvoidingView} from 'react-native-picker-select';
import type {EdgeInsets} from 'react-native-safe-area-context';
import useEnvironment from '@hooks/useEnvironment';
import useInitialDimensions from '@hooks/useInitialWindowDimensions';
import useKeyboardState from '@hooks/useKeyboardState';
import useNetwork from '@hooks/useNetwork';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
import useTackInputFocus from '@hooks/useTackInputFocus';
import useThemeStyles from '@hooks/useThemeStyles';
import useWindowDimensions from '@hooks/useWindowDimensions';
import * as Browser from '@libs/Browser';
import type {AuthScreensParamList, RootStackParamList} from '@libs/Navigation/types';
import toggleTestToolsModal from '@userActions/TestTool';
import CONST from '@src/CONST';
import CustomDevMenu from './CustomDevMenu';
import FocusTrapForScreens from './FocusTrap/FocusTrapForScreen';
import type FocusTrapForScreenProps from './FocusTrap/FocusTrapForScreen/FocusTrapProps';
import HeaderGap from './HeaderGap';
import ImportedStateIndicator from './ImportedStateIndicator';
import KeyboardAvoidingView from './KeyboardAvoidingView';
import OfflineIndicator from './OfflineIndicator';
import SafeAreaConsumer from './SafeAreaConsumer';
import withNavigationFallback from './withNavigationFallback';
type ScreenWrapperChildrenProps = {
insets: EdgeInsets;
safeAreaPaddingBottomStyle?: {
paddingBottom?: ViewStyle['paddingBottom'];
};
didScreenTransitionEnd: boolean;
};
type ScreenWrapperProps = {
/** Returns a function as a child to pass insets to or a node to render without insets */
children: ReactNode | React.FC<ScreenWrapperChildrenProps>;
/** A unique ID to find the screen wrapper in tests */
testID: string;
/** Additional styles to add */
style?: StyleProp<ViewStyle>;
/** Additional styles for header gap */
headerGapStyles?: StyleProp<ViewStyle>;
/** Styles for the offline indicator */
offlineIndicatorStyle?: StyleProp<ViewStyle>;
/** Whether to include padding bottom */
includeSafeAreaPaddingBottom?: boolean;
/** Whether to include padding top */
includePaddingTop?: boolean;
/** Called when navigated Screen's transition is finished. It does not fire when user exit the page. */
onEntryTransitionEnd?: () => void;
/** The behavior to pass to the KeyboardAvoidingView, requires some trial and error depending on the layout/devices used.
* Search 'switch(behavior)' in ./node_modules/react-native/Libraries/Components/Keyboard/KeyboardAvoidingView.js for more context */
keyboardAvoidingViewBehavior?: 'padding' | 'height' | 'position';
/** Whether KeyboardAvoidingView should be enabled. Use false for screens where this functionality is not necessary */
shouldEnableKeyboardAvoidingView?: boolean;
/** Whether picker modal avoiding should be enabled. Should be enabled when there's a picker at the bottom of a
* scrollable form, gives a subtly better UX if disabled on non-scrollable screens with a submit button */
shouldEnablePickerAvoiding?: boolean;
/** Whether to dismiss keyboard before leaving a screen */
shouldDismissKeyboardBeforeClose?: boolean;
/** Whether to use the maxHeight (true) or use the 100% of the height (false) */
shouldEnableMaxHeight?: boolean;
/** Whether to use the minHeight. Use true for screens where the window height are changing because of Virtual Keyboard */
shouldEnableMinHeight?: boolean;
/** Whether to show offline indicator */
shouldShowOfflineIndicator?: boolean;
/** Whether to avoid scroll on virtual viewport */
shouldAvoidScrollOnVirtualViewport?: boolean;
/** Whether to use cached virtual viewport height */
shouldUseCachedViewportHeight?: boolean;
/**
* The navigation prop is passed by the navigator. It is used to trigger the onEntryTransitionEnd callback
* when the screen transition ends.
*
* This is required because transitionEnd event doesn't trigger in the testing environment.
*/
navigation?: StackNavigationProp<RootStackParamList> | StackNavigationProp<AuthScreensParamList>;
/** Whether to show offline indicator on wide screens */
shouldShowOfflineIndicatorInWideScreen?: boolean;
/** Overrides the focus trap default settings */
focusTrapSettings?: FocusTrapForScreenProps['focusTrapSettings'];
};
type ScreenWrapperStatusContextType = {didScreenTransitionEnd: boolean};
const ScreenWrapperStatusContext = createContext<ScreenWrapperStatusContextType | undefined>(undefined);
function ScreenWrapper(
{
shouldEnableMaxHeight = false,
shouldEnableMinHeight = false,
includePaddingTop = true,
keyboardAvoidingViewBehavior = 'padding',
includeSafeAreaPaddingBottom = true,
shouldEnableKeyboardAvoidingView = true,
shouldEnablePickerAvoiding = true,
headerGapStyles,
children,
shouldShowOfflineIndicator = true,
offlineIndicatorStyle,
style,
shouldDismissKeyboardBeforeClose = true,
onEntryTransitionEnd,
testID,
navigation: navigationProp,
shouldAvoidScrollOnVirtualViewport = true,
shouldShowOfflineIndicatorInWideScreen = false,
shouldUseCachedViewportHeight = false,
focusTrapSettings,
}: ScreenWrapperProps,
ref: ForwardedRef<View>,
) {
/**
* We are only passing navigation as prop from
* ReportScreen -> ScreenWrapper
*
* so in other places where ScreenWrapper is used, we need to
* fallback to useNavigation.
*/
const navigationFallback = useNavigation<StackNavigationProp<RootStackParamList>>();
const navigation = navigationProp ?? navigationFallback;
const isFocused = useIsFocused();
const {windowHeight} = useWindowDimensions(shouldUseCachedViewportHeight);
// We need to use isSmallScreenWidth instead of shouldUseNarrowLayout for a case where we want to show the offline indicator only on small screens
// eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth
const {isSmallScreenWidth, shouldUseNarrowLayout} = useResponsiveLayout();
const {initialHeight} = useInitialDimensions();
const styles = useThemeStyles();
const keyboardState = useKeyboardState();
const {isDevelopment} = useEnvironment();
const {isOffline} = useNetwork();
const [didScreenTransitionEnd, setDidScreenTransitionEnd] = useState(false);
const maxHeight = shouldEnableMaxHeight ? windowHeight : undefined;
const minHeight = shouldEnableMinHeight && !Browser.isSafari() ? initialHeight : undefined;
const isKeyboardShown = keyboardState?.isKeyboardShown ?? false;
const isKeyboardShownRef = useRef<boolean>(false);
// eslint-disable-next-line react-compiler/react-compiler
isKeyboardShownRef.current = keyboardState?.isKeyboardShown ?? false;
const route = useRoute();
const shouldReturnToOldDot = useMemo(() => {
return !!route?.params && 'singleNewDotEntry' in route.params && route.params.singleNewDotEntry === 'true';
}, [route?.params]);
UNSTABLE_usePreventRemove(shouldReturnToOldDot, () => {
NativeModules.HybridAppModule?.closeReactNativeApp(false, false);
});
const panResponder = useRef(
PanResponder.create({
onStartShouldSetPanResponderCapture: (_e, gestureState) => gestureState.numberActiveTouches === CONST.TEST_TOOL.NUMBER_OF_TAPS,
onPanResponderRelease: toggleTestToolsModal,
}),
).current;
const keyboardDismissPanResponder = useRef(
PanResponder.create({
onMoveShouldSetPanResponderCapture: (_e, gestureState) => {
const isHorizontalSwipe = Math.abs(gestureState.dx) > Math.abs(gestureState.dy);
const shouldDismissKeyboard = shouldDismissKeyboardBeforeClose && isKeyboardShown && Browser.isMobile();
return isHorizontalSwipe && shouldDismissKeyboard;
},
onPanResponderGrant: Keyboard.dismiss,
}),
).current;
useEffect(() => {
// On iOS, the transitionEnd event doesn't trigger some times. As such, we need to set a timeout
const timeout = setTimeout(() => {
setDidScreenTransitionEnd(true);
onEntryTransitionEnd?.();
}, CONST.SCREEN_TRANSITION_END_TIMEOUT);
const unsubscribeTransitionEnd = navigation.addListener('transitionEnd', (event) => {
// Prevent firing the prop callback when user is exiting the page.
if (event?.data?.closing) {
return;
}
clearTimeout(timeout);
setDidScreenTransitionEnd(true);
onEntryTransitionEnd?.();
});
// We need to have this prop to remove keyboard before going away from the screen, to avoid previous screen look weird for a brief moment,
// also we need to have generic control in future - to prevent closing keyboard for some rare cases in which beforeRemove has limitations
// described here https://reactnavigation.org/docs/preventing-going-back/#limitations
const beforeRemoveSubscription = shouldDismissKeyboardBeforeClose
? navigation.addListener('beforeRemove', () => {
if (!isKeyboardShownRef.current) {
return;
}
Keyboard.dismiss();
})
: undefined;
return () => {
clearTimeout(timeout);
unsubscribeTransitionEnd();
if (beforeRemoveSubscription) {
beforeRemoveSubscription();
}
};
// Rule disabled because this effect is only for component did mount & will component unmount lifecycle event
// eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps
}, []);
const isAvoidingViewportScroll = useTackInputFocus(isFocused && shouldEnableMaxHeight && shouldAvoidScrollOnVirtualViewport && Browser.isMobileWebKit());
const contextValue = useMemo(() => ({didScreenTransitionEnd}), [didScreenTransitionEnd]);
return (
<SafeAreaConsumer>
{({
insets = {
top: 0,
bottom: 0,
left: 0,
right: 0,
},
paddingTop,
paddingBottom,
safeAreaPaddingBottomStyle,
}) => {
const paddingStyle: StyleProp<ViewStyle> = {};
if (includePaddingTop) {
paddingStyle.paddingTop = paddingTop;
}
// We always need the safe area padding bottom if we're showing the offline indicator since it is bottom-docked.
if (includeSafeAreaPaddingBottom || (isOffline && shouldShowOfflineIndicator)) {
paddingStyle.paddingBottom = paddingBottom;
}
return (
<FocusTrapForScreens focusTrapSettings={focusTrapSettings}>
<View
ref={ref}
style={[styles.flex1, {minHeight}]}
// eslint-disable-next-line react/jsx-props-no-spreading
{...panResponder.panHandlers}
testID={testID}
>
<View
fsClass="fs-unmask"
style={[styles.flex1, paddingStyle, style]}
// eslint-disable-next-line react/jsx-props-no-spreading
{...keyboardDismissPanResponder.panHandlers}
>
<KeyboardAvoidingView
style={[styles.w100, styles.h100, {maxHeight}, isAvoidingViewportScroll ? [styles.overflowAuto, styles.overscrollBehaviorContain] : {}]}
behavior={keyboardAvoidingViewBehavior}
enabled={shouldEnableKeyboardAvoidingView}
>
<PickerAvoidingView
style={isAvoidingViewportScroll ? [styles.h100, {marginTop: 1}] : styles.flex1}
enabled={shouldEnablePickerAvoiding}
>
<HeaderGap styles={headerGapStyles} />
{isDevelopment && <CustomDevMenu />}
<ScreenWrapperStatusContext.Provider value={contextValue}>
{
// If props.children is a function, call it to provide the insets to the children.
typeof children === 'function'
? children({
insets,
safeAreaPaddingBottomStyle,
didScreenTransitionEnd,
})
: children
}
{isSmallScreenWidth && shouldShowOfflineIndicator && (
<>
<OfflineIndicator style={offlineIndicatorStyle} />
{/* Since import state is tightly coupled to the offline state, it is safe to display it when showing offline indicator */}
<ImportedStateIndicator />
</>
)}
{!shouldUseNarrowLayout && shouldShowOfflineIndicatorInWideScreen && (
<>
<OfflineIndicator
containerStyles={[]}
style={[styles.pl5, styles.offlineIndicatorRow, offlineIndicatorStyle]}
/>
{/* Since import state is tightly coupled to the offline state, it is safe to display it when showing offline indicator */}
<ImportedStateIndicator />
</>
)}
</ScreenWrapperStatusContext.Provider>
</PickerAvoidingView>
</KeyboardAvoidingView>
</View>
</View>
</FocusTrapForScreens>
);
}}
</SafeAreaConsumer>
);
}
ScreenWrapper.displayName = 'ScreenWrapper';
export default withNavigationFallback(forwardRef(ScreenWrapper));
export {ScreenWrapperStatusContext};
export type {ScreenWrapperChildrenProps};