UNPKG

react-native-drawer-layout

Version:
321 lines (313 loc) 13.6 kB
"use strict"; import * as React from 'react'; import { I18nManager, InteractionManager, Keyboard, Platform, StatusBar, StyleSheet, useWindowDimensions, View } from 'react-native'; import Animated, { interpolate, ReduceMotion, runOnJS, useAnimatedStyle, useDerivedValue, useSharedValue, withSpring } from 'react-native-reanimated'; import useLatestCallback from 'use-latest-callback'; import { DrawerGestureContext } from "../utils/DrawerGestureContext.js"; import { DrawerProgressContext } from "../utils/DrawerProgressContext.js"; import { getDrawerWidth } from "../utils/getDrawerWidth.js"; import { Gesture, GestureDetector, GestureHandlerRootView, GestureState } from './GestureHandler'; import { Overlay } from './Overlay'; import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; const SWIPE_EDGE_WIDTH = 32; const SWIPE_MIN_OFFSET = 5; const SWIPE_MIN_DISTANCE = 60; const SWIPE_MIN_VELOCITY = 500; const minmax = (value, start, end) => { '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 }) { const windowDimensions = useWindowDimensions(); const layout = customLayout ?? windowDimensions; const drawerWidth = getDrawerWidth({ layout, drawerStyle }); const isOpen = drawerType === 'permanent' ? true : open; const isRight = drawerPosition === 'right'; const getDrawerTranslationX = React.useCallback(open => { 'worklet'; if (drawerPosition === 'left') { return open ? 0 : -drawerWidth; } return open ? 0 : drawerWidth; }, [drawerPosition, drawerWidth]); const hideStatusBar = React.useCallback(hide => { 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 = useLatestCallback(() => { interactionHandleRef.current = InteractionManager.createInteractionHandle(); }); const endInteraction = useLatestCallback(() => { if (interactionHandleRef.current != null) { InteractionManager.clearInteractionHandle(interactionHandleRef.current); interactionHandleRef.current = null; } }); 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 onAnimationStart = useLatestCallback(open => { onTransitionStart?.(!open); }); const onAnimationEnd = useLatestCallback((open, finished) => { if (!finished) { return; } onTransitionEnd?.(!open); }); const toggleDrawer = React.useCallback((open, velocity) => { '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, restDisplacementThreshold: 0.01, restSpeedThreshold: 0.01, reduceMotion: ReduceMotion.Never }, finished => runOnJS(onAnimationEnd)(open, finished)); if (open) { runOnJS(onOpen)(); } else { runOnJS(onClose)(); } }, [getDrawerTranslationX, onAnimationEnd, onAnimationStart, onClose, onOpen, touchStartX, touchX, translationX]); React.useEffect(() => 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.translationX) > 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 = layout.width - drawerWidth; 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 - it doesn't affect the drawer visually zIndex: translateX.value === 0 ? 0 : 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 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, 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]); }); return /*#__PURE__*/_jsx(GestureHandlerRootView, { style: [styles.container, style], children: /*#__PURE__*/_jsx(DrawerProgressContext.Provider, { value: progress, children: /*#__PURE__*/_jsx(DrawerGestureContext.Provider, { value: pan, children: /*#__PURE__*/_jsx(GestureDetector, { gesture: pan, children: /*#__PURE__*/_jsxs(Animated.View, { style: [styles.main, { flexDirection: drawerType === 'permanent' ? isRight && direction === 'ltr' || !isRight && direction === 'rtl' ? 'row' : 'row-reverse' : 'row' }], children: [/*#__PURE__*/_jsxs(Animated.View, { style: [styles.content, contentAnimatedStyle], children: [/*#__PURE__*/_jsx(View, { accessibilityElementsHidden: isOpen && drawerType !== 'permanent', importantForAccessibility: isOpen && drawerType !== 'permanent' ? 'no-hide-descendants' : 'auto', style: styles.content, children: children }), drawerType !== 'permanent' ? /*#__PURE__*/_jsx(Overlay, { open: open, progress: progress, onPress: () => toggleDrawer(false), style: overlayStyle, accessibilityLabel: overlayAccessibilityLabel }) : null] }), /*#__PURE__*/_jsx(Animated.View, { removeClippedSubviews: Platform.OS !== 'ios', style: [styles.drawer, { width: drawerWidth, position: drawerType === 'permanent' ? 'relative' : 'absolute', zIndex: drawerType === 'back' ? -1 : 0 }, drawerAnimatedStyle, drawerStyle], children: 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' } }) } }); //# sourceMappingURL=Drawer.native.js.map