diff --git a/src/components/Modal/ReanimatedModal/Backdrop/index.web.tsx b/src/components/Modal/ReanimatedModal/Backdrop/index.web.tsx index 253da05ed50b..10f4f54c9263 100644 --- a/src/components/Modal/ReanimatedModal/Backdrop/index.web.tsx +++ b/src/components/Modal/ReanimatedModal/Backdrop/index.web.tsx @@ -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, @@ -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]); diff --git a/src/components/Modal/ReanimatedModal/Container/GestureHandler.tsx b/src/components/Modal/ReanimatedModal/Container/GestureHandler.tsx new file mode 100644 index 000000000000..a03aa2582e71 --- /dev/null +++ b/src/components/Modal/ReanimatedModal/Container/GestureHandler.tsx @@ -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, + 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) { + 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 {children}; +} + +GestureHandler.displayName = 'GestureHandler'; + +export default GestureHandler; diff --git a/src/components/Modal/ReanimatedModal/Container/index.tsx b/src/components/Modal/ReanimatedModal/Container/index.tsx index 1ab545e50c6b..d9c50b8d9b6d 100644 --- a/src/components/Modal/ReanimatedModal/Container/index.tsx +++ b/src/components/Modal/ReanimatedModal/Container/index.tsx @@ -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, @@ -16,6 +17,9 @@ function Container({ animationIn, animationOut, type, + onSwipeComplete, + swipeDirection, + swipeThreshold = 100, ...props }: Partial & ContainerProps) { const styles = useThemeStyles(); @@ -46,13 +50,19 @@ function Container({ // eslint-disable-next-line react/jsx-props-no-spreading {...props} > - - {props.children} - + + {props.children} + + ); } diff --git a/src/components/Modal/ReanimatedModal/index.tsx b/src/components/Modal/ReanimatedModal/index.tsx index ab873254fc29..db0c3b8b22c6 100644 --- a/src/components/Modal/ReanimatedModal/index.tsx +++ b/src/components/Modal/ReanimatedModal/index.tsx @@ -37,6 +37,9 @@ function ReanimatedModal({ style, type, statusBarTranslucent = false, + onSwipeComplete, + swipeDirection, + swipeThreshold, ...props }: ReanimatedModalProps) { const [isVisibleState, setIsVisibleState] = useState(isVisible); @@ -148,6 +151,8 @@ function ReanimatedModal({ animationOut={animationOut as AnimationOutType} style={style} type={type} + onSwipeComplete={onSwipeComplete} + swipeDirection={swipeDirection} > {children} diff --git a/src/components/Modal/ReanimatedModal/types.ts b/src/components/Modal/ReanimatedModal/types.ts index cf03deb66340..dbcc19ae82df 100644 --- a/src/components/Modal/ReanimatedModal/types.ts +++ b/src/components/Modal/ReanimatedModal/types.ts @@ -13,13 +13,27 @@ type GestureProps = { deviceWidth?: number | null; }; +type SwipeDirection = ValueOf; + +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>; type ReanimatedModalProps = ViewProps & - GestureProps & { + GestureProps & + GestureHandlerProps & { /** Content inside the modal */ children: ReactNode; @@ -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}; diff --git a/src/components/Search/SearchRouter/SearchRouter.tsx b/src/components/Search/SearchRouter/SearchRouter.tsx index ae855804beb9..131d54416d95 100644 --- a/src/components/Search/SearchRouter/SearchRouter.tsx +++ b/src/components/Search/SearchRouter/SearchRouter.tsx @@ -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'; @@ -428,62 +429,64 @@ function SearchRouter({onRouterClose, shouldHideInputCaret, isSearchRouterDispla const isRecentSearchesDataLoaded = !isLoadingOnyxValue(recentSearchesMetadata); return ( - true} - onResponderRelease={Keyboard.dismiss} - > - {shouldUseNarrowLayout && ( - onRouterClose()} - shouldDisplayHelpButton={false} - /> - )} - {isRecentSearchesDataLoaded && ( - <> - { - 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} - /> - listRef.current?.updateAndScrollToFocusedIndex(1)} - ref={listRef} - textInputRef={textInputRef} - /> - - )} - + + + + {shouldUseNarrowLayout && ( + onRouterClose()} + shouldDisplayHelpButton={false} + /> + )} + {isRecentSearchesDataLoaded && ( + <> + { + 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} + /> + listRef.current?.updateAndScrollToFocusedIndex(1)} + ref={listRef} + textInputRef={textInputRef} + /> + + )} + + + ); } diff --git a/src/components/Search/SearchRouter/SearchRouterModal.tsx b/src/components/Search/SearchRouter/SearchRouterModal.tsx index c4a6b5d128bf..ef93bb0188f7 100644 --- a/src/components/Search/SearchRouter/SearchRouterModal.tsx +++ b/src/components/Search/SearchRouter/SearchRouterModal.tsx @@ -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 ( setShouldHideInputCaret(false)} shouldApplySidePanelOffset={!shouldUseNarrowLayout} enableEdgeToEdgeBottomSafeAreaPadding + shouldUseReanimatedModal={shouldUseNarrowLayout} >