react-native-side-menu
Version:
Simple customizable component to create side menu
305 lines (258 loc) • 8.71 kB
JavaScript
// @flow
import React from 'react';
import {
PanResponder,
View,
Dimensions,
Animated,
TouchableWithoutFeedback,
} from 'react-native';
import PropTypes from 'prop-types';
import styles from './styles';
type WindowDimensions = { width: number, height: number };
type Props = {
edgeHitWidth: number,
toleranceX: number,
toleranceY: number,
menuPosition: 'left' | 'right',
onChange: Function,
onMove: Function,
onSliding: Function,
openMenuOffset: number,
hiddenMenuOffset: number,
disableGestures: Function | bool,
animationFunction: Function,
onStartShouldSetResponderCapture: Function,
isOpen: bool,
bounceBackOnOverdraw: bool,
autoClosing: bool
};
type Event = {
nativeEvent: {
layout: {
width: number,
height: number,
},
},
};
type State = {
width: number,
height: number,
openOffsetMenuPercentage: number,
openMenuOffset: number,
hiddenMenuOffsetPercentage: number,
hiddenMenuOffset: number,
left: Animated.Value,
};
const deviceScreen: WindowDimensions = Dimensions.get('window');
const barrierForward: number = deviceScreen.width / 4;
function shouldOpenMenu(dx: number): boolean {
return dx > barrierForward;
}
export default class SideMenu extends React.Component {
onLayoutChange: Function;
onStartShouldSetResponderCapture: Function;
onMoveShouldSetPanResponder: Function;
onPanResponderMove: Function;
onPanResponderRelease: Function;
onPanResponderTerminate: Function;
state: State;
prevLeft: number;
isOpen: boolean;
constructor(props: Props) {
super(props);
this.prevLeft = 0;
this.isOpen = !!props.isOpen;
const initialMenuPositionMultiplier = props.menuPosition === 'right' ? -1 : 1;
const openOffsetMenuPercentage = props.openMenuOffset / deviceScreen.width;
const hiddenMenuOffsetPercentage = props.hiddenMenuOffset / deviceScreen.width;
const left: Animated.Value = new Animated.Value(
props.isOpen
? props.openMenuOffset * initialMenuPositionMultiplier
: props.hiddenMenuOffset,
);
this.onLayoutChange = this.onLayoutChange.bind(this);
this.onStartShouldSetResponderCapture = props.onStartShouldSetResponderCapture.bind(this);
this.onMoveShouldSetPanResponder = this.handleMoveShouldSetPanResponder.bind(this);
this.onPanResponderMove = this.handlePanResponderMove.bind(this);
this.onPanResponderRelease = this.handlePanResponderEnd.bind(this);
this.onPanResponderTerminate = this.handlePanResponderEnd.bind(this);
this.state = {
width: deviceScreen.width,
height: deviceScreen.height,
openOffsetMenuPercentage,
openMenuOffset: deviceScreen.width * openOffsetMenuPercentage,
hiddenMenuOffsetPercentage,
hiddenMenuOffset: deviceScreen.width * hiddenMenuOffsetPercentage,
left,
};
this.state.left.addListener(({value}) => this.props.onSliding(Math.abs((value - this.state.hiddenMenuOffset) / (this.state.openMenuOffset - this.state.hiddenMenuOffset))));
}
componentWillMount(): void {
this.responder = PanResponder.create({
onStartShouldSetResponderCapture: this.onStartShouldSetResponderCapture,
onMoveShouldSetPanResponder: this.onMoveShouldSetPanResponder,
onPanResponderMove: this.onPanResponderMove,
onPanResponderRelease: this.onPanResponderRelease,
onPanResponderTerminate: this.onPanResponderTerminate,
});
}
componentWillReceiveProps(props: Props): void {
if (typeof props.isOpen !== 'undefined' && this.isOpen !== props.isOpen && (props.autoClosing || this.isOpen === false)) {
this.openMenu(props.isOpen);
}
}
onLayoutChange(e: Event) {
const { width, height } = e.nativeEvent.layout;
const openMenuOffset = width * this.state.openOffsetMenuPercentage;
const hiddenMenuOffset = width * this.state.hiddenMenuOffsetPercentage;
this.setState({ width, height, openMenuOffset, hiddenMenuOffset });
}
/**
* Get content view. This view will be rendered over menu
* @return {React.Component}
*/
getContentView() {
let overlay: React.Element<void, void> = null;
if (this.isOpen) {
overlay = (
<TouchableWithoutFeedback onPress={() => this.openMenu(false)}>
<View style={styles.overlay} />
</TouchableWithoutFeedback>
);
}
const { width, height } = this.state;
const ref = sideMenu => (this.sideMenu = sideMenu);
const style = [
styles.frontView,
{ width, height },
this.props.animationStyle(this.state.left),
];
return (
<Animated.View style={style} ref={ref} {...this.responder.panHandlers}>
{this.props.children}
{overlay}
</Animated.View>
);
}
moveLeft(offset: number) {
const newOffset = this.menuPositionMultiplier() * offset;
this.props
.animationFunction(this.state.left, newOffset)
.start();
this.prevLeft = newOffset;
}
menuPositionMultiplier(): -1 | 1 {
return this.props.menuPosition === 'right' ? -1 : 1;
}
handlePanResponderMove(e: Object, gestureState: Object) {
if (this.state.left.__getValue() * this.menuPositionMultiplier() >= 0) {
let newLeft = this.prevLeft + gestureState.dx;
if (!this.props.bounceBackOnOverdraw && Math.abs(newLeft) > this.state.openMenuOffset) {
newLeft = this.menuPositionMultiplier() * this.state.openMenuOffset;
}
this.props.onMove(newLeft);
this.state.left.setValue(newLeft);
}
}
handlePanResponderEnd(e: Object, gestureState: Object) {
const offsetLeft = this.menuPositionMultiplier() *
(this.state.left.__getValue() + gestureState.dx);
this.openMenu(shouldOpenMenu(offsetLeft));
}
handleMoveShouldSetPanResponder(e: any, gestureState: any): boolean {
if (this.gesturesAreEnabled()) {
const x = Math.round(Math.abs(gestureState.dx));
const y = Math.round(Math.abs(gestureState.dy));
const touchMoved = x > this.props.toleranceX && y < this.props.toleranceY;
if (this.isOpen) {
return touchMoved;
}
const withinEdgeHitWidth = this.props.menuPosition === 'right' ?
gestureState.moveX > (deviceScreen.width - this.props.edgeHitWidth) :
gestureState.moveX < this.props.edgeHitWidth;
const swipingToOpen = this.menuPositionMultiplier() * gestureState.dx > 0;
return withinEdgeHitWidth && touchMoved && swipingToOpen;
}
return false;
}
openMenu(isOpen: boolean): void {
const { hiddenMenuOffset, openMenuOffset } = this.state;
this.moveLeft(isOpen ? openMenuOffset : hiddenMenuOffset);
this.isOpen = isOpen;
this.forceUpdate();
this.props.onChange(isOpen);
}
gesturesAreEnabled(): boolean {
const { disableGestures } = this.props;
if (typeof disableGestures === 'function') {
return !disableGestures();
}
return !disableGestures;
}
render(): React.Element<void, void> {
const boundryStyle = this.props.menuPosition === 'right' ?
{ left: this.state.width - this.state.openMenuOffset } :
{ right: this.state.width - this.state.openMenuOffset };
const menu = (
<View style={[styles.menu, boundryStyle]}>
{this.props.menu}
</View>
);
return (
<View
style={styles.container}
onLayout={this.onLayoutChange}
>
{menu}
{this.getContentView()}
</View>
);
}
}
SideMenu.propTypes = {
edgeHitWidth: PropTypes.number,
toleranceX: PropTypes.number,
toleranceY: PropTypes.number,
menuPosition: PropTypes.oneOf(['left', 'right']),
onChange: PropTypes.func,
onMove: PropTypes.func,
children: PropTypes.node,
menu: PropTypes.node,
openMenuOffset: PropTypes.number,
hiddenMenuOffset: PropTypes.number,
animationStyle: PropTypes.func,
disableGestures: PropTypes.oneOfType([PropTypes.func, PropTypes.bool]),
animationFunction: PropTypes.func,
onStartShouldSetResponderCapture: PropTypes.func,
isOpen: PropTypes.bool,
bounceBackOnOverdraw: PropTypes.bool,
autoClosing: PropTypes.bool,
};
SideMenu.defaultProps = {
toleranceY: 10,
toleranceX: 10,
edgeHitWidth: 60,
children: null,
menu: null,
openMenuOffset: deviceScreen.width * (2 / 3),
disableGestures: false,
menuPosition: 'left',
hiddenMenuOffset: 0,
onMove: () => {},
onStartShouldSetResponderCapture: () => true,
onChange: () => {},
onSliding: () => {},
animationStyle: value => ({
transform: [{
translateX: value,
}],
}),
animationFunction: (prop, value) => Animated.spring(prop, {
toValue: value,
friction: 8,
}),
isOpen: false,
bounceBackOnOverdraw: true,
autoClosing: true,
};