refactor: add reanimated 2 implementation for drawer

This commit is contained in:
Satyajit Sahoo
2021-03-31 12:56:30 +02:00
parent d85a4fd8ed
commit 1149e718c1
9 changed files with 617 additions and 169 deletions

View File

@@ -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';

View File

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

View 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);

View 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;
}

View File

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

View File

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

View 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' },
}),
},
});

View 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;