Skip to content

Commit

Permalink
Modal code refactor
Browse files Browse the repository at this point in the history
@bartosz grajdek/react native modal refactor kuba v2
  • Loading branch information
BartoszGrajdek authored Dec 18, 2024
2 parents 61f207a + 0312e11 commit afcb7db
Show file tree
Hide file tree
Showing 7 changed files with 138 additions and 79 deletions.
19 changes: 8 additions & 11 deletions src/components/Modal/BaseModal.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import {PortalHost} from '@gorhom/portal';
import React, {forwardRef, useCallback, useEffect, useMemo, useRef, useState} from 'react';
import React, {forwardRef, useCallback, useEffect, useMemo, useRef} from 'react';
import {View} from 'react-native';
import ReactNativeModal, {ModalProps as ReactNativeModalProps} from 'react-native-modal';
import {ValueOf} from 'type-fest';
import type {ModalProps as ReactNativeModalProps} from 'react-native-modal';
import ReactNativeModal from 'react-native-modal';
import type {ValueOf} from 'type-fest';
import ColorSchemeWrapper from '@components/ColorSchemeWrapper';
import FocusTrapForModal from '@components/FocusTrap/FocusTrapForModal';
import useKeyboardState from '@hooks/useKeyboardState';
Expand All @@ -15,14 +15,13 @@ import useThemeStyles from '@hooks/useThemeStyles';
import useWindowDimensions from '@hooks/useWindowDimensions';
import ComposerFocusManager from '@libs/ComposerFocusManager';
import Overlay from '@libs/Navigation/AppNavigator/Navigators/Overlay';
import useNativeDriver from '@libs/useNativeDriver';
import variables from '@styles/variables';
import * as Modal from '@userActions/Modal';
import CONST from '@src/CONST';
import ModalContent from './ModalContent';
import ModalContext from './ModalContext';
import BottomDockedModal from './ReactNativeModal/Modal';
import ModalProps from './ReactNativeModal/types';
import type ModalProps from './ReactNativeModal/types';
import type BaseModalProps from './types';

type ModalComponentProps = (ReactNativeModalProps | ModalProps) & {
Expand Down Expand Up @@ -53,8 +52,6 @@ function BaseModal(
fullscreen = true,
animationIn,
animationOut,
useNativeDriver: useNativeDriverProp,
useNativeDriverForBackdrop,
hideModalContentWhileAnimating = false,
animationInTiming,
animationOutTiming,
Expand All @@ -70,6 +67,8 @@ function BaseModal(
restoreFocusType,
shouldUseModalPaddingStyle = true,
initialFocus = false,
swipeThreshold = 150,
swipeDirection,
}: BaseModalProps,
ref: React.ForwardedRef<View>,
) {
Expand All @@ -94,7 +93,6 @@ function BaseModal(
}
ComposerFocusManager.resetReadyToFocus(uniqueModalId);
}, [shouldEnableNewFocusManagement, uniqueModalId]);

/**
* Hides modal
* @param callHideCallback - Should we call the onModalHide callback
Expand Down Expand Up @@ -171,7 +169,6 @@ function BaseModal(
const {
modalStyle,
modalContainerStyle,
swipeDirection,
animationIn: modalStyleAnimationIn,
animationOut: modalStyleAnimationOut,
shouldAddTopSafeAreaMargin,
Expand Down Expand Up @@ -244,7 +241,6 @@ function BaseModal(
<ModalComponent
backdropTransitionInTiming={300}
panResponderThreshold={100}
swipeThreshold={100}
onModalWillHide={() => {}}
scrollTo={null}
scrollOffset={0}
Expand All @@ -264,6 +260,7 @@ function BaseModal(
onDismiss={handleDismissModal}
onSwipeComplete={() => onClose?.()}
swipeDirection={swipeDirection}
swipeThreshold={swipeThreshold}
isVisible={isVisible}
backdropColor={theme.overlay}
backdropOpacity={!shouldUseCustomBackdrop && hideBackdrop ? 0 : variables.overlayOpacity}
Expand Down
23 changes: 18 additions & 5 deletions src/components/Modal/ReactNativeModal/Container.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,30 @@
import React from 'react';
import Animated, {SlideInDown, SlideOutDown} from 'react-native-reanimated';
import Animated, {runOnJS, SlideInDown, SlideOutDown, useAnimatedStyle} from 'react-native-reanimated';
import type ModalProps from './types';
import type {ContainerProps} from './types';

function Container({style, ...props}: ModalProps) {
function Container({style, ...props}: ModalProps & ContainerProps) {
const animatedStyles = useAnimatedStyle(() => {
if (!props.panPosition) {
return {};
}
return {
transform: [{translateX: props.panPosition.translateX.value}, {translateY: props.panPosition.translateY.value}],
};
});
return (
<Animated.View
style={[style, {flex: 1, height: '100%', flexDirection: 'row'}]}
entering={SlideInDown.duration(300)}
exiting={SlideOutDown.duration(300)}
entering={SlideInDown.duration(300).withCallback(() => {
runOnJS(props.onOpenCallBack)();
})}
exiting={SlideOutDown.duration(300).withCallback(() => {
runOnJS(props.onCloseCallBack)();
})}
// eslint-disable-next-line react/jsx-props-no-spreading
{...props}
>
<Animated.View style={{width: '100%', flex: 1, alignSelf: 'flex-end'}}>{props.children}</Animated.View>
<Animated.View style={[{width: '100%', flex: 1, alignSelf: 'flex-end'}, animatedStyles]}>{props.children}</Animated.View>
</Animated.View>
);
}
Expand Down
86 changes: 36 additions & 50 deletions src/components/Modal/ReactNativeModal/Modal.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React, {useCallback, useEffect, useRef, useState} from 'react';
import type {EmitterSubscription, PanResponderInstance, StyleProp, ViewStyle} from 'react-native';
import {Animated, BackHandler, DeviceEventEmitter, Dimensions, KeyboardAvoidingView, Modal, PanResponder, Platform, View} from 'react-native';
import {useSharedValue} from 'react-native-reanimated';
import Backdrop from './Backdrop';
import Container from './Container';
import styles from './modal.style';
Expand All @@ -10,9 +11,9 @@ import type ModalProps from './types';
import type {AnimationEvent, Direction} from './types';
import {defaultProps} from './utils';

type TransitionType = 'open' | 'close';

function ReactNativeModal(incomingProps: ModalProps) {
const mergedProps = {...defaultProps, ...incomingProps};

const {
animationIn,
animationOut,
Expand All @@ -34,11 +35,8 @@ function ReactNativeModal(incomingProps: ModalProps) {
style,
avoidKeyboard,
...props
}: ModalProps = {
...defaultProps,
...incomingProps,
};
const [showContent, setShowContent] = useState(isVisible);
} = mergedProps;

const [isVisibleState, setIsVisibleState] = useState(isVisible);
const [isContainerOpen, setIsContainerOpen] = useState(false);
const [isTransitioning, setIsTransitioning] = useState(false);
Expand All @@ -48,7 +46,7 @@ function ReactNativeModal(incomingProps: ModalProps) {
const [panResponder, setPanResponder] = useState<PanResponderInstance | null>(null);
const [inSwipeClosingState, setInSwipeClosingState] = useState(false);
const isSwipeable = !!props.swipeDirection;

const shouldHideChildren = props.hideModalContentWhileAnimating && isContainerOpen && isTransitioning;
const currentSwipingDirectionRef = useRef<Direction | null>(null);

const setCurrentSwipingDirection = (direction: Direction | null) => {
Expand All @@ -65,14 +63,28 @@ function ReactNativeModal(incomingProps: ModalProps) {

const getDeviceHeight = () => props.deviceHeight ?? deviceHeight;
const getDeviceWidth = () => props.deviceWidth ?? deviceWidth;
const yOffset = useSharedValue<number>(0);
const xOffset = useSharedValue<number>(0);

const buildPanResponder = useCallback(() => {
setPanResponder(
PanResponder.create({
onMoveShouldSetPanResponder: onMoveShouldSetPanResponder(props, setAnimEvt, setCurrentSwipingDirection, pan),
onStartShouldSetPanResponder: (a, b) => onStartShouldSetPanResponder(props, setCurrentSwipingDirection)(a as EnhancedGestureEvent, b),
onPanResponderMove: onPanResponderMove(props, currentSwipingDirectionRef, setCurrentSwipingDirection, setAnimEvt, animEvt, pan, deviceHeight, deviceWidth),
onPanResponderRelease: onPanResponderRelease(props, currentSwipingDirectionRef, setInSwipeClosingState, pan),
onPanResponderMove: onPanResponderMove(
props,
currentSwipingDirectionRef,
setCurrentSwipingDirection,
setAnimEvt,
animEvt,
pan,
deviceHeight,
deviceWidth,
xOffset,
yOffset,
props.swipeDirection,
),
onPanResponderRelease: onPanResponderRelease(props, currentSwipingDirectionRef, setInSwipeClosingState, pan, xOffset, yOffset),
}),
);
// eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps
Expand All @@ -99,41 +111,24 @@ function ReactNativeModal(incomingProps: ModalProps) {
return false;
};

const handleTransition = (type: TransitionType, onFinish: () => void) => {
const handleTransition = () => {
const shouldAnimate = isVisible !== isContainerOpen;

if (shouldAnimate && !isTransitioning) {
setIsTransitioning(true);

setTimeout(() => {
setIsContainerOpen(isVisible);
setIsTransitioning(false);

onFinish();
}, 300);
// TODO: type === 'open' ? animationInTiming : animationOutTiming,
}
};

const open = () => {
if (isTransitioning) {
return;
}
if (isSwipeable) {
pan.setValue?.({x: 0, y: 0});
}

if (props.onModalWillShow) {
props.onModalWillShow();
}
setIsVisibleState(true);
handleTransition('open', () => {
if (!isVisible) {
setIsContainerOpen(false);
} else {
onModalShow();
}
});
handleTransition();
};

const close = () => {
Expand All @@ -150,14 +145,7 @@ function ReactNativeModal(incomingProps: ModalProps) {
}

setIsVisibleState(false);
handleTransition('close', () => {
if (isVisible) {
setIsContainerOpen(true);
} else {
setShowContent(false);
props.onModalHide?.();
}
});
handleTransition();
};

useEffect(() => {
Expand Down Expand Up @@ -200,33 +188,31 @@ function ReactNativeModal(incomingProps: ModalProps) {

const computedStyle: Array<StyleProp<ViewStyle>> = [{margin: getDeviceWidth() * 0.05}, styles.content, style];

let panPosition: StyleProp<ViewStyle> = {};
if (isSwipeable && panResponder) {
if (useNativeDriver) {
panPosition = {
// transform: pan.getTranslateTransform(),
};
} else {
// panPosition = pan.getLayout();
}
}

const containerView = (
<Container
animationIn={animationIn}
animationOut={animationOut}
animationInTiming={animationInTiming}
animationOutTiming={animationOutTiming}
isVisible={isVisibleState}
style={[panPosition, computedStyle]}
style={[computedStyle]}
panPosition={isSwipeable ? {translateX: xOffset, translateY: yOffset} : undefined}
pointerEvents="box-none"
useNativeDriver={useNativeDriver}
onOpenCallBack={() => {
setIsContainerOpen(true);
setIsTransitioning(false);
}}
onCloseCallBack={() => {
setIsContainerOpen(false);
setIsTransitioning(false);
}}
// eslint-disable-next-line react/jsx-props-no-spreading
{...(isSwipeable && panResponder ? panResponder.panHandlers : {})}
// eslint-disable-next-line react/jsx-props-no-spreading
{...otherProps}
>
{props.hideModalContentWhileAnimating && !showContent ? <View /> : children}
{shouldHideChildren ? <View /> : children}
</Container>
);

Expand Down
48 changes: 41 additions & 7 deletions src/components/Modal/ReactNativeModal/panResponders.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,35 @@
import type {MutableRefObject} from 'react';
import type {PanResponderGestureState} from 'react-native';
import {Animated} from 'react-native';
import type {Animated, PanResponderGestureState} from 'react-native';
import type {SharedValue} from 'react-native-reanimated';
import {withSpring} from 'react-native-reanimated';
import {calcDistancePercentage, createAnimationEventForSwipe, getAccDistancePerDirection, getSwipingDirection, isSwipeDirectionAllowed, shouldPropagateSwipe} from './panHandlers';
import type {AnimationEvent, Direction, GestureResponderEvent} from './types';
import type ModalProps from './types';

function handleSwipe(dx: number, dy: number, Xoffset: SharedValue<number>, Yoffset: SharedValue<number>, swipeDirection?: Direction | Direction[]) {
if (!swipeDirection) {
return;
}
const directions = Array.isArray(swipeDirection) ? swipeDirection : [swipeDirection];

if (directions.includes('right') && dx > 0) {
// eslint-disable-next-line no-param-reassign
Xoffset.value = dx;
}
if (directions.includes('left') && dx < 0) {
// eslint-disable-next-line no-param-reassign
Xoffset.value = dx;
}
if (directions.includes('up') && dy < 0) {
// eslint-disable-next-line no-param-reassign
Yoffset.value = dy;
}
if (directions.includes('down') && dy > 0) {
// eslint-disable-next-line no-param-reassign
Yoffset.value = dy;
}
}

type RemainingModalProps = Omit<
ModalProps,
| 'animationIn'
Expand Down Expand Up @@ -82,8 +107,12 @@ const onPanResponderMove = (
pan: Animated.ValueXY,
deviceHeight: number,
deviceWidth: number,
xOffset: SharedValue<number>,
yOffset: SharedValue<number>,
swipeDirection?: Direction | Direction[],
) => {
return (evt: GestureResponderEvent, gestureState: PanResponderGestureState) => {
handleSwipe(gestureState.dx, gestureState.dy, xOffset, yOffset, swipeDirection);
const currentSwipingDirection = currentSwipingDirectionRef.current;
if (!currentSwipingDirection) {
if (gestureState.dx === 0 && gestureState.dy === 0) {
Expand Down Expand Up @@ -128,6 +157,8 @@ const onPanResponderRelease = (
currentSwipingDirectionRef: MutableRefObject<Direction | null>,
setInSwipeClosingState: (val: boolean) => void,
pan: Animated.ValueXY,
xOffset: SharedValue<number>,
yOffset: SharedValue<number>,
) => {
return (evt: GestureResponderEvent, gestureState: PanResponderGestureState) => {
const currentSwipingDirection = currentSwipingDirectionRef.current;
Expand All @@ -141,6 +172,10 @@ const onPanResponderRelease = (
},
gestureState,
);
// eslint-disable-next-line no-param-reassign
xOffset.value = 0;
// eslint-disable-next-line no-param-reassign
yOffset.value = 0;
return;
}
}
Expand All @@ -149,11 +184,10 @@ const onPanResponderRelease = (
props.onSwipeCancel(gestureState);
}

Animated.spring(pan, {
toValue: {x: 0, y: 0},
bounciness: 0,
useNativeDriver: false,
}).start();
// eslint-disable-next-line no-param-reassign
xOffset.value = withSpring(0);
// eslint-disable-next-line no-param-reassign
yOffset.value = withSpring(0);

if (props.scrollTo && props.scrollOffset !== undefined && props.scrollOffsetMax !== undefined) {
if (props.scrollOffset > props.scrollOffsetMax) {
Expand Down
Loading

0 comments on commit afcb7db

Please sign in to comment.