import * as React from 'react'; import { I18nManager, Keyboard, Platform, StatusBar, StyleSheet, useWindowDimensions, type ViewProps, } from 'react-native'; import Animated, { interpolate, ReduceMotion, runOnJS, useAnimatedProps, useAnimatedStyle, useDerivedValue, useSharedValue, withSpring, } from 'react-native-reanimated'; import useLatestCallback from 'use-latest-callback'; import type { DrawerProps } from '../types'; import { DrawerGestureContext } from '../utils/DrawerGestureContext'; import { DrawerProgressContext } from '../utils/DrawerProgressContext'; import { getDrawerWidthNative } from '../utils/getDrawerWidth'; import { Gesture, GestureDetector, GestureHandlerRootView, GestureState, } from './GestureHandler'; import { InteractionManager } from './InteractionManager'; import { Overlay } from './Overlay'; const SWIPE_EDGE_WIDTH = 32; const SWIPE_MIN_OFFSET = 5; const SWIPE_MIN_DISTANCE = 60; const SWIPE_MIN_VELOCITY = 500; const PROGRESS_EPSILON = 0.05; const minmax = (value: number, start: number, end: number) => { 'worklet'; return Math.min(Math.max(value, start), end); }; export function Drawer({ layout: customLayout, direction = I18nManager.getConstants().isRTL ? 'rtl' : 'ltr', drawerPosition = direction === 'rtl' ? 'right' : 'left', drawerStyle, drawerType = 'front', configureGestureHandler, hideStatusBarOnOpen = false, keyboardDismissMode = 'on-drag', onClose, onOpen, onGestureStart, onGestureCancel, onGestureEnd, onTransitionStart, onTransitionEnd, open, overlayStyle, overlayAccessibilityLabel, statusBarAnimation = 'slide', swipeEnabled = Platform.OS !== 'web' && Platform.OS !== 'windows' && Platform.OS !== 'macos', swipeEdgeWidth = SWIPE_EDGE_WIDTH, swipeMinDistance = SWIPE_MIN_DISTANCE, swipeMinVelocity = SWIPE_MIN_VELOCITY, renderDrawerContent, children, style, }: DrawerProps) { const windowDimensions = useWindowDimensions(); const layout = customLayout ?? windowDimensions; const drawerWidth = getDrawerWidthNative({ layout, drawerStyle }); 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.useLayoutEffect(() => { hideStatusBar(isOpen); return () => hideStatusBar(false); }, [isOpen, hideStatusBarOnOpen, statusBarAnimation, hideStatusBar]); const interactionHandleRef = React.useRef(undefined); const startInteraction = useLatestCallback(() => { interactionHandleRef.current = InteractionManager?.createInteractionHandle(); }); const endInteraction = useLatestCallback(() => { if (interactionHandleRef.current != null) { InteractionManager?.clearInteractionHandle(interactionHandleRef.current); interactionHandleRef.current = undefined; } }); const hideKeyboard = useLatestCallback(() => { if (keyboardDismissMode === 'on-drag') { Keyboard.dismiss(); } }); const onGestureBegin = useLatestCallback(() => { onGestureStart?.(); startInteraction(); hideKeyboard(); hideStatusBar(true); }); const onGestureFinish = useLatestCallback(() => { onGestureEnd?.(); endInteraction(); }); const onGestureAbort = useLatestCallback(() => { onGestureCancel?.(); endInteraction(); }); const hitSlop = React.useMemo( () => 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 }, [isRight, isOpen, swipeEdgeWidth] ); const touchStartX = useSharedValue(0); const touchX = useSharedValue(0); const translationX = useSharedValue(getDrawerTranslationX(open)); const gestureState = useSharedValue(GestureState.UNDETERMINED); const onOpenLatest = useLatestCallback(onOpen); const onCloseLatest = useLatestCallback(onClose); const onAnimationStart = useLatestCallback((open: boolean) => { if (drawerType !== 'permanent') { hideKeyboard(); } onTransitionStart?.(!open); }); const onAnimationEnd = useLatestCallback( (open: boolean, finished?: boolean) => { if (!finished) { return; } onTransitionEnd?.(!open); } ); const toggleDrawer = React.useCallback( (open: boolean, velocity?: number) => { 'worklet'; const translateX = getDrawerTranslationX(open); if (velocity === undefined) { runOnJS(onAnimationStart)(open); } touchStartX.value = 0; touchX.value = 0; translationX.value = withSpring( translateX, { velocity, stiffness: 1000, damping: 500, mass: 3, overshootClamping: true, reduceMotion: ReduceMotion.Never, }, (finished) => runOnJS(onAnimationEnd)(open, finished) ); if (open) { runOnJS(onOpenLatest)(); } else { runOnJS(onCloseLatest)(); } }, [ getDrawerTranslationX, onAnimationEnd, onAnimationStart, onCloseLatest, onOpenLatest, touchStartX, touchX, translationX, ] ); const hasDrawerWidth = drawerWidth > 0; const previousHasDrawerWidthRef = React.useRef(hasDrawerWidth); const isHidden = useSharedValue(!isOpen && !hasDrawerWidth); React.useLayoutEffect(() => { if ( hasDrawerWidth !== previousHasDrawerWidthRef.current && isHidden.value ) { // Update translationX without animation after measurement // This avoids flash of animation on initial render with async layout // We only want to animate it when something changes after measurement translationX.value = getDrawerTranslationX(open); // Unhide the drawer only after we set the correct translationX // Otherwise the drawer might briefly flash in the wrong position isHidden.value = false; } previousHasDrawerWidthRef.current = hasDrawerWidth; }, [getDrawerTranslationX, hasDrawerWidth, isHidden, open, translationX]); React.useLayoutEffect(() => toggleDrawer(open), [open, toggleDrawer]); const startX = useSharedValue(0); const pan = React.useMemo(() => { let panGesture = Gesture?.Pan() .onBegin((event) => { 'worklet'; startX.value = translationX.value; gestureState.value = event.state; touchStartX.value = event.x; }) .onStart(() => { 'worklet'; runOnJS(onGestureBegin)(); }) .onChange((event) => { 'worklet'; touchX.value = event.x; translationX.value = startX.value + event.translationX; gestureState.value = event.state; }) .onEnd((event, success) => { 'worklet'; gestureState.value = event.state; if (!success) { runOnJS(onGestureAbort)(); } const nextOpen = (Math.abs(event.translationX) > SWIPE_MIN_OFFSET && Math.abs(event.velocityX) > swipeMinVelocity) || Math.abs(event.translationX) > swipeMinDistance ? 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(onGestureFinish)(); }) .activeOffsetX([-SWIPE_MIN_OFFSET, SWIPE_MIN_OFFSET]) .failOffsetY([-SWIPE_MIN_OFFSET, SWIPE_MIN_OFFSET]) .hitSlop(hitSlop) .enabled(drawerType !== 'permanent' && swipeEnabled); if (panGesture && configureGestureHandler) { panGesture = configureGestureHandler(panGesture); } return panGesture; }, [ configureGestureHandler, drawerPosition, drawerType, gestureState, hitSlop, onGestureBegin, onGestureAbort, onGestureFinish, open, startX, swipeEnabled, swipeMinDistance, swipeMinVelocity, toggleDrawer, touchStartX, touchX, translationX, ]); 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 : layout.width - drawerWidth - touchStartX.value, 0, layout.width ) : 0; const translateX = drawerPosition === 'left' ? minmax(translationX.value + touchDistance, -drawerWidth, 0) : minmax(translationX.value - touchDistance, 0, drawerWidth); return translateX; }); const drawerAnimatedStyle = useAnimatedStyle(() => { const distanceFromEdge = Math.max(layout.width - drawerWidth, 0); return { // FIXME: Reanimated skips committing to the shadow tree if no layout props are animated // This results in pressables not getting their correct position and can't be pressed // So we animate the zIndex to force the commit // For 'back' type: always keep drawer behind content (-1 when open, 0 when closed/animating) // For other types: 0 when open, 1 when closed/animating zIndex: drawerType === 'back' ? translateX.value === 0 ? -1 : 0 : translateX.value === 0 ? 0 : 1, display: isHidden.value ? 'none' : 'flex', transform: drawerType === 'permanent' ? // Reanimated needs the property to be present, but it results in Browser bug // https://bugs.chromium.org/p/chromium/issues/detail?id=20574 [] : [ { translateX: // The drawer stays in place when `drawerType` is `back` (drawerType === 'back' ? 0 : translateX.value) + (direction === 'rtl' ? drawerPosition === 'left' ? -distanceFromEdge : 0 : drawerPosition === 'left' ? 0 : distanceFromEdge), }, ], }; }, [ direction, drawerPosition, drawerType, drawerWidth, isHidden, layout.width, translateX, ]); const contentAnimatedStyle = useAnimatedStyle(() => { return { // FIXME: Force Reanimated to commit to the shadow tree zIndex: translateX.value === 0 ? 0 : drawerType === 'back' ? 2 : 1, transform: drawerType === 'permanent' ? // Reanimated needs the property to be present, but it results in Browser bug // https://bugs.chromium.org/p/chromium/issues/detail?id=20574 [] : [ { translateX: // The screen content stays in place when `drawerType` is `front` drawerType === 'front' ? 0 : translateX.value + drawerWidth * (drawerPosition === 'left' ? 1 : -1), }, ], }; }, [drawerPosition, drawerType, drawerWidth, translateX]); const progress = useDerivedValue(() => { return drawerType === 'permanent' ? 1 : interpolate( translateX.value, [getDrawerTranslationX(false), getDrawerTranslationX(true)], [0, 1] ); }); const contentAnimatedProps = useAnimatedProps(() => { const hidden = drawerType !== 'permanent' && progress.value >= 1 - PROGRESS_EPSILON; if (Platform.OS === 'android') { const importantForAccessibility: ViewProps['importantForAccessibility'] = hidden ? 'no-hide-descendants' : 'auto'; return { importantForAccessibility }; } return { accessibilityElementsHidden: hidden }; }, [drawerType, progress]); const drawerContentAnimatedProps = useAnimatedProps(() => { const hidden = drawerType !== 'permanent' && progress.value < 1 - PROGRESS_EPSILON; if (Platform.OS === 'android') { const importantForAccessibility: ViewProps['importantForAccessibility'] = hidden ? 'no-hide-descendants' : 'auto'; return { importantForAccessibility }; } return { accessibilityElementsHidden: hidden }; }, [drawerType, progress]); return ( {/* Immediate child of gesture handler needs to be an Animated.View */} {children} {drawerType !== 'permanent' ? ( toggleDrawer(false)} style={overlayStyle} accessibilityLabel={overlayAccessibilityLabel} /> ) : null} {renderDrawerContent()} ); } const styles = StyleSheet.create({ container: { flex: 1, }, drawer: { top: 0, bottom: 0, maxWidth: '100%', backgroundColor: 'white', }, 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' }, }), }, });