Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 4 additions & 17 deletions src/components/Modal/ReanimatedModal/Backdrop/index.web.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,14 @@
import React, {useMemo} from 'react';
import {View} from 'react-native';
import Animated, {Easing, Keyframe} from 'react-native-reanimated';
import Animated, {Keyframe} from 'react-native-reanimated';
import type {BackdropProps} from '@components/Modal/ReanimatedModal/types';
import {getModalInAnimation, getModalOutAnimation} from '@components/Modal/ReanimatedModal/utils';
import {PressableWithoutFeedback} from '@components/Pressable';
import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
import variables from '@styles/variables';
import CONST from '@src/CONST';

const easing = Easing.bezier(0.76, 0.0, 0.24, 1.0).factory();

function Backdrop({
style,
customBackdrop,
Expand All @@ -23,25 +22,13 @@ function Backdrop({
const {translate} = useLocalize();

const Entering = useMemo(() => {
const FadeIn = new Keyframe({
from: {opacity: 0},
to: {
opacity: 0.72,
easing,
},
});
const FadeIn = new Keyframe(getModalInAnimation('fadeIn'));

return FadeIn.duration(animationInTiming);
}, [animationInTiming]);

const Exiting = useMemo(() => {
const FadeOut = new Keyframe({
from: {opacity: 0.72},
to: {
opacity: 0,
easing,
},
});
const FadeOut = new Keyframe(getModalOutAnimation('fadeOut'));

return FadeOut.duration(animationOutTiming);
}, [animationOutTiming]);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import type {PropsWithChildren} from 'react';
import React, {useMemo} from 'react';
import type {GestureStateChangeEvent, GestureType, PanGestureHandlerEventPayload} from 'react-native-gesture-handler';
import {Gesture, GestureDetector} from 'react-native-gesture-handler';
import {runOnJS, useSharedValue} from 'react-native-reanimated';
import type {GestureHandlerProps, SwipeDirection} from '@components/Modal/ReanimatedModal/types';
import CONST from '@src/CONST';

function hasSwipeEnded(
e: GestureStateChangeEvent<PanGestureHandlerEventPayload>,
initialPosition: {x: number; y: number},
swipeThreshold: number,
swipeDirection?: SwipeDirection | SwipeDirection[],
onSwipeComplete?: () => void,
) {
'worklet';

// eslint-disable-next-line @typescript-eslint/prefer-optional-chain
if (!swipeDirection || !swipeDirection?.length || !onSwipeComplete) {
return;
}
const directions = Array.isArray(swipeDirection) ? swipeDirection : [swipeDirection];

for (const direction of directions) {
switch (direction) {
case CONST.SWIPE_DIRECTION.RIGHT:
if (e.translationX - initialPosition.x > swipeThreshold) {
runOnJS(onSwipeComplete)();
}
break;
case CONST.SWIPE_DIRECTION.LEFT:
if (initialPosition.x - e.translationX > swipeThreshold) {
runOnJS(onSwipeComplete)();
}
break;
case CONST.SWIPE_DIRECTION.UP:
if (initialPosition.y - e.translationY > swipeThreshold) {
runOnJS(onSwipeComplete)();
}
break;
case CONST.SWIPE_DIRECTION.DOWN:
if (e.translationY - initialPosition.y > swipeThreshold) {
runOnJS(onSwipeComplete)();
}
break;
default:
throw new Error('Unknown swipe direction');
}
}
}

function GestureHandler({swipeDirection, onSwipeComplete, swipeThreshold = 100, children}: PropsWithChildren<GestureHandlerProps>) {
const initialTranslationX = useSharedValue(0);
const initialTranslationY = useSharedValue(0);
const panGesture: GestureType = useMemo(
() =>
Gesture.Pan()
.onStart((e) => {
initialTranslationX.set(e.translationX);
initialTranslationY.set(e.translationY);
})
.onEnd((e) => {
hasSwipeEnded(e, {x: initialTranslationX.get(), y: initialTranslationY.get()}, swipeThreshold, swipeDirection, onSwipeComplete);
}),
[initialTranslationX, initialTranslationY, onSwipeComplete, swipeDirection, swipeThreshold],
);

// eslint-disable-next-line @typescript-eslint/prefer-optional-chain
if (!swipeDirection || !swipeDirection?.length || !onSwipeComplete) {
return children;
}

return <GestureDetector gesture={panGesture}>{children}</GestureDetector>;
}

GestureHandler.displayName = 'GestureHandler';

export default GestureHandler;
22 changes: 16 additions & 6 deletions src/components/Modal/ReanimatedModal/Container/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type {ContainerProps} from '@components/Modal/ReanimatedModal/types';
import {getModalInAnimation, getModalOutAnimation} from '@components/Modal/ReanimatedModal/utils';
import useThemeStyles from '@hooks/useThemeStyles';
import CONST from '@src/CONST';
import GestureHandler from './GestureHandler';

function Container({
style,
Expand All @@ -16,6 +17,9 @@ function Container({
animationIn,
animationOut,
type,
onSwipeComplete,
swipeDirection,
swipeThreshold = 100,
...props
}: Partial<ReanimatedModalProps> & ContainerProps) {
const styles = useThemeStyles();
Expand Down Expand Up @@ -46,13 +50,19 @@ function Container({
// eslint-disable-next-line react/jsx-props-no-spreading
{...props}
>
<Animated.View
style={[styles.modalAnimatedContainer, type !== CONST.MODAL.MODAL_TYPE.BOTTOM_DOCKED && styles.flex1]}
entering={Entering}
exiting={Exiting}
<GestureHandler
swipeThreshold={swipeThreshold}
swipeDirection={swipeDirection}
onSwipeComplete={onSwipeComplete}
>
{props.children}
</Animated.View>
<Animated.View
style={[styles.modalAnimatedContainer, type !== CONST.MODAL.MODAL_TYPE.BOTTOM_DOCKED && styles.flex1]}
entering={Entering}
exiting={Exiting}
>
{props.children}
</Animated.View>
</GestureHandler>
</View>
);
}
Expand Down
5 changes: 5 additions & 0 deletions src/components/Modal/ReanimatedModal/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ function ReanimatedModal({
style,
type,
statusBarTranslucent = false,
onSwipeComplete,
swipeDirection,
swipeThreshold,
...props
}: ReanimatedModalProps) {
const [isVisibleState, setIsVisibleState] = useState(isVisible);
Expand Down Expand Up @@ -148,6 +151,8 @@ function ReanimatedModal({
animationOut={animationOut as AnimationOutType}
style={style}
type={type}
onSwipeComplete={onSwipeComplete}
swipeDirection={swipeDirection}
>
{children}
</Container>
Expand Down
18 changes: 16 additions & 2 deletions src/components/Modal/ReanimatedModal/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,27 @@ type GestureProps = {
deviceWidth?: number | null;
};

type SwipeDirection = ValueOf<typeof CONST.SWIPE_DIRECTION>;

type GestureHandlerProps = {
/** Callback to be fired on swipe gesture. */
onSwipeComplete?: () => void;

/** Threshold for swipe gesture. */
swipeThreshold: number;

/** Threshold for swipe gesture. */
swipeDirection?: SwipeDirection | SwipeDirection[];
};

type AnimationInType = 'fadeIn' | 'slideInUp' | 'slideInRight';
type AnimationOutType = 'fadeOut' | 'slideOutDown' | 'slideOutRight';

type AnimationOut = ValueOf<Pick<ReactNativeModalProps, 'animationOut'>>;

type ReanimatedModalProps = ViewProps &
GestureProps & {
GestureProps &
GestureHandlerProps & {
/** Content inside the modal */
children: ReactNode;

Expand Down Expand Up @@ -168,4 +182,4 @@ type ContainerProps = {
};

export default ReanimatedModalProps;
export type {BackdropProps, ContainerProps, AnimationOut, AnimationInType, AnimationOutType};
export type {BackdropProps, ContainerProps, GestureHandlerProps, AnimationOut, AnimationInType, AnimationOutType, SwipeDirection};
115 changes: 59 additions & 56 deletions src/components/Search/SearchRouter/SearchRouter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {deepEqual} from 'fast-equals';
import React, {forwardRef, useCallback, useEffect, useRef, useState} from 'react';
import type {TextInputProps} from 'react-native';
import {InteractionManager, Keyboard, View} from 'react-native';
import {Gesture, GestureDetector, GestureHandlerRootView} from 'react-native-gesture-handler';
import type {ValueOf} from 'type-fest';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
import * as Expensicons from '@components/Icon/Expensicons';
Expand Down Expand Up @@ -428,62 +429,64 @@ function SearchRouter({onRouterClose, shouldHideInputCaret, isSearchRouterDispla
const isRecentSearchesDataLoaded = !isLoadingOnyxValue(recentSearchesMetadata);

return (
<View
style={[styles.flex1, modalWidth, styles.h100, !shouldUseNarrowLayout && styles.mh85vh]}
testID={SearchRouter.displayName}
ref={ref}
onStartShouldSetResponder={() => true}
onResponderRelease={Keyboard.dismiss}
>
{shouldUseNarrowLayout && (
<HeaderWithBackButton
title={translate('common.search')}
onBackButtonPress={() => onRouterClose()}
shouldDisplayHelpButton={false}
/>
)}
{isRecentSearchesDataLoaded && (
<>
<SearchInputSelectionWrapper
value={textInputValue}
isFullWidth={shouldUseNarrowLayout}
onSearchQueryChange={onSearchQueryChange}
onSubmit={() => {
const focusedOption = listRef.current?.getFocusedOption();

if (!focusedOption) {
submitSearch(textInputValue);
return;
}

onListItemPress(focusedOption);
}}
caretHidden={shouldHideInputCaret}
autocompleteListRef={listRef}
shouldShowOfflineMessage
wrapperStyle={{...styles.border, ...styles.alignItemsCenter}}
outerWrapperStyle={[shouldUseNarrowLayout ? styles.mv3 : styles.mv2, shouldUseNarrowLayout ? styles.mh5 : styles.mh2]}
wrapperFocusedStyle={styles.borderColorFocus}
isSearchingForReports={isSearchingForReports}
selection={selection}
substitutionMap={autocompleteSubstitutions}
ref={textInputRef}
/>
<SearchAutocompleteList
autocompleteQueryValue={autocompleteQueryValue || textInputValue}
handleSearch={searchInServer}
searchQueryItem={searchQueryItem}
getAdditionalSections={getAdditionalSections}
onListItemPress={onListItemPress}
setTextQuery={setTextAndUpdateSelection}
updateAutocompleteSubstitutions={updateAutocompleteSubstitutions}
onHighlightFirstItem={() => listRef.current?.updateAndScrollToFocusedIndex(1)}
ref={listRef}
textInputRef={textInputRef}
/>
</>
)}
</View>
<GestureHandlerRootView style={{flex: 1}}>
<GestureDetector gesture={Gesture.Tap().runOnJS(true).onFinalize(Keyboard.dismiss)}>
<View
style={[styles.flex1, modalWidth, styles.h100, !shouldUseNarrowLayout && styles.mh85vh]}
testID={SearchRouter.displayName}
ref={ref}
>
{shouldUseNarrowLayout && (
<HeaderWithBackButton
title={translate('common.search')}
onBackButtonPress={() => onRouterClose()}
shouldDisplayHelpButton={false}
/>
)}
{isRecentSearchesDataLoaded && (
<>
<SearchInputSelectionWrapper
value={textInputValue}
isFullWidth={shouldUseNarrowLayout}
onSearchQueryChange={onSearchQueryChange}
onSubmit={() => {
const focusedOption = listRef.current?.getFocusedOption();

if (!focusedOption) {
submitSearch(textInputValue);
return;
}

onListItemPress(focusedOption);
}}
caretHidden={shouldHideInputCaret}
autocompleteListRef={listRef}
shouldShowOfflineMessage
wrapperStyle={{...styles.border, ...styles.alignItemsCenter}}
outerWrapperStyle={[shouldUseNarrowLayout ? styles.mv3 : styles.mv2, shouldUseNarrowLayout ? styles.mh5 : styles.mh2]}
wrapperFocusedStyle={styles.borderColorFocus}
isSearchingForReports={isSearchingForReports}
selection={selection}
substitutionMap={autocompleteSubstitutions}
ref={textInputRef}
/>
<SearchAutocompleteList
autocompleteQueryValue={autocompleteQueryValue || textInputValue}
handleSearch={searchInServer}
searchQueryItem={searchQueryItem}
getAdditionalSections={getAdditionalSections}
onListItemPress={onListItemPress}
setTextQuery={setTextAndUpdateSelection}
updateAutocompleteSubstitutions={updateAutocompleteSubstitutions}
onHighlightFirstItem={() => listRef.current?.updateAndScrollToFocusedIndex(1)}
ref={listRef}
textInputRef={textInputRef}
/>
</>
)}
</View>
</GestureDetector>
</GestureHandlerRootView>
);
}

Expand Down
3 changes: 2 additions & 1 deletion src/components/Search/SearchRouter/SearchRouterModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ function SearchRouterModal() {
const [shouldHideInputCaret, setShouldHideInputCaret] = useState(isMobileWebIOS);

const modalType = shouldUseNarrowLayout ? CONST.MODAL.MODAL_TYPE.CENTERED_SWIPEABLE_TO_RIGHT : CONST.MODAL.MODAL_TYPE.POPOVER;

// For now were only enabling shouldUseReanimatedModal narrow layouts. On wide ones it's a popover and it is not migrated yet.
return (
<Modal
type={modalType}
Expand All @@ -35,6 +35,7 @@ function SearchRouterModal() {
onModalShow={() => setShouldHideInputCaret(false)}
shouldApplySidePanelOffset={!shouldUseNarrowLayout}
enableEdgeToEdgeBottomSafeAreaPadding
shouldUseReanimatedModal={shouldUseNarrowLayout}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Coming from the #66065 checklist, this option doesn’t work on mWeb, so we have temporarily disabled it on mobile web.

>
<ScreenWrapperContainer
testID={SearchRouterModal.displayName}
Expand Down
Loading