diff --git a/packages/drawer/.eslintrc b/packages/drawer/.eslintrc index 448eedc9..20448bc9 100644 --- a/packages/drawer/.eslintrc +++ b/packages/drawer/.eslintrc @@ -10,5 +10,6 @@ "rules": { "import/no-unresolved": "off", + "eslint-comments/no-unlimited-disable": "off", } } diff --git a/packages/drawer/example/src/GestureInteraction.js b/packages/drawer/example/src/GestureInteraction.js index 24fe1906..7f5f3669 100644 --- a/packages/drawer/example/src/GestureInteraction.js +++ b/packages/drawer/example/src/GestureInteraction.js @@ -2,7 +2,10 @@ import React from 'react'; import { Button, WebView, View } from 'react-native'; import { MapView } from 'expo'; import { withNavigation } from 'react-navigation'; -import { createDrawerNavigator } from 'react-navigation-drawer'; +import { + createDrawerNavigator, + DrawerGestureContext, +} from 'react-navigation-drawer'; import { NativeViewGestureHandler } from 'react-native-gesture-handler'; @withNavigation @@ -41,9 +44,13 @@ class ContainerWithButtons extends React.Component { const MapScreen = () => ( - - - + + {ref => ( + + + + )} + ); @@ -53,12 +60,16 @@ MapScreen.navigationOptions = { const WebViewScreen = () => ( - - - + + {ref => ( + + + + )} + ); @@ -72,8 +83,8 @@ const DrawerExample = createDrawerNavigator( Web: WebViewScreen, }, { - edgeWidth: 100, - minSwipeDistance: 1, + edgeWidth: 70, + minSwipeDistance: 3, contentOptions: { activeTintColor: '#e91e63', }, diff --git a/packages/drawer/src/index.js b/packages/drawer/src/index.js index 03eecc18..0f0ca88f 100644 --- a/packages/drawer/src/index.js +++ b/packages/drawer/src/index.js @@ -30,4 +30,8 @@ module.exports = { get DrawerView() { return require('./views/DrawerView').default; }, + + get DrawerGestureContext() { + return require('./utils/DrawerGestureContext').default; + }, }; diff --git a/packages/drawer/src/navigators/__tests__/__snapshots__/createDrawerNavigator-test.js.snap b/packages/drawer/src/navigators/__tests__/__snapshots__/createDrawerNavigator-test.js.snap index fad653ae..acdf0796 100644 --- a/packages/drawer/src/navigators/__tests__/__snapshots__/createDrawerNavigator-test.js.snap +++ b/packages/drawer/src/navigators/__tests__/__snapshots__/createDrawerNavigator-test.js.snap @@ -8,6 +8,11 @@ exports[`createDrawerNavigator renders successfully 1`] = ` drawerType="front" drawerWidth={320} edgeWidth={undefined} + gestureRef={ + Object { + "current": null, + } + } hideStatusBar={false} minSwipeDistance={undefined} onDrawerClose={[Function]} diff --git a/packages/drawer/src/utils/DrawerGestureContext.js b/packages/drawer/src/utils/DrawerGestureContext.js new file mode 100644 index 00000000..51b82c7d --- /dev/null +++ b/packages/drawer/src/utils/DrawerGestureContext.js @@ -0,0 +1,3 @@ +import React from 'react'; + +export default React.createContext(null); diff --git a/packages/drawer/src/views/DrawerLayout.js b/packages/drawer/src/views/DrawerLayout.js index 00489083..b8a07181 100644 --- a/packages/drawer/src/views/DrawerLayout.js +++ b/packages/drawer/src/views/DrawerLayout.js @@ -1,2 +1,492 @@ -import { DrawerLayout } from 'react-native-gesture-handler'; -export default DrawerLayout; +/* eslint-disable */ + +// @flow + +// ######################################################### +// This is vendored from react-native-gesture-handler! +// ######################################################### + +// This component is based on RN's DrawerLayoutAndroid API +// +// It perhaps deserves to be put in a separate repo, but since it relies +// on react-native-gesture-handler library which isn't very popular at the +// moment I decided to keep it here for the time being. It will allow us +// to move faster and fix issues that may arise in gesture handler library +// that could be found when using the drawer component + +import React, { Component } from 'react'; +import { Animated, StyleSheet, View, Keyboard, StatusBar } from 'react-native'; +import invariant from '../utils/invariant'; +import { AnimatedEvent } from 'react-native/Libraries/Animated/src/AnimatedEvent'; + +import { + PanGestureHandler, + TapGestureHandler, + State, +} from 'react-native-gesture-handler'; + +const DRAG_TOSS = 0.05; + +const IDLE = 'Idle'; +const DRAGGING = 'Dragging'; +const SETTLING = 'Settling'; + +export type PropType = { + children: any, + drawerBackgroundColor?: string, + drawerPosition: 'left' | 'right', + drawerWidth: number, + keyboardDismissMode?: 'none' | 'on-drag', + onDrawerClose?: Function, + onDrawerOpen?: Function, + onDrawerStateChanged?: Function, + renderNavigationView: (progressAnimatedValue: any) => any, + useNativeAnimations: boolean, + + // brand new properties + drawerType: 'front' | 'back' | 'slide', + edgeWidth: number, + minSwipeDistance: number, + hideStatusBar?: boolean, + statusBarAnimation?: 'slide' | 'none' | 'fade', + overlayColor: string, + contentContainerStyle?: any, + + // Properties not yet supported + // onDrawerSlide?: Function + // drawerLockMode?: 'unlocked' | 'locked-closed' | 'locked-open', +}; + +export type StateType = { + drawerShown: boolean, + dragX: any, + touchX: any, + drawerTranslation: any, + containerWidth: number, +}; + +export type EventType = { + stopPropagation: Function, +}; + +export type DrawerMovementOptionType = { + velocity?: number, +}; + +export default class DrawerLayout extends Component { + static defaultProps = { + drawerWidth: 200, + drawerPosition: 'left', + useNativeAnimations: true, + drawerType: 'front', + edgeWidth: 20, + minSwipeDistance: 3, + overlayColor: 'black', + }; + + static positions = { + Left: 'left', + Right: 'right', + }; + _openValue: ?Animated.Interpolation; + _onGestureEvent: ?AnimatedEvent; + + constructor(props: PropType, context: any) { + super(props, context); + + const dragX = new Animated.Value(0); + const touchX = new Animated.Value(0); + const drawerTranslation = new Animated.Value(0); + + this.state = { + dragX, + touchX, + drawerTranslation, + drawerShown: false, + containerWidth: 0, + }; + + this._updateAnimatedEvent(props, this.state); + } + + componentWillUpdate(props: PropType, state: StateType) { + if ( + this.props.drawerPosition !== props.drawerPosition || + this.props.drawerWidth !== props.drawerWidth || + this.props.drawerType !== props.drawerType || + this.state.containerWidth !== state.containerWidth + ) { + this._updateAnimatedEvent(props, state); + } + } + + _updateAnimatedEvent = (props: PropType, state: StateType) => { + // Event definition is based on + const { drawerPosition, drawerWidth, drawerType } = props; + const { + dragX: dragXValue, + touchX: touchXValue, + drawerTranslation, + containerWidth, + } = state; + + let dragX = dragXValue; + let touchX = touchXValue; + + if (drawerPosition !== 'left') { + // Most of the code is written in a way to handle left-side drawer. + // In order to handle right-side drawer the only thing we need to + // do is to reverse events coming from gesture handler in a way they + // emulate left-side drawer gestures. E.g. dragX is simply -dragX, and + // touchX is calulcated by subtracing real touchX from the width of the + // container (such that when touch happens at the right edge the value + // is simply 0) + dragX = Animated.multiply(new Animated.Value(-1), dragXValue); + touchX = Animated.add( + new Animated.Value(containerWidth), + Animated.multiply(new Animated.Value(-1), touchXValue) + ); + touchXValue.setValue(containerWidth); + } else { + touchXValue.setValue(0); + } + + // 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 dragX. 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" + // + let translationX = dragX; + if (drawerType === 'front') { + const startPositionX = Animated.add( + touchX, + Animated.multiply(new Animated.Value(-1), dragX) + ); + + const dragOffsetFromOnStartPosition = startPositionX.interpolate({ + inputRange: [drawerWidth - 1, drawerWidth, drawerWidth + 1], + outputRange: [0, 0, 1], + }); + translationX = Animated.add(dragX, dragOffsetFromOnStartPosition); + } + + this._openValue = Animated.add(translationX, drawerTranslation).interpolate( + { + inputRange: [0, drawerWidth], + outputRange: [0, 1], + extrapolate: 'clamp', + } + ); + + this._onGestureEvent = Animated.event( + [{ nativeEvent: { translationX: dragXValue, x: touchXValue } }], + { useNativeDriver: props.useNativeAnimations } + ); + }; + + _handleContainerLayout = ({ nativeEvent }) => { + this.setState({ containerWidth: nativeEvent.layout.width }); + }; + + _emitStateChanged = (newState: string, drawerWillShow: boolean) => { + this.props.onDrawerStateChanged && + this.props.onDrawerStateChanged(newState, drawerWillShow); + }; + + _openingHandlerStateChange = ({ nativeEvent }) => { + if (nativeEvent.oldState === State.ACTIVE) { + this._handleRelease(nativeEvent); + } else if (nativeEvent.state === State.ACTIVE) { + this._emitStateChanged(DRAGGING, false); + if (this.props.keyboardDismissMode === 'on-drag') { + Keyboard.dismiss(); + } + if (this.props.hideStatusBar) { + StatusBar.setHidden(true, this.props.statusBarAnimation || 'slide'); + } + } + }; + + _onTapHandlerStateChange = ({ nativeEvent }) => { + if (this.state.drawerShown && nativeEvent.oldState === State.ACTIVE) { + this.closeDrawer(); + } + }; + + _handleRelease = nativeEvent => { + const { drawerWidth, drawerPosition, drawerType } = this.props; + const { drawerShown, containerWidth } = this.state; + let { translationX: dragX, velocityX, x: touchX } = nativeEvent; + + if (drawerPosition !== 'left') { + // See description in _updateAnimatedEvent about why events are flipped + // for right-side drawer + dragX = -dragX; + touchX = containerWidth - touchX; + velocityX = -velocityX; + } + + const gestureStartX = touchX - dragX; + let dragOffsetBasedOnStart = 0; + + if (drawerType === 'front') { + dragOffsetBasedOnStart = + gestureStartX > drawerWidth ? gestureStartX - drawerWidth : 0; + } + + const startOffsetX = + dragX + dragOffsetBasedOnStart + (drawerShown ? drawerWidth : 0); + const projOffsetX = startOffsetX + DRAG_TOSS * velocityX; + + const shouldOpen = projOffsetX > drawerWidth / 2; + + if (shouldOpen) { + this._animateDrawer({ + fromValue: startOffsetX, + toValue: drawerWidth, + velocity: velocityX, + }); + } else { + this._animateDrawer({ + fromValue: startOffsetX, + toValue: 0, + velocity: velocityX, + }); + } + }; + + _animateDrawer = ({ + fromValue, + toValue, + velocity, + }: { + fromValue: number, + toValue: number, + velocity: number, + }) => { + this.state.dragX.setValue(0); + this.state.touchX.setValue( + this.props.drawerPosition === 'left' ? 0 : this.state.containerWidth + ); + + if (typeof fromValue === 'number') { + this.state.drawerTranslation.setValue(fromValue); + } + + const willShow = toValue !== 0; + this.setState({ drawerShown: willShow }); + this._emitStateChanged(SETTLING, willShow); + if (this.props.hideStatusBar) { + StatusBar.setHidden(willShow, this.props.statusBarAnimation || 'slide'); + } + Animated.spring(this.state.drawerTranslation, { + velocity, + bounciness: 0, + toValue, + useNativeDriver: this.props.useNativeAnimations, + }).start(({ finished }) => { + if (finished) { + this._emitStateChanged(IDLE, willShow); + if (willShow) { + this.props.onDrawerOpen && this.props.onDrawerOpen(); + } else { + this.props.onDrawerClose && this.props.onDrawerClose(); + } + } + }); + }; + + openDrawer = (options: DrawerMovementOptionType = {}) => { + this._animateDrawer({ + toValue: this.props.drawerWidth, + velocity: options.velocity ? options.velocity : 0, + }); + }; + + closeDrawer = (options: DrawerMovementOptionType = {}) => { + this._animateDrawer({ + toValue: 0, + velocity: options.velocity ? options.velocity : 0, + }); + }; + + _renderOverlay = () => { + /* Overlay styles */ + invariant(this._openValue, 'should be set'); + const overlayOpacity = this._openValue.interpolate({ + inputRange: [0, 1], + outputRange: [0, 0.7], + extrapolate: 'clamp', + }); + const dynamicOverlayStyles = { + opacity: overlayOpacity, + backgroundColor: this.props.overlayColor, + }; + return ( + + + + ); + }; + + _renderDrawer = () => { + const { drawerShown } = this.state; + const { + drawerBackgroundColor, + drawerWidth, + drawerPosition, + drawerType, + contentContainerStyle, + } = this.props; + + const fromLeft = drawerPosition === 'left'; + const drawerSlide = drawerType !== 'back'; + const containerSlide = drawerType !== 'front'; + + const dynamicDrawerStyles = { + backgroundColor: drawerBackgroundColor, + width: drawerWidth, + }; + const openValue = this._openValue; + invariant(openValue, 'should be set'); + + let containerStyles; + if (containerSlide) { + const containerTranslateX = openValue.interpolate({ + inputRange: [0, 1], + outputRange: fromLeft ? [0, drawerWidth] : [0, -drawerWidth], + extrapolate: 'clamp', + }); + containerStyles = { + transform: [{ translateX: containerTranslateX }], + }; + } + + let drawerTranslateX = 0; + if (drawerSlide) { + const closedDrawerOffset = fromLeft ? -drawerWidth : drawerWidth; + drawerTranslateX = openValue.interpolate({ + inputRange: [0, 1], + outputRange: [closedDrawerOffset, 0], + extrapolate: 'clamp', + }); + } + const drawerStyles = { + transform: [{ translateX: drawerTranslateX }], + flexDirection: fromLeft ? 'row' : 'row-reverse', + }; + + return ( + + + {typeof this.props.children === 'function' + ? this.props.children(this._openValue) + : this.props.children} + {this._renderOverlay()} + + + + {this.props.renderNavigationView(this._openValue)} + + + + ); + }; + + render() { + const { drawerShown, containerWidth } = this.state; + + const { + drawerPosition, + drawerType, + edgeWidth, + minSwipeDistance, + } = this.props; + + const fromLeft = drawerPosition === 'left'; + + // gestureOrientation is 1 if the expected gesture is from left to right and -1 otherwise + // e.g. when drawer is on the left and is closed we expect left to right gesture, thus + // orientation will be 1. + const gestureOrientation = (fromLeft ? 1 : -1) * (drawerShown ? -1 : 1); + + // When drawer is closed we want the hitSlop to be horizontally shorter + // than the container size by the value of SLOP. This will make it only + // activate when gesture happens not further than SLOP away from the edge + const hitSlop = fromLeft + ? { right: drawerShown ? 0 : edgeWidth - containerWidth } + : { left: drawerShown ? 0 : edgeWidth - containerWidth }; + + return ( + + {this._renderDrawer()} + + ); + } +} + +const styles = StyleSheet.create({ + drawer: { flex: 0 }, + drawerContainer: { + ...StyleSheet.absoluteFillObject, + zIndex: 1001, + flexDirection: 'row', + }, + containerInFront: { + ...StyleSheet.absoluteFillObject, + zIndex: 1002, + }, + containerOnBack: { + ...StyleSheet.absoluteFillObject, + }, + main: { + flex: 1, + zIndex: 0, + overflow: 'hidden', + }, + overlay: { + ...StyleSheet.absoluteFillObject, + zIndex: 1000, + }, +}); diff --git a/packages/drawer/src/views/DrawerView.js b/packages/drawer/src/views/DrawerView.js index 6c592f80..811b9745 100644 --- a/packages/drawer/src/views/DrawerView.js +++ b/packages/drawer/src/views/DrawerView.js @@ -4,6 +4,7 @@ import { SceneView } from 'react-navigation'; import DrawerActions from '../routers/DrawerActions'; import DrawerLayout from './DrawerLayout'; import DrawerSidebar from './DrawerSidebar'; +import DrawerGestureContext from '../utils/DrawerGestureContext'; /** * Component that renders the drawer. @@ -57,6 +58,8 @@ export default class DrawerView extends React.PureComponent { Dimensions.removeEventListener('change', this._updateWidth); } + drawerGestureRef = React.createRef(); + _handleDrawerOpen = () => { this.props.navigation.dispatch({ type: DrawerActions.DRAWER_OPENED, @@ -84,16 +87,18 @@ export default class DrawerView extends React.PureComponent { _renderNavigationView = () => { return ( - + + + ); }; @@ -109,6 +114,7 @@ export default class DrawerView extends React.PureComponent { ref={c => { this._drawer = c; }} + gestureRef={this.drawerGestureRef} drawerLockMode={ drawerLockMode || (this.props.screenProps && this.props.screenProps.drawerLockMode) || @@ -135,11 +141,13 @@ export default class DrawerView extends React.PureComponent { minSwipeDistance={this.props.navigationConfig.minSwipeDistance} overlayColor={this.props.navigationConfig.overlayColor} > - + + + ); }