Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

@bartosz grajdek/react native modal refactor kuba v2 #140

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 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 All @@ -32,9 +31,9 @@
function ModalComponent(props: ModalComponentProps) {
switch (props.type) {
case CONST.MODAL.MODAL_TYPE.BOTTOM_DOCKED:
return <BottomDockedModal {...(props as ModalProps)} />;

Check failure on line 34 in src/components/Modal/BaseModal.tsx

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

Prop spreading is forbidden

Check failure on line 34 in src/components/Modal/BaseModal.tsx

View workflow job for this annotation

GitHub Actions / ESLint check

Prop spreading is forbidden
default:
return <ReactNativeModal {...(props as ReactNativeModalProps)} />;

Check failure on line 36 in src/components/Modal/BaseModal.tsx

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

Prop spreading is forbidden

Check failure on line 36 in src/components/Modal/BaseModal.tsx

View workflow job for this annotation

GitHub Actions / ESLint check

Prop spreading is forbidden
}
}

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

/**
* Hides modal
* @param callHideCallback - Should we call the onModalHide callback
Expand Down Expand Up @@ -171,7 +169,6 @@
const {
modalStyle,
modalContainerStyle,
swipeDirection,
animationIn: modalStyleAnimationIn,
animationOut: modalStyleAnimationOut,
shouldAddTopSafeAreaMargin,
Expand Down Expand Up @@ -244,7 +241,6 @@
<ModalComponent
backdropTransitionInTiming={300}
panResponderThreshold={100}
swipeThreshold={100}
onModalWillHide={() => {}}
scrollTo={null}
scrollOffset={0}
Expand All @@ -264,6 +260,7 @@
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}],

Check failure on line 12 in src/components/Modal/ReactNativeModal/Container.tsx

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

'value' is deprecated. Use the new `.get()` and `.set(value)` methods instead

Check failure on line 12 in src/components/Modal/ReactNativeModal/Container.tsx

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

'value' is deprecated. Use the new `.get()` and `.set(value)` methods instead
};
});
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 {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,21 +35,18 @@
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);
const [deviceWidth, setDeviceWidth] = useState(Dimensions.get('window').width);

Check failure on line 43 in src/components/Modal/ReactNativeModal/Modal.tsx

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

Avoid calling a function directly in the useState initializer. Use an initializer function instead (a callback)

Check failure on line 43 in src/components/Modal/ReactNativeModal/Modal.tsx

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

Avoid calling a function directly in the useState initializer. Use an initializer function instead (a callback)

Check failure on line 43 in src/components/Modal/ReactNativeModal/Modal.tsx

View workflow job for this annotation

GitHub Actions / ESLint check

Avoid calling a function directly in the useState initializer. Use an initializer function instead (a callback)

Check failure on line 43 in src/components/Modal/ReactNativeModal/Modal.tsx

View workflow job for this annotation

GitHub Actions / ESLint check

Avoid calling a function directly in the useState initializer. Use an initializer function instead (a callback)
const [deviceHeight, setDeviceHeight] = useState(Dimensions.get('window').height);

Check failure on line 44 in src/components/Modal/ReactNativeModal/Modal.tsx

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

Avoid calling a function directly in the useState initializer. Use an initializer function instead (a callback)

Check failure on line 44 in src/components/Modal/ReactNativeModal/Modal.tsx

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

Avoid calling a function directly in the useState initializer. Use an initializer function instead (a callback)

Check failure on line 44 in src/components/Modal/ReactNativeModal/Modal.tsx

View workflow job for this annotation

GitHub Actions / ESLint check

Avoid calling a function directly in the useState initializer. Use an initializer function instead (a callback)

Check failure on line 44 in src/components/Modal/ReactNativeModal/Modal.tsx

View workflow job for this annotation

GitHub Actions / ESLint check

Avoid calling a function directly in the useState initializer. Use an initializer function instead (a callback)
const [pan, setPan] = useState<Animated.ValueXY>(new Animated.ValueXY());
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 @@

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 @@
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 @@
}

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

useEffect(() => {
Expand Down Expand Up @@ -200,33 +188,31 @@

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;

Check failure on line 17 in src/components/Modal/ReactNativeModal/panResponders.ts

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

'value' is deprecated. Use the new `.get()` and `.set(value)` methods instead
}
if (directions.includes('left') && dx < 0) {
// eslint-disable-next-line no-param-reassign
Xoffset.value = dx;

Check failure on line 21 in src/components/Modal/ReactNativeModal/panResponders.ts

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

'value' is deprecated. Use the new `.get()` and `.set(value)` methods instead
}
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 @@
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 @@
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 @@
},
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 @@
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
Loading