Skip to content

Commit 10af833

Browse files
authored
Merge pull request Expensify#66160 from software-mansion-labs/update-mobile-test-drive-for-better-scroll
Improve native top spacing for bottom docked modals when keyboard shows v2
2 parents e26acfb + c839ccf commit 10af833

6 files changed

Lines changed: 134 additions & 13 deletions

File tree

src/components/FeatureTrainingModal.tsx

Lines changed: 43 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
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';
9+
import useKeyboardState from '@hooks/useKeyboardState';
810
import useLocalize from '@hooks/useLocalize';
911
import useNetwork from '@hooks/useNetwork';
1012
import useResponsiveLayout from '@hooks/useResponsiveLayout';
13+
import useSafeAreaInsets from '@hooks/useSafeAreaInsets';
1114
import useStyleUtils from '@hooks/useStyleUtils';
1215
import useThemeStyles from '@hooks/useThemeStyles';
1316
import {parseFSAttributes} from '@libs/Fullstory';
@@ -209,6 +212,11 @@ function FeatureTrainingModal({
209212
const {shouldUseNarrowLayout} = useResponsiveLayout();
210213
const {isOffline} = useNetwork();
211214
const hasHelpButtonBeenPressed = useRef(false);
215+
const scrollViewRef = useRef<RNScrollView>(null);
216+
const [containerHeight, setContainerHeight] = useState(0);
217+
const [contentHeight, setContentHeight] = useState(0);
218+
const insets = useSafeAreaInsets();
219+
const {isKeyboardActive} = useKeyboardState();
212220

213221
useEffect(() => {
214222
InteractionManager.runAfterInteractions(() => {
@@ -355,8 +363,21 @@ function FeatureTrainingModal({
355363
*/
356364
useLayoutEffect(parseFSAttributes, []);
357365

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

376+
const wrapperStyles = useMemo(
377+
() => (shouldUseScrollView ? StyleUtils.getScrollableFeatureTrainingModalStyles(insets, isKeyboardActive) : {}),
378+
[shouldUseScrollView, StyleUtils, insets, isKeyboardActive],
379+
);
380+
360381
return (
361382
<Modal
362383
avoidKeyboard={avoidKeyboard}
@@ -384,11 +405,16 @@ function FeatureTrainingModal({
384405
onHelp();
385406
}}
386407
shouldUseReanimatedModal
408+
shouldDisableBottomSafeAreaPadding={shouldUseScrollView}
387409
>
388410
<Wrapper
389-
style={[styles.mh100, onboardingIsMediumOrLargerScreenWidth && StyleUtils.getWidthStyle(width)]}
390-
contentContainerStyle={shouldUseScrollView ? styles.pb5 : undefined}
411+
scrollsToTop={false}
412+
style={[styles.mh100, onboardingIsMediumOrLargerScreenWidth && StyleUtils.getWidthStyle(width), wrapperStyles.style]}
413+
contentContainerStyle={wrapperStyles.containerStyle}
391414
keyboardShouldPersistTaps={shouldUseScrollView ? 'handled' : undefined}
415+
ref={shouldUseScrollView ? scrollViewRef : undefined}
416+
onLayout={shouldUseScrollView ? (e: LayoutChangeEvent) => setContainerHeight(e.nativeEvent.layout.height) : undefined}
417+
onContentSizeChange={shouldUseScrollView ? (_w: number, h: number) => setContentHeight(h) : undefined}
392418
fsClass={CONST.FULL_STORY.UNMASK}
393419
testID={CONST.FULL_STORY.UNMASK}
394420
>
@@ -397,9 +423,20 @@ function FeatureTrainingModal({
397423
</View>
398424
<View style={[styles.mt5, styles.mh5, contentOuterContainerStyles]}>
399425
{!!title && !!description && (
400-
<View style={[onboardingIsMediumOrLargerScreenWidth ? [styles.gap1, styles.mb8] : [styles.mb10], contentInnerContainerStyles]}>
426+
<View
427+
style={[
428+
onboardingIsMediumOrLargerScreenWidth ? [styles.gap1, styles.mb8] : [shouldRenderHTMLDescription ? styles.mb5 : styles.mb10],
429+
contentInnerContainerStyles,
430+
]}
431+
>
401432
{typeof title === 'string' ? <Text style={[styles.textHeadlineH1, titleStyles]}>{title}</Text> : title}
402-
{shouldRenderHTMLDescription ? <RenderHTML html={description} /> : <Text style={styles.textSupporting}>{description}</Text>}
433+
{shouldRenderHTMLDescription ? (
434+
<View style={styles.mb2}>
435+
<RenderHTML html={description} />
436+
</View>
437+
) : (
438+
<Text style={styles.textSupporting}>{description}</Text>
439+
)}
403440
{secondaryDescription.length > 0 && <Text style={[styles.textSupporting, styles.mt4]}>{secondaryDescription}</Text>}
404441
{children}
405442
</View>

src/components/Modal/BaseModal.tsx

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import React, {forwardRef, useCallback, useContext, useEffect, useMemo, useRef} from 'react';
1+
import React, {forwardRef, useCallback, useContext, useEffect, useMemo, useRef, useState} from 'react';
2+
import type {LayoutChangeEvent} from 'react-native';
23
// Animated required for side panel navigation
34
// eslint-disable-next-line no-restricted-imports
45
import {Animated, View} from 'react-native';
@@ -19,6 +20,7 @@ import useTheme from '@hooks/useTheme';
1920
import useThemeStyles from '@hooks/useThemeStyles';
2021
import useWindowDimensions from '@hooks/useWindowDimensions';
2122
import ComposerFocusManager from '@libs/ComposerFocusManager';
23+
import {canUseTouchScreen as canUseTouchScreenCheck} from '@libs/DeviceCapabilities';
2224
import NarrowPaneContext from '@libs/Navigation/AppNavigator/Navigators/NarrowPaneContext';
2325
import Overlay from '@libs/Navigation/AppNavigator/Navigators/Overlay';
2426
import Navigation from '@libs/Navigation/Navigation';
@@ -97,6 +99,7 @@ function BaseModal(
9799
hasBackdrop,
98100
backdropOpacity,
99101
shouldUseReanimatedModal = false,
102+
shouldDisableBottomSafeAreaPadding = false,
100103
}: BaseModalProps,
101104
ref: React.ForwardedRef<View>,
102105
) {
@@ -107,6 +110,7 @@ function BaseModal(
107110
const StyleUtils = useStyleUtils();
108111
const {windowWidth, windowHeight} = useWindowDimensions();
109112
// We need to use isSmallScreenWidth instead of shouldUseNarrowLayout to apply correct modal width
113+
const canUseTouchScreen = canUseTouchScreenCheck();
110114
// eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth
111115
const {isSmallScreenWidth, shouldUseNarrowLayout} = useResponsiveLayout();
112116
const {sidePanelOffset} = useSidePanel();
@@ -115,6 +119,9 @@ function BaseModal(
115119
shouldUseReanimatedModal && shouldApplySidePanelOffset && !isSmallScreenWidth ? {transform: [{translateX: Animated.multiply(sidePanelOffset.current, -1)}]} : undefined;
116120
const keyboardStateContextValue = useKeyboardState();
117121

122+
const [modalOverlapsWithTopSafeArea, setModalOverlapsWithTopSafeArea] = useState(false);
123+
const [modalHeight, setModalHeight] = useState(0);
124+
118125
const insets = useSafeAreaInsets();
119126

120127
const shouldCallHideModalOnUnmount = useRef(false);
@@ -208,6 +215,32 @@ function BaseModal(
208215
ComposerFocusManager.setReadyToFocus(uniqueModalId);
209216
};
210217

218+
// Checks if modal overlaps with topSafeArea. Used to offset tall bottom docked modals with keyboard.
219+
useEffect(() => {
220+
if (type !== CONST.MODAL.MODAL_TYPE.BOTTOM_DOCKED || !canUseTouchScreen || !isSmallScreenWidth) {
221+
return;
222+
}
223+
const {paddingTop} = StyleUtils.getPlatformSafeAreaPadding(insets);
224+
const availableHeight = windowHeight - modalHeight - keyboardStateContextValue.keyboardActiveHeight - paddingTop;
225+
setModalOverlapsWithTopSafeArea((keyboardStateContextValue.isKeyboardAnimatingRef.current || keyboardStateContextValue.isKeyboardActive) && Math.floor(availableHeight) <= 0);
226+
}, [
227+
StyleUtils,
228+
insets,
229+
keyboardStateContextValue.isKeyboardActive,
230+
keyboardStateContextValue.isKeyboardAnimatingRef,
231+
keyboardStateContextValue.keyboardActiveHeight,
232+
modalHeight,
233+
type,
234+
windowHeight,
235+
modalOverlapsWithTopSafeArea,
236+
canUseTouchScreen,
237+
isSmallScreenWidth,
238+
]);
239+
240+
const onViewLayout = (e: LayoutChangeEvent) => {
241+
setModalHeight(e.nativeEvent.layout.height);
242+
};
243+
211244
const {
212245
modalStyle,
213246
modalContainerStyle,
@@ -232,6 +265,10 @@ function BaseModal(
232265
innerContainerStyle,
233266
outerStyle,
234267
shouldUseModalPaddingStyle,
268+
{
269+
modalOverlapsWithTopSafeArea,
270+
shouldDisableBottomSafeAreaPadding: !!shouldDisableBottomSafeAreaPadding,
271+
},
235272
shouldUseReanimatedModal,
236273
),
237274
[
@@ -245,6 +282,8 @@ function BaseModal(
245282
innerContainerStyle,
246283
outerStyle,
247284
shouldUseModalPaddingStyle,
285+
modalOverlapsWithTopSafeArea,
286+
shouldDisableBottomSafeAreaPadding,
248287
shouldUseReanimatedModal,
249288
],
250289
);
@@ -369,6 +408,7 @@ function BaseModal(
369408
shouldPreventScroll={shouldPreventScrollOnFocus}
370409
>
371410
<Animated.View
411+
onLayout={onViewLayout}
372412
style={[styles.defaultModalContainer, modalContainerStyle, modalPaddingStyles, !isVisible && styles.pointerEventsNone, sidePanelReanimatedStyle]}
373413
ref={ref}
374414
>

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: 12 additions & 1 deletion
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';
@@ -43,6 +44,10 @@ type GetModalStylesStyleUtil = {
4344
innerContainerStyle?: ViewStyle,
4445
outerStyle?: ViewStyle,
4546
shouldUseModalPaddingStyle?: boolean,
47+
safeAreaOptions?: {
48+
shouldDisableBottomSafeAreaPadding?: boolean;
49+
modalOverlapsWithTopSafeArea?: boolean;
50+
},
4651
shouldUseReanimatedModal?: boolean,
4752
) => GetModalStyles;
4853
};
@@ -55,6 +60,7 @@ const createModalStyleUtils: StyleUtilGenerator<GetModalStylesStyleUtil> = ({the
5560
innerContainerStyle = {},
5661
outerStyle = {},
5762
shouldUseModalPaddingStyle = true,
63+
safeAreaOptions = {modalOverlapsWithTopSafeArea: false, shouldDisableBottomSafeAreaPadding: false},
5864
shouldUseReanimatedModal = false,
5965
): GetModalStyles => {
6066
const {windowWidth, isSmallScreenWidth} = windowDimensions;
@@ -228,14 +234,19 @@ const createModalStyleUtils: StyleUtilGenerator<GetModalStylesStyleUtil> = ({the
228234
justifyContent: 'center',
229235
overflow: 'hidden',
230236
boxShadow: theme.shadow,
237+
// Workaround for Safari not supporting interactive-widget=resizes-content, sets max height of a container modal.
238+
// This allows better scrolling experience after keyboard shows for modals with input, that are larger than remaining screen height.
239+
// More info https://github.com/Expensify/App/pull/62799#issuecomment-2943136220.
240+
...(isMobileSafari() ? {maxHeight: `${windowDimensions.windowHeight}px`} : {}),
231241
};
232242

233243
if (shouldUseModalPaddingStyle) {
234244
modalContainerStyle.paddingTop = variables.componentBorderRadiusLarge;
235245
modalContainerStyle.paddingBottom = variables.componentBorderRadiusLarge;
236246
}
237247

238-
shouldAddBottomSafeAreaPadding = true;
248+
shouldAddBottomSafeAreaPadding = !safeAreaOptions?.shouldDisableBottomSafeAreaPadding;
249+
shouldAddTopSafeAreaMargin = !!safeAreaOptions?.modalOverlapsWithTopSafeArea;
239250
swipeDirection = undefined;
240251
animationIn = 'slideInUp';
241252
animationOut = 'slideOutDown';

src/styles/utils/index.ts

Lines changed: 21 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,26 @@ 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+
isKeyboardOpen = false,
1838+
): {
1839+
style?: ViewStyle;
1840+
containerStyle?: ViewStyle;
1841+
} => {
1842+
const {paddingBottom: safeAreaPaddingBottom} = getPlatformSafeAreaPadding(insets);
1843+
// When keyboard is open and we want to disregard safeAreaPaddingBottom.
1844+
const paddingBottom = getCombinedSpacing(styles.pb5.paddingBottom, safeAreaPaddingBottom, !isKeyboardOpen);
1845+
// Forces scroll on modal when keyboard is open and the modal larger than remaining screen height.
1846+
return {
1847+
style: isMobileChrome()
1848+
? {
1849+
maxHeight: '100dvh',
1850+
}
1851+
: {},
1852+
containerStyle: {paddingBottom},
1853+
};
1854+
},
18351855
});
18361856

18371857
type StyleUtilsType = ReturnType<typeof createStyleUtils>;

0 commit comments

Comments
 (0)