mirror of
https://github.com/zhigang1992/react-navigation.git
synced 2026-01-12 22:51:18 +08:00
refactor: add reanimated 2 implementation for drawer
This commit is contained in:
@@ -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';
|
||||
|
||||
|
||||
@@ -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<ParamListBase>;
|
||||
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<number>;
|
||||
};
|
||||
|
||||
export type DrawerHeaderProps = {
|
||||
@@ -268,3 +273,24 @@ export type DrawerDescriptor = Descriptor<
|
||||
>;
|
||||
|
||||
export type DrawerDescriptorMap = Record<string, DrawerDescriptor>;
|
||||
|
||||
export type DrawerProps = {
|
||||
dimensions: { width: number; height: number };
|
||||
drawerPosition: 'left' | 'right';
|
||||
drawerStyle?: StyleProp<ViewStyle>;
|
||||
drawerType: 'front' | 'back' | 'slide' | 'permanent';
|
||||
gestureHandlerProps?: React.ComponentProps<typeof PanGestureHandler>;
|
||||
hideStatusBarOnOpen: boolean;
|
||||
keyboardDismissMode: 'none' | 'on-drag';
|
||||
onClose: () => void;
|
||||
onOpen: () => void;
|
||||
open: boolean;
|
||||
overlayStyle?: StyleProp<ViewStyle>;
|
||||
renderDrawerContent: () => React.ReactNode;
|
||||
renderSceneContent: () => React.ReactNode;
|
||||
statusBarAnimation: 'slide' | 'none' | 'fade';
|
||||
swipeDistanceThreshold: number;
|
||||
swipeEdgeWidth: number;
|
||||
swipeEnabled: boolean;
|
||||
swipeVelocityThreshold: number;
|
||||
};
|
||||
|
||||
6
packages/drawer/src/utils/DrawerProgressContext.tsx
Normal file
6
packages/drawer/src/utils/DrawerProgressContext.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import * as React from 'react';
|
||||
import type Animated from 'react-native-reanimated';
|
||||
|
||||
export default React.createContext<
|
||||
Readonly<Animated.SharedValue<number>> | Animated.Node<number> | undefined
|
||||
>(undefined);
|
||||
17
packages/drawer/src/utils/useDrawerProgress.tsx
Normal file
17
packages/drawer/src/utils/useDrawerProgress.tsx
Normal file
@@ -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.SharedValue<number>>
|
||||
| Animated.Node<number> {
|
||||
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;
|
||||
}
|
||||
@@ -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({
|
||||
<DrawerContent {...props} />
|
||||
),
|
||||
detachInactiveScreens = true,
|
||||
// @ts-expect-error: the type definitions are incomplete
|
||||
useLegacyImplementation = !Animated.isConfigured?.(),
|
||||
}: Props) {
|
||||
const Drawer: React.ComponentType<DrawerProps> = 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 (
|
||||
<DrawerPositionContext.Provider value={drawerPosition}>
|
||||
{drawerContent({
|
||||
progress: progress,
|
||||
state: state,
|
||||
navigation: navigation,
|
||||
descriptors: descriptors,
|
||||
@@ -243,11 +250,16 @@ function DrawerViewBase({
|
||||
<DrawerStatusContext.Provider value={drawerStatus}>
|
||||
<Drawer
|
||||
open={drawerStatus !== 'closed'}
|
||||
gestureEnabled={gestureEnabled}
|
||||
swipeEnabled={swipeEnabled}
|
||||
onOpen={handleDrawerOpen}
|
||||
onClose={handleDrawerClose}
|
||||
gestureHandlerProps={gestureHandlerProps}
|
||||
swipeEnabled={swipeEnabled}
|
||||
swipeEdgeWidth={swipeEdgeWidth}
|
||||
swipeVelocityThreshold={500}
|
||||
swipeDistanceThreshold={swipeMinDistance}
|
||||
hideStatusBarOnOpen={drawerHideStatusBarOnOpen}
|
||||
statusBarAnimation={drawerStatusBarAnimation}
|
||||
keyboardDismissMode={keyboardDismissMode}
|
||||
drawerType={drawerType}
|
||||
drawerPosition={drawerPosition}
|
||||
drawerStyle={[
|
||||
@@ -268,13 +280,8 @@ function DrawerViewBase({
|
||||
drawerStyle,
|
||||
]}
|
||||
overlayStyle={{ backgroundColor: overlayColor }}
|
||||
swipeEdgeWidth={swipeEdgeWidth}
|
||||
swipeDistanceThreshold={swipeMinDistance}
|
||||
hideStatusBarOnOpen={drawerHideStatusBarOnOpen}
|
||||
statusBarAnimation={drawerStatusBarAnimation}
|
||||
renderDrawerContent={renderDrawerContent}
|
||||
renderSceneContent={renderSceneContent}
|
||||
keyboardDismissMode={keyboardDismissMode}
|
||||
dimensions={dimensions}
|
||||
/>
|
||||
</DrawerStatusContext.Provider>
|
||||
|
||||
@@ -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<number> }) => 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<ViewStyle>;
|
||||
drawerStyle?: StyleProp<ViewStyle>;
|
||||
renderDrawerContent: Renderer;
|
||||
renderSceneContent: Renderer;
|
||||
gestureHandlerProps?: React.ComponentProps<typeof PanGestureHandler>;
|
||||
dimensions: { width: number; height: number };
|
||||
};
|
||||
|
||||
export default class DrawerView extends React.Component<Props> {
|
||||
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<DrawerProps> {
|
||||
componentDidUpdate(prevProps: DrawerProps) {
|
||||
const {
|
||||
open,
|
||||
drawerPosition,
|
||||
@@ -150,11 +110,7 @@ export default class DrawerView extends React.Component<Props> {
|
||||
}
|
||||
|
||||
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<Props> {
|
||||
);
|
||||
|
||||
private swipeDistanceThreshold = new Value<number>(
|
||||
this.props.swipeDistanceThreshold !== undefined
|
||||
? this.props.swipeDistanceThreshold
|
||||
: SWIPE_DISTANCE_THRESHOLD_DEFAULT
|
||||
this.props.swipeDistanceThreshold
|
||||
);
|
||||
private swipeVelocityThreshold = new Value<number>(
|
||||
this.props.swipeVelocityThreshold
|
||||
@@ -547,7 +501,6 @@ export default class DrawerView extends React.Component<Props> {
|
||||
render() {
|
||||
const {
|
||||
open,
|
||||
gestureEnabled,
|
||||
swipeEnabled,
|
||||
drawerPosition,
|
||||
drawerType,
|
||||
@@ -597,108 +550,109 @@ export default class DrawerView extends React.Component<Props> {
|
||||
const progress = drawerType === 'permanent' ? ANIMATED_ONE : this.progress;
|
||||
|
||||
return (
|
||||
<PanGestureHandler
|
||||
activeOffsetX={[-SWIPE_DISTANCE_MINIMUM, SWIPE_DISTANCE_MINIMUM]}
|
||||
failOffsetY={[-SWIPE_DISTANCE_MINIMUM, SWIPE_DISTANCE_MINIMUM]}
|
||||
onGestureEvent={this.handleGestureEvent}
|
||||
onHandlerStateChange={this.handleGestureStateChange}
|
||||
hitSlop={hitSlop}
|
||||
enabled={drawerType !== 'permanent' && gestureEnabled && swipeEnabled}
|
||||
{...gestureHandlerProps}
|
||||
>
|
||||
<Animated.View
|
||||
onLayout={this.handleContainerLayout}
|
||||
style={[
|
||||
styles.main,
|
||||
{
|
||||
flexDirection:
|
||||
drawerType === 'permanent' && !isRight ? 'row-reverse' : 'row',
|
||||
},
|
||||
]}
|
||||
<DrawerProgressContext.Provider value={progress}>
|
||||
<PanGestureHandler
|
||||
activeOffsetX={[-SWIPE_DISTANCE_MINIMUM, SWIPE_DISTANCE_MINIMUM]}
|
||||
failOffsetY={[-SWIPE_DISTANCE_MINIMUM, SWIPE_DISTANCE_MINIMUM]}
|
||||
onGestureEvent={this.handleGestureEvent}
|
||||
onHandlerStateChange={this.handleGestureStateChange}
|
||||
hitSlop={hitSlop}
|
||||
enabled={drawerType !== 'permanent' && swipeEnabled}
|
||||
{...gestureHandlerProps}
|
||||
>
|
||||
<Animated.View
|
||||
onLayout={this.handleContainerLayout}
|
||||
style={[
|
||||
styles.content,
|
||||
{ transform: [{ translateX: contentTranslateX }] },
|
||||
]}
|
||||
>
|
||||
<View
|
||||
accessibilityElementsHidden={isOpen && drawerType !== 'permanent'}
|
||||
importantForAccessibility={
|
||||
isOpen && drawerType !== 'permanent'
|
||||
? 'no-hide-descendants'
|
||||
: 'auto'
|
||||
}
|
||||
style={styles.content}
|
||||
>
|
||||
{renderSceneContent({ progress })}
|
||||
</View>
|
||||
{
|
||||
// Disable overlay if sidebar is permanent
|
||||
drawerType === 'permanent' ? null : Platform.OS === 'web' ||
|
||||
Platform.OS === 'windows' ||
|
||||
Platform.OS === 'macos' ? (
|
||||
<Pressable
|
||||
onPress={
|
||||
gestureEnabled ? () => this.toggleDrawer(false) : undefined
|
||||
}
|
||||
>
|
||||
<Overlay progress={progress} style={overlayStyle as any} />
|
||||
</Pressable>
|
||||
) : (
|
||||
<TapGestureHandler
|
||||
enabled={gestureEnabled}
|
||||
onHandlerStateChange={this.handleTapStateChange}
|
||||
>
|
||||
<Overlay progress={progress} style={overlayStyle as any} />
|
||||
</TapGestureHandler>
|
||||
)
|
||||
}
|
||||
</Animated.View>
|
||||
<Animated.Code
|
||||
// This is needed to make sure that container width updates with `setValue`
|
||||
// Without this, it won't update when not used in styles
|
||||
exec={this.containerWidth}
|
||||
/>
|
||||
{drawerType === 'permanent' ? null : (
|
||||
<Animated.Code
|
||||
exec={block([
|
||||
onChange(this.manuallyTriggerSpring, [
|
||||
cond(eq(this.manuallyTriggerSpring, TRUE), [
|
||||
set(this.nextIsOpen, FALSE),
|
||||
call([], () => (this.currentOpenValue = false)),
|
||||
]),
|
||||
]),
|
||||
])}
|
||||
/>
|
||||
)}
|
||||
<Animated.View
|
||||
accessibilityViewIsModal={isOpen && drawerType !== 'permanent'}
|
||||
removeClippedSubviews={Platform.OS !== 'ios'}
|
||||
onLayout={this.handleDrawerLayout}
|
||||
style={[
|
||||
styles.container,
|
||||
styles.main,
|
||||
{
|
||||
transform: [{ translateX: drawerTranslateX }],
|
||||
opacity: this.drawerOpacity,
|
||||
flexDirection:
|
||||
drawerType === 'permanent' && !isRight
|
||||
? 'row-reverse'
|
||||
: 'row',
|
||||
},
|
||||
drawerType === 'permanent'
|
||||
? // Without this, the `left`/`right` values don't get reset
|
||||
isRight
|
||||
? { right: 0 }
|
||||
: { left: 0 }
|
||||
: [
|
||||
styles.nonPermanent,
|
||||
isRight ? { right: offset } : { left: offset },
|
||||
{ zIndex: drawerType === 'back' ? -1 : 0 },
|
||||
],
|
||||
drawerStyle as any,
|
||||
]}
|
||||
>
|
||||
{renderDrawerContent({ progress })}
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.content,
|
||||
{ transform: [{ translateX: contentTranslateX }] },
|
||||
]}
|
||||
>
|
||||
<View
|
||||
accessibilityElementsHidden={
|
||||
isOpen && drawerType !== 'permanent'
|
||||
}
|
||||
importantForAccessibility={
|
||||
isOpen && drawerType !== 'permanent'
|
||||
? 'no-hide-descendants'
|
||||
: 'auto'
|
||||
}
|
||||
style={styles.content}
|
||||
>
|
||||
{renderSceneContent()}
|
||||
</View>
|
||||
{
|
||||
// Disable overlay if sidebar is permanent
|
||||
drawerType === 'permanent' ? null : Platform.OS === 'web' ||
|
||||
Platform.OS === 'windows' ||
|
||||
Platform.OS === 'macos' ? (
|
||||
<Pressable onPress={() => this.toggleDrawer(false)}>
|
||||
<Overlay progress={progress} style={overlayStyle as any} />
|
||||
</Pressable>
|
||||
) : (
|
||||
<TapGestureHandler
|
||||
onHandlerStateChange={this.handleTapStateChange}
|
||||
>
|
||||
<Overlay progress={progress} style={overlayStyle as any} />
|
||||
</TapGestureHandler>
|
||||
)
|
||||
}
|
||||
</Animated.View>
|
||||
<Animated.Code
|
||||
// This is needed to make sure that container width updates with `setValue`
|
||||
// Without this, it won't update when not used in styles
|
||||
exec={this.containerWidth}
|
||||
/>
|
||||
{drawerType === 'permanent' ? null : (
|
||||
<Animated.Code
|
||||
exec={block([
|
||||
onChange(this.manuallyTriggerSpring, [
|
||||
cond(eq(this.manuallyTriggerSpring, TRUE), [
|
||||
set(this.nextIsOpen, FALSE),
|
||||
call([], () => (this.currentOpenValue = false)),
|
||||
]),
|
||||
]),
|
||||
])}
|
||||
/>
|
||||
)}
|
||||
<Animated.View
|
||||
accessibilityViewIsModal={isOpen && drawerType !== 'permanent'}
|
||||
removeClippedSubviews={Platform.OS !== 'ios'}
|
||||
onLayout={this.handleDrawerLayout}
|
||||
style={[
|
||||
styles.container,
|
||||
{
|
||||
transform: [{ translateX: drawerTranslateX }],
|
||||
opacity: this.drawerOpacity,
|
||||
},
|
||||
drawerType === 'permanent'
|
||||
? // Without this, the `left`/`right` values don't get reset
|
||||
isRight
|
||||
? { right: 0 }
|
||||
: { left: 0 }
|
||||
: [
|
||||
styles.nonPermanent,
|
||||
isRight ? { right: offset } : { left: offset },
|
||||
{ zIndex: drawerType === 'back' ? -1 : 0 },
|
||||
],
|
||||
drawerStyle as any,
|
||||
]}
|
||||
>
|
||||
{renderDrawerContent()}
|
||||
</Animated.View>
|
||||
</Animated.View>
|
||||
</Animated.View>
|
||||
</PanGestureHandler>
|
||||
</PanGestureHandler>
|
||||
</DrawerProgressContext.Provider>
|
||||
);
|
||||
}
|
||||
}
|
||||
379
packages/drawer/src/views/modern/Drawer.tsx
Normal file
379
packages/drawer/src/views/modern/Drawer.tsx
Normal file
@@ -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<number | null>(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>(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 (
|
||||
<DrawerProgressContext.Provider value={progress}>
|
||||
<PanGestureHandler
|
||||
activeOffsetX={[-SWIPE_DISTANCE_MINIMUM, SWIPE_DISTANCE_MINIMUM]}
|
||||
failOffsetY={[-SWIPE_DISTANCE_MINIMUM, SWIPE_DISTANCE_MINIMUM]}
|
||||
hitSlop={hitSlop}
|
||||
enabled={drawerType !== 'permanent' && swipeEnabled}
|
||||
onGestureEvent={onGestureEvent}
|
||||
{...gestureHandlerProps}
|
||||
>
|
||||
{/* Immediate child of gesture handler needs to be an Animated.View */}
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.main,
|
||||
{
|
||||
flexDirection:
|
||||
drawerType === 'permanent' && !isRight ? 'row-reverse' : 'row',
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Animated.View style={[styles.content, contentAnimatedStyle]}>
|
||||
<View
|
||||
accessibilityElementsHidden={isOpen && drawerType !== 'permanent'}
|
||||
importantForAccessibility={
|
||||
isOpen && drawerType !== 'permanent'
|
||||
? 'no-hide-descendants'
|
||||
: 'auto'
|
||||
}
|
||||
style={styles.content}
|
||||
>
|
||||
{renderSceneContent()}
|
||||
</View>
|
||||
{drawerType !== 'permanent' ? (
|
||||
<Overlay
|
||||
progress={progress}
|
||||
onPress={() => toggleDrawer(false)}
|
||||
style={overlayStyle}
|
||||
/>
|
||||
) : null}
|
||||
</Animated.View>
|
||||
<Animated.View
|
||||
accessibilityViewIsModal={isOpen && drawerType !== 'permanent'}
|
||||
removeClippedSubviews={Platform.OS !== 'ios'}
|
||||
style={[
|
||||
styles.container,
|
||||
{
|
||||
position: drawerType === 'permanent' ? 'relative' : 'absolute',
|
||||
zIndex: drawerType === 'back' ? -1 : 0,
|
||||
},
|
||||
drawerAnimatedStyle,
|
||||
drawerStyle as any,
|
||||
]}
|
||||
>
|
||||
{renderDrawerContent()}
|
||||
</Animated.View>
|
||||
</Animated.View>
|
||||
</PanGestureHandler>
|
||||
</DrawerProgressContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
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' },
|
||||
}),
|
||||
},
|
||||
});
|
||||
56
packages/drawer/src/views/modern/Overlay.tsx
Normal file
56
packages/drawer/src/views/modern/Overlay.tsx
Normal file
@@ -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<typeof Animated.View> & {
|
||||
progress: Animated.SharedValue<number>;
|
||||
onPress: () => void;
|
||||
};
|
||||
|
||||
const Overlay = React.forwardRef(function Overlay(
|
||||
{ progress, onPress, style, ...props }: Props,
|
||||
ref: React.Ref<Animated.View>
|
||||
) {
|
||||
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 (
|
||||
<Animated.View
|
||||
{...props}
|
||||
ref={ref}
|
||||
style={[styles.overlay, overlayStyle, animatedStyle, style]}
|
||||
>
|
||||
<Pressable onPress={onPress} style={styles.pressable} />
|
||||
</Animated.View>
|
||||
);
|
||||
});
|
||||
|
||||
const overlayStyle = Platform.select<Record<string, string>>({
|
||||
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;
|
||||
Reference in New Issue
Block a user