diff --git a/packages/drawer/src/index.tsx b/packages/drawer/src/index.tsx index f30df89f..56718d26 100644 --- a/packages/drawer/src/index.tsx +++ b/packages/drawer/src/index.tsx @@ -18,6 +18,9 @@ export { default as DrawerToggleButton } from './views/DrawerToggleButton'; */ export { default as DrawerGestureContext } from './utils/DrawerGestureContext'; +export { default as DrawerProgressContext } from './utils/DrawerProgressContext'; +export { default as useDrawerProgress } from './utils/useDrawerProgress'; + export { default as getDrawerStatusFromState } from './utils/getDrawerStatusFromState'; export { default as useDrawerStatus } from './utils/useDrawerStatus'; diff --git a/packages/drawer/src/types.tsx b/packages/drawer/src/types.tsx index fdb9e6cf..507ecb7b 100644 --- a/packages/drawer/src/types.tsx +++ b/packages/drawer/src/types.tsx @@ -1,6 +1,8 @@ import type { StyleProp, ViewStyle, TextStyle } from 'react-native'; -import type Animated from 'react-native-reanimated'; -import type { PanGestureHandlerProperties } from 'react-native-gesture-handler'; +import type { + PanGestureHandler, + PanGestureHandlerProperties, +} from 'react-native-gesture-handler'; import type { Route, ParamListBase, @@ -33,6 +35,14 @@ export type DrawerNavigationConfig = { * Defaults to `true`. */ detachInactiveScreens?: boolean; + /** + * Whether to use the legacy implementation based on Reanimated 1. + * The new implementation based on Reanimated 2 will perform better, + * but you need additional configuration and need to use Hermes with Flipper to debug. + * + * Defaults to `false` if Reanimated 2 is configured in the project, otherwise `true`. + */ + useLegacyImplementation?: boolean; }; export type DrawerNavigationOptions = HeaderOptions & { @@ -207,11 +217,6 @@ export type DrawerContentComponentProps = { state: DrawerNavigationState; navigation: DrawerNavigationHelpers; descriptors: DrawerDescriptorMap; - /** - * Animated node which represents the current progress of the drawer's open state. - * `0` is closed, `1` is open. - */ - progress: Animated.Node; }; export type DrawerHeaderProps = { @@ -268,3 +273,24 @@ export type DrawerDescriptor = Descriptor< >; export type DrawerDescriptorMap = Record; + +export type DrawerProps = { + dimensions: { width: number; height: number }; + drawerPosition: 'left' | 'right'; + drawerStyle?: StyleProp; + drawerType: 'front' | 'back' | 'slide' | 'permanent'; + gestureHandlerProps?: React.ComponentProps; + hideStatusBarOnOpen: boolean; + keyboardDismissMode: 'none' | 'on-drag'; + onClose: () => void; + onOpen: () => void; + open: boolean; + overlayStyle?: StyleProp; + renderDrawerContent: () => React.ReactNode; + renderSceneContent: () => React.ReactNode; + statusBarAnimation: 'slide' | 'none' | 'fade'; + swipeDistanceThreshold: number; + swipeEdgeWidth: number; + swipeEnabled: boolean; + swipeVelocityThreshold: number; +}; diff --git a/packages/drawer/src/utils/DrawerProgressContext.tsx b/packages/drawer/src/utils/DrawerProgressContext.tsx new file mode 100644 index 00000000..13203868 --- /dev/null +++ b/packages/drawer/src/utils/DrawerProgressContext.tsx @@ -0,0 +1,6 @@ +import * as React from 'react'; +import type Animated from 'react-native-reanimated'; + +export default React.createContext< + Readonly> | Animated.Node | undefined +>(undefined); diff --git a/packages/drawer/src/utils/useDrawerProgress.tsx b/packages/drawer/src/utils/useDrawerProgress.tsx new file mode 100644 index 00000000..28a364c3 --- /dev/null +++ b/packages/drawer/src/utils/useDrawerProgress.tsx @@ -0,0 +1,17 @@ +import * as React from 'react'; +import type Animated from 'react-native-reanimated'; +import DrawerProgressContext from './DrawerProgressContext'; + +export default function useDrawerProgress(): + | Readonly> + | Animated.Node { + const progress = React.useContext(DrawerProgressContext); + + if (progress === undefined) { + throw new Error( + "Couldn't find a drawer. Is your component inside a drawer navigator?" + ); + } + + return progress; +} diff --git a/packages/drawer/src/views/DrawerView.tsx b/packages/drawer/src/views/DrawerView.tsx index 95da4459..56680727 100644 --- a/packages/drawer/src/views/DrawerView.tsx +++ b/packages/drawer/src/views/DrawerView.tsx @@ -8,6 +8,7 @@ import { } from 'react-native'; import { ScreenContainer } from 'react-native-screens'; import { useSafeAreaFrame } from 'react-native-safe-area-context'; +import Animated from 'react-native-reanimated'; import { NavigationHelpersContext, DrawerNavigationState, @@ -26,7 +27,6 @@ import { GestureHandlerRootView } from './GestureHandler'; import ScreenFallback from './ScreenFallback'; import DrawerToggleButton from './DrawerToggleButton'; import DrawerContent from './DrawerContent'; -import Drawer from './Drawer'; import DrawerStatusContext from '../utils/DrawerStatusContext'; import DrawerPositionContext from '../utils/DrawerPositionContext'; import getDrawerStatusFromState from '../utils/getDrawerStatusFromState'; @@ -37,6 +37,7 @@ import type { DrawerContentComponentProps, DrawerHeaderProps, DrawerNavigationProp, + DrawerProps, } from '../types'; type Props = DrawerNavigationConfig & { @@ -76,7 +77,13 @@ function DrawerViewBase({ ), detachInactiveScreens = true, + // @ts-expect-error: the type definitions are incomplete + useLegacyImplementation = !Animated.isConfigured?.(), }: Props) { + const Drawer: React.ComponentType = useLegacyImplementation + ? require('./legacy/Drawer').default + : require('./modern/Drawer').default; + const focusedRouteKey = state.routes[state.index].key; const { drawerHideStatusBarOnOpen = false, @@ -84,13 +91,14 @@ function DrawerViewBase({ drawerStatusBarAnimation = 'slide', drawerStyle, drawerType = Platform.select({ ios: 'slide', default: 'front' }), - gestureEnabled, gestureHandlerProps, keyboardDismissMode = 'on-drag', overlayColor = 'rgba(0, 0, 0, 0.5)', - swipeEdgeWidth, - swipeEnabled, - swipeMinDistance, + swipeEdgeWidth = 32, + swipeEnabled = Platform.OS !== 'web' && + Platform.OS !== 'windows' && + Platform.OS !== 'macos', + swipeMinDistance = 60, } = descriptors[focusedRouteKey].options; const [loaded, setLoaded] = React.useState([focusedRouteKey]); @@ -163,11 +171,10 @@ function DrawerViewBase({ }; }, [drawerStatus, drawerType, handleDrawerClose, navigation]); - const renderDrawerContent = ({ progress }: any) => { + const renderDrawerContent = () => { return ( {drawerContent({ - progress: progress, state: state, navigation: navigation, descriptors: descriptors, @@ -243,11 +250,16 @@ function DrawerViewBase({ diff --git a/packages/drawer/src/views/Drawer.tsx b/packages/drawer/src/views/legacy/Drawer.tsx similarity index 76% rename from packages/drawer/src/views/Drawer.tsx rename to packages/drawer/src/views/legacy/Drawer.tsx index d9bed0e0..df3787b1 100644 --- a/packages/drawer/src/views/Drawer.tsx +++ b/packages/drawer/src/views/legacy/Drawer.tsx @@ -1,13 +1,11 @@ import * as React from 'react'; import { StyleSheet, - ViewStyle, LayoutChangeEvent, I18nManager, Platform, Keyboard, StatusBar, - StyleProp, View, InteractionManager, Pressable, @@ -17,8 +15,10 @@ import { PanGestureHandler, TapGestureHandler, GestureState, -} from './GestureHandler'; +} from '../GestureHandler'; import Overlay from './Overlay'; +import DrawerProgressContext from '../../utils/DrawerProgressContext'; +import type { DrawerProps } from '../../types'; const { Clock, @@ -56,7 +56,6 @@ const UNSET = -1; const DIRECTION_LEFT = 1; const DIRECTION_RIGHT = -1; -const SWIPE_DISTANCE_THRESHOLD_DEFAULT = 60; const SWIPE_DISTANCE_MINIMUM = 5; const DEFAULT_DRAWER_WIDTH = '80%'; @@ -75,47 +74,8 @@ const ANIMATED_ONE = new Animated.Value(1); type Binary = 0 | 1; -type Renderer = (props: { progress: Animated.Node }) => React.ReactNode; - -type Props = { - open: boolean; - onOpen: () => void; - onClose: () => void; - gestureEnabled: boolean; - swipeEnabled: boolean; - drawerPosition: 'left' | 'right'; - drawerType: 'front' | 'back' | 'slide' | 'permanent'; - keyboardDismissMode: 'none' | 'on-drag'; - swipeEdgeWidth: number; - swipeDistanceThreshold?: number; - swipeVelocityThreshold: number; - hideStatusBarOnOpen: boolean; - statusBarAnimation: 'slide' | 'none' | 'fade'; - overlayStyle?: StyleProp; - drawerStyle?: StyleProp; - renderDrawerContent: Renderer; - renderSceneContent: Renderer; - gestureHandlerProps?: React.ComponentProps; - dimensions: { width: number; height: number }; -}; - -export default class DrawerView extends React.Component { - static defaultProps = { - drawerPosition: I18nManager.isRTL ? 'left' : 'right', - drawerType: 'front', - gestureEnabled: true, - swipeEnabled: - Platform.OS !== 'web' && - Platform.OS !== 'windows' && - Platform.OS !== 'macos', - swipeEdgeWidth: 32, - swipeVelocityThreshold: 500, - keyboardDismissMode: 'on-drag', - hideStatusBarOnOpen: false, - statusBarAnimation: 'slide', - }; - - componentDidUpdate(prevProps: Props) { +export default class DrawerView extends React.Component { + componentDidUpdate(prevProps: DrawerProps) { const { open, drawerPosition, @@ -150,11 +110,7 @@ export default class DrawerView extends React.Component { } if (prevProps.swipeDistanceThreshold !== swipeDistanceThreshold) { - this.swipeDistanceThreshold.setValue( - swipeDistanceThreshold !== undefined - ? swipeDistanceThreshold - : SWIPE_DISTANCE_THRESHOLD_DEFAULT - ); + this.swipeDistanceThreshold.setValue(swipeDistanceThreshold); } if (prevProps.swipeVelocityThreshold !== swipeVelocityThreshold) { @@ -283,9 +239,7 @@ export default class DrawerView extends React.Component { ); private swipeDistanceThreshold = new Value( - this.props.swipeDistanceThreshold !== undefined - ? this.props.swipeDistanceThreshold - : SWIPE_DISTANCE_THRESHOLD_DEFAULT + this.props.swipeDistanceThreshold ); private swipeVelocityThreshold = new Value( this.props.swipeVelocityThreshold @@ -547,7 +501,6 @@ export default class DrawerView extends React.Component { render() { const { open, - gestureEnabled, swipeEnabled, drawerPosition, drawerType, @@ -597,108 +550,109 @@ export default class DrawerView extends React.Component { const progress = drawerType === 'permanent' ? ANIMATED_ONE : this.progress; return ( - - + - - {renderSceneContent({ progress })} - - { - // Disable overlay if sidebar is permanent - drawerType === 'permanent' ? null : Platform.OS === 'web' || - Platform.OS === 'windows' || - Platform.OS === 'macos' ? ( - this.toggleDrawer(false) : undefined - } - > - - - ) : ( - - - - ) - } - - - {drawerType === 'permanent' ? null : ( - (this.currentOpenValue = false)), - ]), - ]), - ])} - /> - )} - - {renderDrawerContent({ progress })} + + + {renderSceneContent()} + + { + // Disable overlay if sidebar is permanent + drawerType === 'permanent' ? null : Platform.OS === 'web' || + Platform.OS === 'windows' || + Platform.OS === 'macos' ? ( + this.toggleDrawer(false)}> + + + ) : ( + + + + ) + } + + + {drawerType === 'permanent' ? null : ( + (this.currentOpenValue = false)), + ]), + ]), + ])} + /> + )} + + {renderDrawerContent()} + - - + + ); } } diff --git a/packages/drawer/src/views/Overlay.tsx b/packages/drawer/src/views/legacy/Overlay.tsx similarity index 100% rename from packages/drawer/src/views/Overlay.tsx rename to packages/drawer/src/views/legacy/Overlay.tsx diff --git a/packages/drawer/src/views/modern/Drawer.tsx b/packages/drawer/src/views/modern/Drawer.tsx new file mode 100644 index 00000000..fb025933 --- /dev/null +++ b/packages/drawer/src/views/modern/Drawer.tsx @@ -0,0 +1,379 @@ +import * as React from 'react'; +import { + InteractionManager, + Keyboard, + Platform, + StatusBar, + StyleSheet, + View, +} from 'react-native'; +import { + PanGestureHandler, + PanGestureHandlerGestureEvent, + State as GestureState, +} from 'react-native-gesture-handler'; +import Animated, { + interpolate, + runOnJS, + useAnimatedGestureHandler, + useAnimatedStyle, + useDerivedValue, + useSharedValue, + withSpring, +} from 'react-native-reanimated'; +import type { DrawerProps } from '../../types'; +import DrawerProgressContext from '../../utils/DrawerProgressContext'; +import Overlay from './Overlay'; + +const SWIPE_DISTANCE_MINIMUM = 5; +const DEFAULT_DRAWER_WIDTH = '80%'; + +const minmax = (value: number, start: number, end: number) => { + 'worklet'; + + return Math.min(Math.max(value, start), end); +}; + +export default function Drawer({ + dimensions, + drawerPosition, + drawerStyle, + drawerType, + gestureHandlerProps, + hideStatusBarOnOpen, + keyboardDismissMode, + onClose, + onOpen, + open, + overlayStyle, + renderDrawerContent, + renderSceneContent, + statusBarAnimation, + swipeDistanceThreshold, + swipeEdgeWidth, + swipeEnabled, + swipeVelocityThreshold, +}: DrawerProps) { + const getDrawerWidth = (): number => { + const { width = DEFAULT_DRAWER_WIDTH } = + StyleSheet.flatten(drawerStyle) || {}; + + if (typeof width === 'string' && width.endsWith('%')) { + // Try to calculate width if a percentage is given + const percentage = Number(width.replace(/%$/, '')); + + if (Number.isFinite(percentage)) { + return dimensions.width * (percentage / 100); + } + } + + return typeof width === 'number' ? width : 0; + }; + + const drawerWidth = getDrawerWidth(); + + const isOpen = drawerType === 'permanent' ? true : open; + const isRight = drawerPosition === 'right'; + + const getDrawerTranslationX = React.useCallback( + (open: boolean) => { + 'worklet'; + + if (drawerPosition === 'left') { + return open ? 0 : -drawerWidth; + } + + return open ? 0 : drawerWidth; + }, + [drawerPosition, drawerWidth] + ); + + const hideStatusBar = React.useCallback( + (hide: boolean) => { + if (hideStatusBarOnOpen) { + StatusBar.setHidden(hide, statusBarAnimation); + } + }, + [hideStatusBarOnOpen, statusBarAnimation] + ); + + React.useEffect(() => { + hideStatusBar(isOpen); + + return () => hideStatusBar(false); + }, [isOpen, hideStatusBarOnOpen, statusBarAnimation, hideStatusBar]); + + const interactionHandleRef = React.useRef(null); + + const startInteraction = () => { + interactionHandleRef.current = InteractionManager.createInteractionHandle(); + }; + + const endInteraction = () => { + if (interactionHandleRef.current != null) { + InteractionManager.clearInteractionHandle(interactionHandleRef.current); + interactionHandleRef.current = null; + } + }; + + const hideKeyboard = () => { + if (keyboardDismissMode === 'on-drag') { + Keyboard.dismiss(); + } + }; + + const onGestureStart = () => { + startInteraction(); + hideKeyboard(); + hideStatusBar(true); + }; + + const onGestureEnd = () => { + endInteraction(); + }; + + // FIXME: Currently hitSlop is broken when on Android when drawer is on right + // https://github.com/kmagiera/react-native-gesture-handler/issues/569 + const hitSlop = isRight + ? // Extend hitSlop to the side of the screen when drawer is closed + // This lets the user drag the drawer from the side of the screen + { right: 0, width: isOpen ? undefined : swipeEdgeWidth } + : { left: 0, width: isOpen ? undefined : swipeEdgeWidth }; + + const touchStartX = useSharedValue(0); + const touchX = useSharedValue(0); + const translationX = useSharedValue(getDrawerTranslationX(open)); + const gestureState = useSharedValue(GestureState.UNDETERMINED); + + const toggleDrawer = React.useCallback( + (open: boolean, velocity?: number) => { + 'worklet'; + + const translateX = getDrawerTranslationX(open); + + touchStartX.value = 0; + touchX.value = 0; + translationX.value = withSpring( + translateX, + { + velocity, + stiffness: 1000, + damping: 500, + mass: 3, + overshootClamping: true, + restDisplacementThreshold: 0.01, + restSpeedThreshold: 0.01, + }, + () => { + if (translationX.value === getDrawerTranslationX(true)) { + runOnJS(onOpen)(); + } else if (translationX.value === getDrawerTranslationX(false)) { + runOnJS(onClose)(); + } + } + ); + }, + [getDrawerTranslationX, onClose, onOpen, touchStartX, touchX, translationX] + ); + + React.useEffect(() => toggleDrawer(open), [open, toggleDrawer]); + + const onGestureEvent = useAnimatedGestureHandler< + PanGestureHandlerGestureEvent, + { startX: number } + >({ + onStart: (event, ctx) => { + ctx.startX = translationX.value; + gestureState.value = event.state; + touchStartX.value = event.x; + + runOnJS(onGestureStart)(); + }, + onActive: (event, ctx) => { + touchX.value = event.x; + translationX.value = ctx.startX + event.translationX; + gestureState.value = event.state; + }, + onEnd: (event) => { + gestureState.value = event.state; + + const nextOpen = + (Math.abs(event.translationX) > SWIPE_DISTANCE_MINIMUM && + Math.abs(event.translationX) > swipeVelocityThreshold) || + Math.abs(event.translationX) > swipeDistanceThreshold + ? drawerPosition === 'left' + ? // If swiped to right, open the drawer, otherwise close it + (event.velocityX === 0 ? event.translationX : event.velocityX) > 0 + : // If swiped to left, open the drawer, otherwise close it + (event.velocityX === 0 ? event.translationX : event.velocityX) < 0 + : open; + + toggleDrawer(nextOpen, event.velocityX); + runOnJS(onGestureEnd)(); + }, + }); + + const translateX = useDerivedValue(() => { + // Comment stolen from react-native-gesture-handler/DrawerLayout + // + // While closing the drawer when user starts gesture outside of its area (in greyed + // out part of the window), we want the drawer to follow only once finger reaches the + // edge of the drawer. + // E.g. on the diagram below drawer is illustrate by X signs and the greyed out area by + // dots. The touch gesture starts at '*' and moves left, touch path is indicated by + // an arrow pointing left + // 1) +---------------+ 2) +---------------+ 3) +---------------+ 4) +---------------+ + // |XXXXXXXX|......| |XXXXXXXX|......| |XXXXXXXX|......| |XXXXX|.........| + // |XXXXXXXX|......| |XXXXXXXX|......| |XXXXXXXX|......| |XXXXX|.........| + // |XXXXXXXX|......| |XXXXXXXX|......| |XXXXXXXX|......| |XXXXX|.........| + // |XXXXXXXX|......| |XXXXXXXX|.<-*..| |XXXXXXXX|<--*..| |XXXXX|<-----*..| + // |XXXXXXXX|......| |XXXXXXXX|......| |XXXXXXXX|......| |XXXXX|.........| + // |XXXXXXXX|......| |XXXXXXXX|......| |XXXXXXXX|......| |XXXXX|.........| + // |XXXXXXXX|......| |XXXXXXXX|......| |XXXXXXXX|......| |XXXXX|.........| + // +---------------+ +---------------+ +---------------+ +---------------+ + // + // For the above to work properly we define animated value that will keep start position + // of the gesture. Then we use that value to calculate how much we need to subtract from + // the translationX. If the gesture started on the greyed out area we take the distance from the + // edge of the drawer to the start position. Otherwise we don't subtract at all and the + // drawer be pulled back as soon as you start the pan. + // + // This is used only when drawerType is "front" + const touchDistance = + drawerType === 'front' && gestureState.value === GestureState.ACTIVE + ? minmax( + drawerPosition === 'left' + ? touchStartX.value - drawerWidth + : dimensions.width - drawerWidth - touchStartX.value, + 0, + dimensions.width + ) + : 0; + + const translateX = + drawerPosition === 'left' + ? minmax(translationX.value + touchDistance, -drawerWidth, 0) + : minmax(translationX.value - touchDistance, 0, drawerWidth); + + return translateX; + }); + + const drawerAnimatedStyle = useAnimatedStyle(() => { + return { + transform: [ + { + translateX: + drawerType === 'permanent' || drawerType === 'back' + ? 0 + : translateX.value, + }, + ], + }; + }); + + const contentAnimatedStyle = useAnimatedStyle(() => { + return { + transform: [ + { + translateX: + drawerType === 'permanent' || drawerType === 'front' + ? 0 + : drawerPosition === 'left' + ? drawerWidth + translateX.value + : translateX.value - drawerWidth, + }, + ], + }; + }); + + const progress = useDerivedValue(() => { + return drawerType === 'permanent' + ? 1 + : interpolate( + translateX.value, + [getDrawerTranslationX(false), getDrawerTranslationX(true)], + [0, 1] + ); + }); + + return ( + + + {/* Immediate child of gesture handler needs to be an Animated.View */} + + + + {renderSceneContent()} + + {drawerType !== 'permanent' ? ( + toggleDrawer(false)} + style={overlayStyle} + /> + ) : null} + + + {renderDrawerContent()} + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + top: 0, + bottom: 0, + maxWidth: '100%', + width: DEFAULT_DRAWER_WIDTH, + }, + content: { + flex: 1, + }, + main: { + flex: 1, + ...Platform.select({ + // FIXME: We need to hide `overflowX` on Web so the translated content doesn't show offscreen. + // But adding `overflowX: 'hidden'` prevents content from collapsing the URL bar. + web: null, + default: { overflow: 'hidden' }, + }), + }, +}); diff --git a/packages/drawer/src/views/modern/Overlay.tsx b/packages/drawer/src/views/modern/Overlay.tsx new file mode 100644 index 00000000..92094c46 --- /dev/null +++ b/packages/drawer/src/views/modern/Overlay.tsx @@ -0,0 +1,56 @@ +import * as React from 'react'; +import { Pressable, Platform, StyleSheet } from 'react-native'; +import Animated, { useAnimatedStyle } from 'react-native-reanimated'; + +const PROGRESS_EPSILON = 0.05; + +type Props = React.ComponentProps & { + progress: Animated.SharedValue; + onPress: () => void; +}; + +const Overlay = React.forwardRef(function Overlay( + { progress, onPress, style, ...props }: Props, + ref: React.Ref +) { + const animatedStyle = useAnimatedStyle(() => { + return { + opacity: progress.value, + // We don't want the user to be able to press through the overlay when drawer is open + // One approach is to adjust the pointerEvents based on the progress + // But we can also send the overlay behind the screen + zIndex: progress.value > PROGRESS_EPSILON ? 0 : -1, + }; + }); + + return ( + + + + ); +}); + +const overlayStyle = Platform.select>({ + web: { + // Disable touch highlight on mobile Safari. + // WebkitTapHighlightColor must be used outside of StyleSheet.create because react-native-web will omit the property. + WebkitTapHighlightColor: 'transparent', + }, + default: {}, +}); + +const styles = StyleSheet.create({ + overlay: { + ...StyleSheet.absoluteFillObject, + backgroundColor: 'rgba(0, 0, 0, 0.5)', + }, + pressable: { + flex: 1, + }, +}); + +export default Overlay;