Skip to content

Commit 198686d

Browse files
committed
Narrow down disabling bottom padding to FeatureTrainingScrollable modals only
1 parent 6955846 commit 198686d

6 files changed

Lines changed: 144 additions & 15 deletions

File tree

src/components/FeatureTrainingModal.tsx

Lines changed: 38 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
import type {VideoReadyForDisplayEvent} from 'expo-av';
22
import type {ImageContentFit} from 'expo-image';
3-
import React, {useCallback, useEffect, useLayoutEffect, useRef, useState} from 'react';
3+
import React, {useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState} from 'react';
44
import {Image, InteractionManager, View} from 'react-native';
5-
import type {ImageResizeMode, ImageSourcePropType, StyleProp, TextStyle, ViewStyle} from 'react-native';
5+
// eslint-disable-next-line no-restricted-imports
6+
import type {ImageResizeMode, ImageSourcePropType, LayoutChangeEvent, ScrollView as RNScrollView, StyleProp, TextStyle, ViewStyle} from 'react-native';
67
import {GestureHandlerRootView} from 'react-native-gesture-handler';
78
import type {MergeExclusive} from 'type-fest';
89
import useLocalize from '@hooks/useLocalize';
910
import useNetwork from '@hooks/useNetwork';
1011
import useResponsiveLayout from '@hooks/useResponsiveLayout';
12+
import useSafeAreaInsets from '@hooks/useSafeAreaInsets';
1113
import useStyleUtils from '@hooks/useStyleUtils';
1214
import useThemeStyles from '@hooks/useThemeStyles';
1315
import {parseFSAttributes} from '@libs/Fullstory';
@@ -209,6 +211,10 @@ function FeatureTrainingModal({
209211
const {shouldUseNarrowLayout} = useResponsiveLayout();
210212
const {isOffline} = useNetwork();
211213
const hasHelpButtonBeenPressed = useRef(false);
214+
const scrollViewRef = useRef<RNScrollView>(null);
215+
const [containerHeight, setContainerHeight] = useState(0);
216+
const [contentHeight, setContentHeight] = useState(0);
217+
const insets = useSafeAreaInsets();
212218

213219
useEffect(() => {
214220
InteractionManager.runAfterInteractions(() => {
@@ -355,8 +361,18 @@ function FeatureTrainingModal({
355361
*/
356362
useLayoutEffect(parseFSAttributes, []);
357363

364+
// Scrolls modal to the bottom when keyboard appears so the action buttons are visible.
365+
useEffect(() => {
366+
if (contentHeight <= containerHeight || onboardingIsMediumOrLargerScreenWidth || !shouldUseScrollView) {
367+
return;
368+
}
369+
scrollViewRef.current?.scrollToEnd({animated: false});
370+
}, [contentHeight, containerHeight, onboardingIsMediumOrLargerScreenWidth, shouldUseScrollView]);
371+
358372
const Wrapper = shouldUseScrollView ? ScrollView : View;
359373

374+
const wrapperStyles = useMemo(() => (shouldUseScrollView ? StyleUtils.getScrollableFeatureTrainingModalStyles(insets) : {}), [shouldUseScrollView, StyleUtils, insets]);
375+
360376
return (
361377
<Modal
362378
avoidKeyboard={avoidKeyboard}
@@ -384,11 +400,16 @@ function FeatureTrainingModal({
384400
onHelp();
385401
}}
386402
shouldUseReanimatedModal
403+
shouldDisableBottomSafeAreaPadding={shouldUseScrollView}
387404
>
388405
<Wrapper
389-
style={[styles.mh100, onboardingIsMediumOrLargerScreenWidth && StyleUtils.getWidthStyle(width)]}
390-
contentContainerStyle={shouldUseScrollView ? styles.pb5 : undefined}
406+
scrollsToTop={false}
407+
style={[styles.mh100, onboardingIsMediumOrLargerScreenWidth && StyleUtils.getWidthStyle(width), wrapperStyles.style]}
408+
contentContainerStyle={wrapperStyles.containerStyle}
391409
keyboardShouldPersistTaps={shouldUseScrollView ? 'handled' : undefined}
410+
ref={shouldUseScrollView ? scrollViewRef : undefined}
411+
onLayout={shouldUseScrollView ? (e: LayoutChangeEvent) => setContainerHeight(e.nativeEvent.layout.height) : undefined}
412+
onContentSizeChange={shouldUseScrollView ? (_w: number, h: number) => setContentHeight(h) : undefined}
392413
fsClass={CONST.FULL_STORY.UNMASK}
393414
testID={CONST.FULL_STORY.UNMASK}
394415
>
@@ -397,9 +418,20 @@ function FeatureTrainingModal({
397418
</View>
398419
<View style={[styles.mt5, styles.mh5, contentOuterContainerStyles]}>
399420
{!!title && !!description && (
400-
<View style={[onboardingIsMediumOrLargerScreenWidth ? [styles.gap1, styles.mb8] : [styles.mb10], contentInnerContainerStyles]}>
421+
<View
422+
style={[
423+
onboardingIsMediumOrLargerScreenWidth ? [styles.gap1, styles.mb8] : [shouldRenderHTMLDescription ? styles.mb5 : styles.mb10],
424+
contentInnerContainerStyles,
425+
]}
426+
>
401427
{typeof title === 'string' ? <Text style={[styles.textHeadlineH1, titleStyles]}>{title}</Text> : title}
402-
{shouldRenderHTMLDescription ? <RenderHTML html={description} /> : <Text style={styles.textSupporting}>{description}</Text>}
428+
{shouldRenderHTMLDescription ? (
429+
<View style={styles.mb2}>
430+
<RenderHTML html={description} />
431+
</View>
432+
) : (
433+
<Text style={styles.textSupporting}>{description}</Text>
434+
)}
403435
{secondaryDescription.length > 0 && <Text style={[styles.textSupporting, styles.mt4]}>{secondaryDescription}</Text>}
404436
{children}
405437
</View>

src/components/Modal/BaseModal.tsx

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
import React, {forwardRef, useCallback, useContext, useEffect, useMemo, useRef} from 'react';
1+
import React, {forwardRef, useCallback, useContext, useEffect, useMemo, useRef, useState} from 'react';
22
import {View} from 'react-native';
3+
import type {LayoutChangeEvent} from 'react-native';
34
import type {ModalProps as ReactNativeModalProps} from 'react-native-modal';
45
import ReactNativeModal from 'react-native-modal';
56
import type {ValueOf} from 'type-fest';
@@ -17,6 +18,7 @@ import useTheme from '@hooks/useTheme';
1718
import useThemeStyles from '@hooks/useThemeStyles';
1819
import useWindowDimensions from '@hooks/useWindowDimensions';
1920
import ComposerFocusManager from '@libs/ComposerFocusManager';
21+
import getPlatform from '@libs/getPlatform';
2022
import NarrowPaneContext from '@libs/Navigation/AppNavigator/Navigators/NarrowPaneContext';
2123
import Overlay from '@libs/Navigation/AppNavigator/Navigators/Overlay';
2224
import Navigation from '@libs/Navigation/Navigation';
@@ -94,6 +96,7 @@ function BaseModal(
9496
enableEdgeToEdgeBottomSafeAreaPadding,
9597
shouldApplySidePanelOffset = type === CONST.MODAL.MODAL_TYPE.RIGHT_DOCKED,
9698
shouldUseReanimatedModal = false,
99+
shouldDisableBottomSafeAreaPadding = false,
97100
}: BaseModalProps,
98101
ref: React.ForwardedRef<View>,
99102
) {
@@ -110,6 +113,9 @@ function BaseModal(
110113
const sidePanelStyle = shouldApplySidePanelOffset && !isSmallScreenWidth ? {paddingRight: sidePanelOffset.current} : undefined;
111114
const keyboardStateContextValue = useKeyboardState();
112115

116+
const [modalOverlapsWithTopSafeArea, setModalOverlapsWithTopSafeArea] = useState(false);
117+
const [modalHeight, setModalHeight] = useState(0);
118+
113119
const insets = useSafeAreaInsets();
114120

115121
const isVisibleRef = useRef(isVisible);
@@ -203,6 +209,30 @@ function BaseModal(
203209
ComposerFocusManager.setReadyToFocus(uniqueModalId);
204210
};
205211

212+
// Checks if modal overlaps with topSafeArea. Used to offset tall bottom docked modals with keyboard.
213+
useEffect(() => {
214+
if (type !== CONST.MODAL.MODAL_TYPE.BOTTOM_DOCKED || getPlatform() === CONST.PLATFORM.WEB) {
215+
return;
216+
}
217+
const {paddingTop} = StyleUtils.getPlatformSafeAreaPadding(insets);
218+
const availableHeight = windowHeight - modalHeight - keyboardStateContextValue.keyboardActiveHeight - paddingTop;
219+
setModalOverlapsWithTopSafeArea((keyboardStateContextValue.isKeyboardAnimatingRef.current || keyboardStateContextValue.isKeyboardActive) && Math.floor(availableHeight) <= 0);
220+
}, [
221+
StyleUtils,
222+
insets,
223+
keyboardStateContextValue.isKeyboardActive,
224+
keyboardStateContextValue.isKeyboardAnimatingRef,
225+
keyboardStateContextValue.keyboardActiveHeight,
226+
modalHeight,
227+
type,
228+
windowHeight,
229+
modalOverlapsWithTopSafeArea,
230+
]);
231+
232+
const onViewLayout = (e: LayoutChangeEvent) => {
233+
setModalHeight(e.nativeEvent.layout.height);
234+
};
235+
206236
const {
207237
modalStyle,
208238
modalContainerStyle,
@@ -226,8 +256,24 @@ function BaseModal(
226256
innerContainerStyle,
227257
outerStyle,
228258
shouldUseModalPaddingStyle,
259+
{
260+
modalOverlapsWithTopSafeArea,
261+
shouldDisableBottomSafeAreaPadding,
262+
},
229263
),
230-
[StyleUtils, type, windowWidth, windowHeight, isSmallScreenWidth, popoverAnchorPosition, innerContainerStyle, outerStyle, shouldUseModalPaddingStyle],
264+
[
265+
StyleUtils,
266+
type,
267+
windowWidth,
268+
windowHeight,
269+
isSmallScreenWidth,
270+
popoverAnchorPosition,
271+
innerContainerStyle,
272+
outerStyle,
273+
shouldUseModalPaddingStyle,
274+
modalOverlapsWithTopSafeArea,
275+
shouldDisableBottomSafeAreaPadding,
276+
],
231277
);
232278

233279
const modalPaddingStyles = useMemo(() => {
@@ -350,6 +396,7 @@ function BaseModal(
350396
shouldPreventScroll={shouldPreventScrollOnFocus}
351397
>
352398
<View
399+
onLayout={onViewLayout}
353400
style={[styles.defaultModalContainer, modalContainerStyle, modalPaddingStyles, !isVisible && styles.pointerEventsNone]}
354401
ref={ref}
355402
>

src/components/Modal/types.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,11 @@ type BaseModalProps = Partial<ReactNativeModalProps> &
124124
*/
125125
shouldApplySidePanelOffset?: boolean;
126126

127+
/**
128+
* Disables the bottom safe area padding in the modal. Used in for scrollable FeatureTrainingModal.
129+
*/
130+
shouldDisableBottomSafeAreaPadding?: boolean;
131+
127132
/**
128133
* Whether the modal should use ReanimatedModal implementation.
129134
*/

src/components/withKeyboardState.tsx

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type {MutableRefObject, ReactElement} from 'react';
1+
import type {ReactElement, RefObject} from 'react';
22
import React, {createContext, useCallback, useEffect, useMemo, useRef, useState} from 'react';
33
import {KeyboardEvents, useKeyboardHandler} from 'react-native-keyboard-controller';
44
import {runOnJS} from 'react-native-reanimated';
@@ -16,20 +16,25 @@ type KeyboardStateContextValue = {
1616
/** Height of the keyboard in pixels */
1717
keyboardHeight: number;
1818

19+
/** Future or present height of the keyboard in pixels. Available together with isKeyboardActive. */
20+
keyboardActiveHeight: number;
21+
1922
/** Ref to check if the keyboard is animating */
20-
isKeyboardAnimatingRef: MutableRefObject<boolean>;
23+
isKeyboardAnimatingRef: RefObject<boolean>;
2124
};
2225

2326
const KeyboardStateContext = createContext<KeyboardStateContextValue>({
2427
isKeyboardShown: false,
2528
isKeyboardActive: false,
2629
keyboardHeight: 0,
30+
keyboardActiveHeight: 0,
2731
isKeyboardAnimatingRef: {current: false},
2832
});
2933

3034
function KeyboardStateProvider({children}: ChildrenProps): ReactElement | null {
3135
const {bottom} = useSafeAreaInsets();
3236
const [keyboardHeight, setKeyboardHeight] = useState(0);
37+
const [keyboardActiveHeight, setKeyboardActiveHeight] = useState(0);
3338
const isKeyboardAnimatingRef = useRef(false);
3439
const [isKeyboardActive, setIsKeyboardActive] = useState(false);
3540

@@ -42,11 +47,13 @@ function KeyboardStateProvider({children}: ChildrenProps): ReactElement | null {
4247
setKeyboardHeight(0);
4348
setIsKeyboardActive(false);
4449
});
45-
const keyboardWillShowListener = KeyboardEvents.addListener('keyboardWillShow', () => {
50+
const keyboardWillShowListener = KeyboardEvents.addListener('keyboardWillShow', (e) => {
4651
setIsKeyboardActive(true);
52+
setKeyboardActiveHeight(e.height);
4753
});
4854
const keyboardWillHideListener = KeyboardEvents.addListener('keyboardWillHide', () => {
4955
setIsKeyboardActive(false);
56+
setKeyboardActiveHeight(0);
5057
});
5158

5259
return () => {
@@ -80,11 +87,12 @@ function KeyboardStateProvider({children}: ChildrenProps): ReactElement | null {
8087
const contextValue = useMemo(
8188
() => ({
8289
keyboardHeight,
90+
keyboardActiveHeight,
8391
isKeyboardShown: keyboardHeight !== 0,
8492
isKeyboardAnimatingRef,
8593
isKeyboardActive,
8694
}),
87-
[isKeyboardActive, keyboardHeight],
95+
[isKeyboardActive, keyboardActiveHeight, keyboardHeight],
8896
);
8997
return <KeyboardStateContext.Provider value={contextValue}>{children}</KeyboardStateContext.Provider>;
9098
}

src/styles/utils/generators/ModalStyleUtils.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type {ViewStyle} from 'react-native';
22
import type {ModalProps} from 'react-native-modal';
3+
import {isMobileSafari} from '@libs/Browser';
34
import type {ThemeStyles} from '@styles/index';
45
import variables from '@styles/variables';
56
import CONST from '@src/CONST';
@@ -42,11 +43,23 @@ type GetModalStylesStyleUtil = {
4243
innerContainerStyle?: ViewStyle,
4344
outerStyle?: ViewStyle,
4445
shouldUseModalPaddingStyle?: boolean,
46+
safeAreaOptions?: {
47+
shouldDisableBottomSafeAreaPadding?: boolean;
48+
modalOverlapsWithTopSafeArea?: boolean;
49+
},
4550
) => GetModalStyles;
4651
};
4752

4853
const createModalStyleUtils: StyleUtilGenerator<GetModalStylesStyleUtil> = ({theme, styles}) => ({
49-
getModalStyles: (type, windowDimensions, popoverAnchorPosition = {}, innerContainerStyle = {}, outerStyle = {}, shouldUseModalPaddingStyle = true): GetModalStyles => {
54+
getModalStyles: (
55+
type,
56+
windowDimensions,
57+
popoverAnchorPosition = {},
58+
innerContainerStyle = {},
59+
outerStyle = {},
60+
shouldUseModalPaddingStyle = true,
61+
safeAreaOptions = {modalOverlapsWithTopSafeArea: false, shouldDisableBottomSafeAreaPadding: false},
62+
): GetModalStyles => {
5063
const {windowWidth, isSmallScreenWidth} = windowDimensions;
5164

5265
let modalStyle: GetModalStyles['modalStyle'] = {
@@ -218,14 +231,19 @@ const createModalStyleUtils: StyleUtilGenerator<GetModalStylesStyleUtil> = ({the
218231
justifyContent: 'center',
219232
overflow: 'hidden',
220233
boxShadow: theme.shadow,
234+
// Workaround for Safari not supporting interactive-widget=resizes-content, sets max height of a container modal.
235+
// This allows better scrolling experience after keyboard shows for modals with input, that are larger than remaining screen height.
236+
// More info https://github.com/Expensify/App/pull/62799#issuecomment-2943136220.
237+
...(isMobileSafari() ? {maxHeight: `${windowDimensions.windowHeight}px`} : {}),
221238
};
222239

223240
if (shouldUseModalPaddingStyle) {
224241
modalContainerStyle.paddingTop = variables.componentBorderRadiusLarge;
225242
modalContainerStyle.paddingBottom = variables.componentBorderRadiusLarge;
226243
}
227244

228-
shouldAddBottomSafeAreaPadding = true;
245+
shouldAddBottomSafeAreaPadding = !safeAreaOptions?.shouldDisableBottomSafeAreaPadding;
246+
shouldAddTopSafeAreaMargin = !!safeAreaOptions?.modalOverlapsWithTopSafeArea;
229247
swipeDirection = undefined;
230248
animationIn = 'slideInUp';
231249
animationOut = 'slideOutDown';

src/styles/utils/index.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import type {OnyxEntry} from 'react-native-onyx';
44
import type {EdgeInsets} from 'react-native-safe-area-context';
55
import type {ValueOf} from 'type-fest';
66
import type ImageSVGProps from '@components/ImageSVG/types';
7-
import {isMobile} from '@libs/Browser';
7+
import {isMobile, isMobileChrome} from '@libs/Browser';
88
import getPlatform from '@libs/getPlatform';
99
import {hashText} from '@libs/UserUtils';
1010
// eslint-disable-next-line no-restricted-imports
@@ -1832,6 +1832,25 @@ const createStyleUtils = (theme: ThemeColors, styles: ThemeStyles) => ({
18321832
styleObj[key] = null;
18331833
return styleObj;
18341834
}, {} as Nullable<K>) as K,
1835+
getScrollableFeatureTrainingModalStyles: (
1836+
insets: EdgeInsets,
1837+
): {
1838+
style?: ViewStyle;
1839+
containerStyle?: ViewStyle;
1840+
} => {
1841+
const {paddingBottom: safeAreaPaddingBottom} = getPlatformSafeAreaPadding(insets);
1842+
1843+
const paddingBottom = getCombinedSpacing(styles.pb5.paddingBottom, safeAreaPaddingBottom, true);
1844+
// Forces scroll on modal when keyboard is open and the modal larger than remaining screen height.
1845+
return {
1846+
style: isMobileChrome()
1847+
? {
1848+
maxHeight: '100dvh',
1849+
}
1850+
: {},
1851+
containerStyle: {paddingBottom},
1852+
};
1853+
},
18351854
});
18361855

18371856
type StyleUtilsType = ReturnType<typeof createStyleUtils>;

0 commit comments

Comments
 (0)