From fb2f774a88afe07570860cef9dcb38fdb5b95493 Mon Sep 17 00:00:00 2001 From: Bartosz Grajdek Date: Wed, 6 Nov 2024 11:39:29 +0100 Subject: [PATCH] feat: change modal animation library --- .../Modal/ReactNativeModal/Backdrop.tsx | 75 +++ .../Modal/ReactNativeModal/Container.tsx | 69 +++ .../Modal/ReactNativeModal/Modal.tsx | 437 ++++++++++++++++++ .../Modal/ReactNativeModal/modal.style.ts | 22 + .../Modal/ReactNativeModal/panHandlers.ts | 223 +++++++++ .../Modal/ReactNativeModal/panResponders.ts | 30 ++ .../Modal/ReactNativeModal/types.ts | 71 +++ .../Modal/ReactNativeModal/utils.ts | 41 ++ 8 files changed, 968 insertions(+) create mode 100644 src/components/Modal/ReactNativeModal/Backdrop.tsx create mode 100644 src/components/Modal/ReactNativeModal/Container.tsx create mode 100644 src/components/Modal/ReactNativeModal/Modal.tsx create mode 100644 src/components/Modal/ReactNativeModal/modal.style.ts create mode 100644 src/components/Modal/ReactNativeModal/panHandlers.ts create mode 100644 src/components/Modal/ReactNativeModal/panResponders.ts create mode 100644 src/components/Modal/ReactNativeModal/types.ts create mode 100644 src/components/Modal/ReactNativeModal/utils.ts diff --git a/src/components/Modal/ReactNativeModal/Backdrop.tsx b/src/components/Modal/ReactNativeModal/Backdrop.tsx new file mode 100644 index 000000000000..ea59bc2c65c3 --- /dev/null +++ b/src/components/Modal/ReactNativeModal/Backdrop.tsx @@ -0,0 +1,75 @@ +import type {ReactNode} from 'react'; +import React, {useEffect} from 'react'; +import ReAnimated, {Easing, ReduceMotion, useAnimatedStyle, useSharedValue, withDelay, withTiming} from 'react-native-reanimated'; +import {PressableWithFeedback} from '@components/Pressable'; +import styles from './modal.style'; + +type BackdropProps = { + getDeviceWidth: () => number; + getDeviceHeight: () => number; + backdropColor: string; + hasBackdrop: boolean; + customBackdrop?: ReactNode; + isVisible: boolean; + isTransitioning: boolean; + backdropOpacity: number; + onBackdropPress: () => void; +}; + +function Backdrop({getDeviceWidth, backdropColor, getDeviceHeight, hasBackdrop, customBackdrop, isVisible, isTransitioning, backdropOpacity, onBackdropPress, ...props}: BackdropProps) { + const opacityValue = useSharedValue(0); + + useEffect(() => { + if (!isTransitioning) { + return; + } + opacityValue.value = withDelay(0, withTiming(isVisible ? backdropOpacity : 0, {duration: 300, easing: Easing.inOut(Easing.ease), reduceMotion: ReduceMotion.Never})); + }, [isVisible, isTransitioning, backdropOpacity, opacityValue]); + + const animatedStyle = useAnimatedStyle(() => { + return { + opacity: opacityValue.value, + }; + }); + + if (!hasBackdrop) { + return null; + } + + const hasCustomBackdrop = !!customBackdrop; + + const backdropComputedStyle = [ + { + width: getDeviceWidth(), + height: getDeviceHeight(), + backgroundColor: backdropColor, + }, + ]; + + const BDComponent = ( + + {hasCustomBackdrop && customBackdrop} + + ); + + if (!hasCustomBackdrop) { + return ( + + {BDComponent} + + ); + } + + return BDComponent; +} + +export default Backdrop; diff --git a/src/components/Modal/ReactNativeModal/Container.tsx b/src/components/Modal/ReactNativeModal/Container.tsx new file mode 100644 index 000000000000..76c0fab7a149 --- /dev/null +++ b/src/components/Modal/ReactNativeModal/Container.tsx @@ -0,0 +1,69 @@ +import React, {useEffect, useState} from 'react'; +import type {LayoutChangeEvent} from 'react-native'; +import Animated, {Easing, SlideInDown, useAnimatedRef, useAnimatedStyle, useSharedValue, withDelay, withTiming} from 'react-native-reanimated'; +import useThemeStyles from '@hooks/useThemeStyles'; + +type ContainerProps = Partial & { + isVisible: boolean; + isContainerOpen: boolean; + isTransitioning: boolean; + isHeightCalculated: boolean; + toggleCalculatedHeight: (value: boolean) => void; + deviceHeight?: number | undefined | null; + style?: any; + onLayout?: (event: LayoutChangeEvent) => void; + setMeasuredHeight: (value: number) => void; +}; + +function Container({isVisible, isContainerOpen, isTransitioning, isHeightCalculated, toggleCalculatedHeight, style, onLayout, setMeasuredHeight, testName, ...props}: ContainerProps) { + const styles = useThemeStyles(); + const animatedRef = useAnimatedRef(); + const [measuredHeight, setMH] = useState(0); + + const translateY = useSharedValue(500); + + useEffect(() => { + if (!isTransitioning) { + return; + } + // console.log(testName, ' Container: isVisible & translateY', isVisible, isVisible ? 0 : 500); + // eslint-disable-next-line react-compiler/react-compiler + translateY.value = withDelay(0, withTiming(isVisible ? 0 : 500, {duration: 300, easing: Easing.inOut(Easing.ease)})); + setMH(0); + }, [isVisible, isTransitioning]); + + const animatedStyles = useAnimatedStyle(() => { + return { + transform: [{translateY: translateY.value}], + opacity: !isHeightCalculated || (isVisible !== isContainerOpen && !isTransitioning) ? 0 : 1, + }; + }); + + console.log('props: ', Object.keys(props).join('-')); + return ( + + { + if (!measuredHeight && event.nativeEvent.layout.height && measuredHeight !== event.nativeEvent.layout.height) { + // translateY.value = 500; + setMH(event.nativeEvent.layout.height); + setMeasuredHeight(event.nativeEvent.layout.height); + toggleCalculatedHeight(true); + } + }} + > + {props.children} + + + ); +} + +export default Container; diff --git a/src/components/Modal/ReactNativeModal/Modal.tsx b/src/components/Modal/ReactNativeModal/Modal.tsx new file mode 100644 index 000000000000..bd8a61ab3c92 --- /dev/null +++ b/src/components/Modal/ReactNativeModal/Modal.tsx @@ -0,0 +1,437 @@ +import React, {useCallback, useEffect, useRef, useState} from 'react'; +import type {EmitterSubscription, PanResponderInstance} from 'react-native'; +import {Animated, BackHandler, DeviceEventEmitter, Dimensions, KeyboardAvoidingView, Modal, PanResponder, Platform, View} from 'react-native'; +import Backdrop from './Backdrop'; +import Container from './Container'; +import styles from './modal.style'; +import {calcDistancePercentage, createAnimationEventForSwipe, getAccDistancePerDirection, getSwipingDirection, isSwipeDirectionAllowed, shouldPropagateSwipe} from './panHandlers'; +import type {AnimationEvent, Direction, OrNull} from './types'; +import type ModalProps from './types'; +import {defaultProps} from './utils'; + +function ReactNativeModal(incomingProps: ModalProps) { + const { + animationIn, + animationOut, + animationInTiming, + animationOutTiming, + backdropTransitionInTiming, + backdropTransitionOutTiming, + children, + coverScreen, + customBackdrop, + hasBackdrop, + backdropColor, + backdropOpacity, + useNativeDriver, + isVisible, + onBackButtonPress, + onModalShow, + testID, + style, + avoidKeyboard, + testName, + ...props + } = { + ...defaultProps, + ...incomingProps, + }; + const [measuredHeight, setMeasuredHeight] = useState(0); + const [showContent, setShowContent] = useState(isVisible); + const [isVisibleState, setIsVisibleState] = useState(isVisible); + const [isContainerOpen, setIsContainerOpen] = useState(false); + const [isTransitioning, setIsTransitioning] = useState(false); + const [isHeightCalculated, setIsHeightCalculated] = useState(false); + const [deviceWidth, setDeviceWidth] = useState(Dimensions.get('window').width); + const [deviceHeight, setDeviceHeight] = useState(Dimensions.get('window').height); + const [pan, setPan] = useState(new Animated.ValueXY()); + const [panResponder, setPanResponder] = useState(null); + + const [inSwipeClosingState, setInSwipeClosingState] = useState(false); + let currentSwipingDirection: OrNull = null; + const isSwipeable = !!props.swipeDirection; + + const animEvt = useRef(null); + const didUpdateDimensionsEmitter = useRef(null); + + const getDeviceHeight = () => props.deviceHeight || deviceHeight; + const getDeviceWidth = () => props.deviceWidth || deviceWidth; + + const buildPanResponder = useCallback(() => { + // OPTIMIZE: MAKE SURE THIS WORKS PROPERLY + // Maybe refactor this part and move PanResponder.create into util file?? + setPanResponder( + PanResponder.create({ + onMoveShouldSetPanResponder: (evt, gestureState) => { + // Use propagateSwipe to allow inner content to scroll. See PR: + // https://github.com/react-native-community/react-native-modal/pull/246 + if (!shouldPropagateSwipe(evt, gestureState, props.propagateSwipe)) { + // The number "4" is just a good tradeoff to make the panResponder + // work correctly even when the modal has touchable buttons. + // However, if you want to overwrite this and choose for yourself, + // set panResponderThreshold in the props. + // For reference: + // https://github.com/react-native-community/react-native-modal/pull/197 + const shouldSetPanResponder = Math.abs(gestureState.dx) >= props.panResponderThreshold || Math.abs(gestureState.dy) >= props.panResponderThreshold; + if (shouldSetPanResponder && props.onSwipeStart) { + props.onSwipeStart(gestureState); + } + + currentSwipingDirection = getSwipingDirection(gestureState); + animEvt.current = createAnimationEventForSwipe(currentSwipingDirection, pan); + return shouldSetPanResponder; + } + + return false; + }, + onStartShouldSetPanResponder: (e: any, gestureState) => { + const hasScrollableView = e._dispatchInstances ?? e._dispatchInstances.some((instance: any) => /scrollview|flatlist/i.test(instance.type)); + + if (hasScrollableView && shouldPropagateSwipe(e, gestureState) && props.scrollTo && props.scrollOffset > 0) { + return false; // user needs to be able to scroll content back up + } + if (props.onSwipeStart) { + props.onSwipeStart(gestureState); + } + + // Cleared so that onPanResponderMove can wait to have some delta + // to work with + currentSwipingDirection = null; + return true; + }, + onPanResponderMove: (evt, gestureState) => { + // Using onStartShouldSetPanResponder we don't have any delta so we don't know + // The direction to which the user is swiping until some move have been done + if (!currentSwipingDirection) { + if (gestureState.dx === 0 && gestureState.dy === 0) { + return; + } + + currentSwipingDirection = getSwipingDirection(gestureState); + animEvt.current = createAnimationEventForSwipe(currentSwipingDirection, pan); + } + + if (isSwipeDirectionAllowed(gestureState, currentSwipingDirection)) { + // TODO: VERIFY THAT || DOESN'T WORK DIFFERENT THAN NULLISH COALESCING + // Dim the background while swiping the modal + const newOpacityFactor = 1 - calcDistancePercentage(gestureState, currentSwipingDirection, props.deviceHeight || deviceHeight, props.deviceWidth || deviceWidth); + + // TODO: REMOVE THIS LATER + // backdropRef && + // backdropRef.transitionTo({ + // opacity: backdropOpacity * newOpacityFactor, + // }); + + animEvt.current?.(evt, gestureState); + + if (props.onSwipeMove) { + props.onSwipeMove(newOpacityFactor, gestureState); + } + } else { + if (!props.scrollTo) { + return; + } + if (props.scrollHorizontal) { + let offsetX = -gestureState.dx; + if (offsetX > props.scrollOffsetMax) { + offsetX -= (offsetX - props.scrollOffsetMax) / 2; + } + + props.scrollTo({x: offsetX, animated: false}); + } else { + let offsetY = -gestureState.dy; + if (offsetY > props.scrollOffsetMax) { + offsetY -= (offsetY - props.scrollOffsetMax) / 2; + } + + props.scrollTo({y: offsetY, animated: false}); + } + } + }, + onPanResponderRelease: (evt, gestureState) => { + // Call the onSwipe prop if the threshold has been exceeded on the right direction + const accDistance = getAccDistancePerDirection(gestureState, currentSwipingDirection); + if (accDistance > props.swipeThreshold && isSwipeDirectionAllowed(gestureState, currentSwipingDirection, props.swipeDirection)) { + if (props.onSwipeComplete) { + setInSwipeClosingState(true); + props.onSwipeComplete( + { + swipingDirection: getSwipingDirection(gestureState), + }, + gestureState, + ); + return; + } + } + + // Reset backdrop opacity and modal position + if (props.onSwipeCancel) { + props.onSwipeCancel(gestureState); + } + + Animated.spring(pan, { + toValue: {x: 0, y: 0}, + bounciness: 0, + useNativeDriver: false, + }).start(); + + if (props.scrollTo) { + if (props.scrollOffset > props.scrollOffsetMax) { + props.scrollTo({ + y: props.scrollOffsetMax, + animated: true, + }); + } + } + }, + }), + ); + }, []); + + const handleDimensionsUpdate = () => { + if (!(!props.deviceHeight && !props.deviceWidth)) { + return; + } + // Here we update the device dimensions in the state if the layout changed + // (triggering a render) + const deviceWidthTemp = Dimensions.get('window').width; + const deviceHeightTemp = Dimensions.get('window').height; + if (deviceWidthTemp !== deviceWidth || deviceHeightTemp !== deviceHeight) { + setDeviceWidth(deviceWidthTemp); + setDeviceHeight(deviceHeightTemp); + } + }; + + const onBackButtonPressHandler = () => { + if (onBackButtonPress && isVisible) { + onBackButtonPress(); + return true; + } + return false; + }; + + const handleTransition = (type: 'open' | 'close', onFinish: () => void) => { + const shouldAnimate = isVisible !== isContainerOpen; + // const isReadyToAnimate = isHeightCalculated && measuredHeight !== 0; + // console.log(testName, ' Modal: HandleTransition', isTransitioning); + // // console.log('handleTransition', shouldAnimate, isReadyToAnimate); + + if (shouldAnimate && !isTransitioning && isHeightCalculated) { + // if (isReadyToAnimate && shouldAnimate && !isTransitioning) { + setIsTransitioning(true); + + // console.log(testName, ' Modal: setting timeout'); + setTimeout(() => { + setIsContainerOpen(isVisible); + setIsTransitioning(false); + + // console.log(testName, ' Modal: Timeout Finished', isVisible, isVisibleState, isContainerOpen, isTransitioning); + onFinish(); + }, 300); + // TODO: type === 'open' ? animationInTiming : animationOutTiming, + } + }; + + // TODO: VERIFY THAT OPEN() LOGIC IS WORKING PROPERLY + const open = () => { + if (isTransitioning) { + return; + } + + // This is for resetting the pan position,otherwise the modal gets stuck + // at the last released position when you try to open it. + // TODO: Could certainly be improved - no idea for the moment. + if (isSwipeable) { + pan.setValue?.({x: 0, y: 0}); + } + + if (props.onModalWillShow) { + // console.log(testName, ' Modal: open() -> onModalWillShow()'); + props.onModalWillShow(); + } + // console.log(testName, ' Modal: open() -> setIsVisibleState(true)'); + setIsVisibleState(true); + setShowContent(true); + handleTransition('open', () => { + if (!isVisible) { + setIsContainerOpen(false); + } else { + onModalShow(); + } + }); + }; + + // TODO: VERIFY THAT CLOSE() LOGIC WORKS PROPERLY + const close = () => { + // console.log(testName, ' Modal: close()'); + if (isTransitioning) { + return; + } + + if (inSwipeClosingState) { + setInSwipeClosingState(false); + } + + if (props.onModalWillHide) { + // console.log(testName, ' Modal: close() -> onModalWillHide()'); + props.onModalWillHide(); + } + + // console.log(testName, ' Modal: close() -> setIsVisibleState(false)'); + setIsVisibleState(false); + handleTransition('close', () => { + if (isVisible) { + setIsContainerOpen(true); + } else { + // console.log(testName, ' Modal: onModalHide()'); + setShowContent(false); + setMeasuredHeight(0); + setIsHeightCalculated(false); + props.onModalHide(); + } + }); + }; + + // TODO: this was a constructor - verify if it works + useEffect(() => { + if (!isSwipeable) { + return; + } + + setPan(new Animated.ValueXY()); + buildPanResponder(); + }, [isSwipeable, buildPanResponder]); + + useEffect(() => { + // console.log(testName, ' Modal: Mounted'); + didUpdateDimensionsEmitter.current = DeviceEventEmitter.addListener('didUpdateDimensions', handleDimensionsUpdate); + BackHandler.addEventListener('hardwareBackPress', onBackButtonPressHandler); + + return () => { + // console.log(testName, ' Modal: UnMounted'); + BackHandler.removeEventListener('hardwareBackPress', onBackButtonPressHandler); + if (didUpdateDimensionsEmitter.current) { + didUpdateDimensionsEmitter.current.remove(); + } + if (isVisibleState) { + // console.log(testName, ' Modal: useEffect(()=>{}) -> onModalHide()'); + props.onModalHide(); + } + }; + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + // TODO: CHECK THIS LOGIC - maybe add handling for prevProps? + if (isVisible && !isContainerOpen && !isTransitioning) { + // console.log(testName, ' Modal: useEffect(()=>{}, [isVisible]) -> open()'); + open(); + } else if (!isVisible && isContainerOpen && !isTransitioning) { + // console.log(testName, ' Modal: useEffect(()=>{}, [isVisible]) -> close()'); + close(); + } + // TODO: verify if this dependency array is OK + }, [isVisible, isContainerOpen, isHeightCalculated]); + + // eslint-disable-next-line @typescript-eslint/naming-convention + const {propagateSwipe: _, ...otherProps} = props; + + const computedStyle = [{margin: getDeviceWidth() * 0.05}, styles.content, style]; + + // FIXME: RESTORE PAN POSITION & OTHER LOGIC HERE + let panPosition = {}; + if (isSwipeable && panResponder) { + if (useNativeDriver) { + panPosition = { + transform: pan.getTranslateTransform(), + }; + } else { + panPosition = pan.getLayout(); + } + } + + const containerView = ( + (this.contentRef = ref)} + panHandlers={panResponder?.panHandlers} + isVisible={isVisibleState} + style={[panPosition, computedStyle]} + pointerEvents="box-none" + useNativeDriver={useNativeDriver} + isHeightCalculated={isHeightCalculated} + toggleCalculatedHeight={setIsHeightCalculated} + isContainerOpen={isContainerOpen} + isTransitioning={isTransitioning} + setMeasuredHeight={setMeasuredHeight} + // eslint-disable-next-line react/jsx-props-no-spreading + {...otherProps} + > + {children} + {/* {props.hideModalContentWhileAnimating && useNativeDriver && !showContent ? : children} */} + + ); + + // If coverScreen is set to false by the user + // we render the modal inside the parent view directly + if (!coverScreen && isVisibleState) { + return ( + + + {containerView} + + ); + } + + return ( + + + + {avoidKeyboard ? ( + + {containerView} + + ) : ( + containerView + )} + + ); +} + +export default ReactNativeModal; diff --git a/src/components/Modal/ReactNativeModal/modal.style.ts b/src/components/Modal/ReactNativeModal/modal.style.ts new file mode 100644 index 000000000000..4733108b2dea --- /dev/null +++ b/src/components/Modal/ReactNativeModal/modal.style.ts @@ -0,0 +1,22 @@ +import {StyleSheet} from 'react-native'; + +export default StyleSheet.create({ + backdrop: { + position: 'absolute', + top: 0, + bottom: 0, + left: 0, + right: 0, + // opacity: 0, + backgroundColor: 'black', + }, + content: { + flex: 1, + justifyContent: 'center', + }, + containerBox: { + zIndex: 2, + opacity: 1, + backgroundColor: 'transparent', + }, +}); diff --git a/src/components/Modal/ReactNativeModal/panHandlers.ts b/src/components/Modal/ReactNativeModal/panHandlers.ts new file mode 100644 index 000000000000..c04e4c15dc54 --- /dev/null +++ b/src/components/Modal/ReactNativeModal/panHandlers.ts @@ -0,0 +1,223 @@ +import type {GestureResponderEvent, PanResponderGestureState} from 'react-native'; +import {Animated} from 'react-native'; +import type {Direction} from './types'; +import type ModalProps from './types'; + +const reversePercentage = (x: number) => -(x - 1); + +const calcDistancePercentage = (gestureState: PanResponderGestureState, currentSwipingDirection: Direction | null, deviceHeight: number, deviceWidth: number) => { + switch (currentSwipingDirection) { + case 'down': + return (gestureState.moveY - gestureState.y0) / (deviceHeight - gestureState.y0); + case 'up': + return reversePercentage(gestureState.moveY / gestureState.y0); + case 'left': + return reversePercentage(gestureState.moveX / gestureState.x0); + case 'right': + return (gestureState.moveX - gestureState.x0) / (deviceWidth - gestureState.x0); + + default: + return 0; + } +}; + +const getAccDistancePerDirection = (gestureState: PanResponderGestureState, currentSwipingDirection: Direction | null) => { + switch (currentSwipingDirection) { + case 'up': + return -gestureState.dy; + case 'down': + return gestureState.dy; + case 'right': + return gestureState.dx; + case 'left': + return -gestureState.dx; + default: + return 0; + } +}; + +const createAnimationEventForSwipe = (currentSwipingDirection: Direction | null, pan: Animated.ValueXY) => { + if (currentSwipingDirection === 'right' || currentSwipingDirection === 'left') { + return Animated.event([null, {dx: pan.x}], { + useNativeDriver: false, + }); + } + return Animated.event([null, {dy: pan.y}], { + useNativeDriver: false, + }); +}; + +const isDirectionIncluded = (direction: Direction, swipeDirection?: Direction | Direction[]) => { + return Array.isArray(swipeDirection) ? swipeDirection.includes(direction) : swipeDirection === direction; +}; + +const isSwipeDirectionAllowed = ({dy, dx}: PanResponderGestureState, currentSwipingDirection: Direction | null, swipeDirection?: Direction | Direction[]) => { + const draggedDown = dy > 0; + const draggedUp = dy < 0; + const draggedLeft = dx < 0; + const draggedRight = dx > 0; + console.log('isSwipeDirectionAllowed: ', currentSwipingDirection); + if (currentSwipingDirection === 'up' && isDirectionIncluded('up', swipeDirection) && draggedUp) { + return true; + } + if (currentSwipingDirection === 'down' && isDirectionIncluded('down', swipeDirection) && draggedDown) { + return true; + } + if (currentSwipingDirection === 'right' && isDirectionIncluded('right', swipeDirection) && draggedRight) { + return true; + } + if (currentSwipingDirection === 'left' && isDirectionIncluded('left', swipeDirection) && draggedLeft) { + return true; + } + return false; +}; + +const getSwipingDirection = (gestureState: PanResponderGestureState) => { + if (Math.abs(gestureState.dx) > Math.abs(gestureState.dy)) { + return gestureState.dx > 0 ? 'right' : 'left'; + } + + return gestureState.dy > 0 ? 'down' : 'up'; +}; + +const shouldPropagateSwipe = (evt: GestureResponderEvent, gestureState: PanResponderGestureState, propagateSwipe?: ModalProps['propagateSwipe']) => { + return typeof propagateSwipe === 'function' ? propagateSwipe(evt, gestureState) : propagateSwipe; +}; + +// const initiatePanResponder = (propagateSwipe, panResponderThreshold, onSwipeStart, pan, scrollTo, scrollOffset, swipeDirection, deviceHeight, deviceHeightState, deviceWidth, deviceWidthState, backdropRef, backdropOpacity, onSwipeMove, scrollHorizontal, scrollOffsetMax, swipeThreshold, onSwipeComplete, inSwipeClosingState, onSwipe, inSwipeClosingState, onSwipeCancel) => { +// let animEvt: any = null; +// let currentSwipingDirection: Direction | null = null; + +// return PanResponder.create({ +// onMoveShouldSetPanResponder: (evt, gestureState) => { +// // Use propagateSwipe to allow inner content to scroll. See PR: +// // https://github.com/react-native-community/react-native-modal/pull/246 +// if (!shouldPropagateSwipe(evt, gestureState, propagateSwipe)) { +// // The number "4" is just a good tradeoff to make the panResponder +// // work correctly even when the modal has touchable buttons. +// // However, if you want to overwrite this and choose for yourself, +// // set panResponderThreshold in the props. +// // For reference: +// // https://github.com/react-native-community/react-native-modal/pull/197 +// const shouldSetPanResponder = Math.abs(gestureState.dx) >= panResponderThreshold || Math.abs(gestureState.dy) >= panResponderThreshold; +// if (shouldSetPanResponder && onSwipeStart) { +// onSwipeStart(gestureState); +// } + +// currentSwipingDirection = getSwipingDirection(gestureState); +// animEvt = createAnimationEventForSwipe(currentSwipingDirection, pan); +// return shouldSetPanResponder; +// } + +// return false; +// }, +// onStartShouldSetPanResponder: (e: any, gestureState) => { +// const hasScrollableView = e._dispatchInstances?.some((instance: any) => /scrollview|flatlist/i.test(instance.type)); + +// if (hasScrollableView && shouldPropagateSwipe(e, gestureState, propagateSwipe) && scrollTo && scrollOffset > 0) { +// return false; // user needs to be able to scroll content back up +// } +// if (onSwipeStart) { +// onSwipeStart(gestureState); +// } + +// // Cleared so that onPanResponderMove can wait to have some delta +// // to work with +// currentSwipingDirection = null; +// return true; +// }, +// onPanResponderMove: (evt, gestureState) => { +// // Using onStartShouldSetPanResponder we don't have any delta so we don't know +// // The direction to which the user is swiping until some move have been done +// if (!currentSwipingDirection) { +// if (gestureState.dx === 0 && gestureState.dy === 0) { +// return; +// } + +// currentSwipingDirection = getSwipingDirection(gestureState); +// animEvt = createAnimationEventForSwipe(currentSwipingDirection, pan); +// } + +// if (isSwipeDirectionAllowed(gestureState, currentSwipingDirection, swipeDirection)) { +// // Dim the background while swiping the modal +// const newOpacityFactor = 1 - calcDistancePercentage(gestureState, currentSwipingDirection, deviceHeight ?? deviceHeightState, deviceWidth ?? deviceWidthState); + +// backdropRef?.transitionTo({ +// opacity: backdropOpacity * newOpacityFactor, +// }); + +// animEvt!(evt, gestureState); + +// if (onSwipeMove) { +// onSwipeMove(newOpacityFactor, gestureState); +// } +// } else if (scrollTo) { +// if (scrollHorizontal) { +// let offsetX = -gestureState.dx; +// if (offsetX > scrollOffsetMax) { +// offsetX -= (offsetX - scrollOffsetMax) / 2; +// } + +// scrollTo({ x: offsetX, animated: false }); +// } else { +// let offsetY = -gestureState.dy; +// if (offsetY > scrollOffsetMax) { +// offsetY -= (offsetY - scrollOffsetMax) / 2; +// } + +// scrollTo({ y: offsetY, animated: false }); +// } +// } +// }, +// onPanResponderRelease: (evt, gestureState) => { +// // Call the onSwipe prop if the threshold has been exceeded on the right direction +// const accDistance = getAccDistancePerDirection(gestureState, currentSwipingDirection); +// if (accDistance > swipeThreshold && isSwipeDirectionAllowed(gestureState, currentSwipingDirection, swipeDirection)) { +// if (onSwipeComplete) { +// inSwipeClosingState = true; +// onSwipeComplete( +// { +// swipingDirection: getSwipingDirection(gestureState), +// }, +// gestureState, +// ); +// return; +// } +// // Deprecated. Remove later. +// if (onSwipe) { +// inSwipeClosingState = true; +// onSwipe(); +// return; +// } +// } + +// // Reset backdrop opacity and modal position +// if (onSwipeCancel) { +// onSwipeCancel(gestureState); +// } + +// if (backdropRef) { +// backdropRef.transitionTo({ +// opacity: backdropOpacity, +// }); +// } + +// Animated.spring(pan, { +// toValue: { x: 0, y: 0 }, +// bounciness: 0, +// useNativeDriver: false, +// }).start(); + +// if (scrollTo) { +// if (scrollOffset > scrollOffsetMax) { +// scrollTo({ +// y: scrollOffsetMax, +// animated: true, +// }); +// } +// } +// }, +// }); +// } + +export {shouldPropagateSwipe, getSwipingDirection, isSwipeDirectionAllowed, calcDistancePercentage, getAccDistancePerDirection, createAnimationEventForSwipe}; diff --git a/src/components/Modal/ReactNativeModal/panResponders.ts b/src/components/Modal/ReactNativeModal/panResponders.ts new file mode 100644 index 000000000000..be70943ab3e5 --- /dev/null +++ b/src/components/Modal/ReactNativeModal/panResponders.ts @@ -0,0 +1,30 @@ +import type {MutableRefObject} from 'react'; +import type {Animated, PanResponderGestureState} from 'react-native'; +import {createAnimationEventForSwipe, getSwipingDirection, shouldPropagateSwipe} from './panHandlers'; +import type {AnimationEvent, Direction, GestureResponderEvent} from './types'; +import type ModalProps from './types'; + +const onMoveShouldSetPanResponder = (props: ModalProps, animEvt: MutableRefObject, currentSwipingDirection: Direction | null, pan: Animated.ValueXY) => { + return (evt: GestureResponderEvent, gestureState: PanResponderGestureState) => { + // Use propagateSwipe to allow inner content to scroll. See PR: + // https://github.com/react-native-community/react-native-modal/pull/246 + if (!shouldPropagateSwipe(evt, gestureState, props.propagateSwipe)) { + // The number "4" is just a good tradeoff to make the panResponder + // work correctly even when the modal has touchable buttons. + // However, if you want to overwrite this and choose for yourself, + // set panResponderThreshold in the props. + // For reference: + // https://github.com/react-native-community/react-native-modal/pull/197 + const shouldSetPanResponder = Math.abs(gestureState.dx) >= props.panResponderThreshold || Math.abs(gestureState.dy) >= props.panResponderThreshold; + if (shouldSetPanResponder && props.onSwipeStart) { + props.onSwipeStart(gestureState); + } + + currentSwipingDirection = getSwipingDirection(gestureState); + animEvt.current = createAnimationEventForSwipe(currentSwipingDirection, pan); + return shouldSetPanResponder; + } + + return false; + }; +}; diff --git a/src/components/Modal/ReactNativeModal/types.ts b/src/components/Modal/ReactNativeModal/types.ts new file mode 100644 index 000000000000..78926be47aca --- /dev/null +++ b/src/components/Modal/ReactNativeModal/types.ts @@ -0,0 +1,71 @@ +import type {ReactNode} from 'react'; +import type {NativeSyntheticEvent, NativeTouchEvent, PanResponderGestureState, StyleProp, ViewProps, ViewStyle} from 'react-native'; + +type Orientation = 'portrait' | 'portrait-upside-down' | 'landscape' | 'landscape-left' | 'landscape-right'; + +type Direction = 'up' | 'down' | 'left' | 'right'; +type PresentationStyle = 'fullScreen' | 'pageSheet' | 'formSheet' | 'overFullScreen'; +type OnOrientationChange = (orientation: NativeSyntheticEvent) => void; + +type OnSwipeCompleteParams = { + swipingDirection: Direction; +}; + +type ModalProps = ViewProps & { + children: ReactNode; + onSwipeStart?: (gestureState: PanResponderGestureState) => void; + onSwipeMove?: (percentageShown: number, gestureState: PanResponderGestureState) => void; + onSwipeComplete?: (params: OnSwipeCompleteParams, gestureState: PanResponderGestureState) => void; + onSwipeCancel?: (gestureState: PanResponderGestureState) => void; + style?: StyleProp; + swipeDirection?: Direction | Direction[]; + onDismiss?: () => void; + onShow?: () => void; + hardwareAccelerated?: boolean; + onOrientationChange?: OnOrientationChange; + presentationStyle?: PresentationStyle; + + // Default ModalProps Provided + useNativeDriverForBackdrop?: boolean; + + animationIn?: string; // enum + animationInTiming?: number; + animationOut?: string; // enum + animationOutTiming?: number; + avoidKeyboard?: boolean; + coverScreen?: boolean; + hasBackdrop?: boolean; + backdropColor?: string; // color + backdropOpacity?: number; + backdropTransitionInTiming?: number; + backdropTransitionOutTiming?: number; + customBackdrop?: ReactNode; + useNativeDriver?: boolean; + deviceHeight?: number; + deviceWidth?: number; + hideModalContentWhileAnimating?: boolean; + propagateSwipe?: boolean | ((event?: GestureResponderEvent, gestureState?: PanResponderGestureState) => boolean); + isVisible?: boolean; + panResponderThreshold: 4; + swipeThreshold?: 100; + + onModalShow?: () => void; + onModalWillShow?: () => void; + onModalHide?: () => void; + onModalWillHide?: () => void; + onBackdropPress?: () => void; + onBackButtonPress?: () => void; + scrollTo?: (e?: any) => void; + scrollOffset?: number; + scrollOffsetMax?: number; + scrollHorizontal?: boolean; + statusBarTranslucent?: boolean; + supportedOrientations?: Orientation[]; +}; + +type GestureResponderEvent = NativeSyntheticEvent; +type AnimationEvent = (...args: any[]) => void; +type OrNull = null | T; + +export default ModalProps; +export type {GestureResponderEvent, AnimationEvent, Direction, OrNull, Orientation}; diff --git a/src/components/Modal/ReactNativeModal/utils.ts b/src/components/Modal/ReactNativeModal/utils.ts new file mode 100644 index 000000000000..1d0e5a2ea8eb --- /dev/null +++ b/src/components/Modal/ReactNativeModal/utils.ts @@ -0,0 +1,41 @@ +import type {Orientation} from './types'; + +const defaultProps = { + animationIn: 'slideInUp', + animationInTiming: 300, + animationOut: 'slideOutDown', + animationOutTiming: 300, + avoidKeyboard: false, + coverScreen: true, + hasBackdrop: true, + backdropColor: 'black', + backdropOpacity: 0.7, + backdropTransitionInTiming: 300, + backdropTransitionOutTiming: 300, + customBackdrop: null, + useNativeDriver: false, + deviceHeight: null, + deviceWidth: null, + hideModalContentWhileAnimating: false, + propagateSwipe: false, + isVisible: false, + panResponderThreshold: 4, + swipeThreshold: 100, + + onModalShow: () => {}, + onModalWillShow: () => {}, + onModalHide: () => {}, + onModalWillHide: () => {}, + onBackdropPress: () => {}, + onBackButtonPress: () => {}, + scrollTo: null, + scrollOffset: 0, + scrollOffsetMax: 0, + scrollHorizontal: false, + statusBarTranslucent: false, + supportedOrientations: ['portrait', 'landscape'] as Orientation[], +}; + +const reversePercentage = (x: number) => -(x - 1); + +export {reversePercentage, defaultProps};