react-native-modalbox
Version:
A <Modal/> component for react-native
585 lines (527 loc) • 16 kB
JavaScript
import React from 'react';
import PropTypes from 'prop-types';
import {
View,
StyleSheet,
PanResponder,
Animated,
TouchableWithoutFeedback,
Dimensions,
Easing,
BackHandler,
Platform,
Modal,
Keyboard
} from 'react-native';
const {height: SCREEN_HEIGHT, width: SCREEN_WIDTH} = Dimensions.get('window');
const styles = StyleSheet.create({
wrapper: {
backgroundColor: 'white'
},
transparent: {
zIndex: 2,
backgroundColor: 'rgba(0,0,0,0)'
},
absolute: {
position: 'absolute',
top: 0,
bottom: 0,
left: 0,
right: 0
}
});
export default class ModalBox extends React.PureComponent {
static propTypes = {
isOpen: PropTypes.bool,
isDisabled: PropTypes.bool,
startOpen: PropTypes.bool,
backdropPressToClose: PropTypes.bool,
swipeToClose: PropTypes.bool,
swipeThreshold: PropTypes.number,
swipeArea: PropTypes.number,
position: PropTypes.string,
entry: PropTypes.string,
backdrop: PropTypes.bool,
backdropOpacity: PropTypes.number,
backdropColor: PropTypes.string,
backdropContent: PropTypes.element,
animationDuration: PropTypes.number,
backButtonClose: PropTypes.bool,
easing: PropTypes.func,
coverScreen: PropTypes.bool,
keyboardTopOffset: PropTypes.number,
onClosed: PropTypes.func,
onOpened: PropTypes.func,
onClosingState: PropTypes.func
};
static defaultProps = {
startOpen: false,
backdropPressToClose: true,
swipeToClose: true,
swipeThreshold: 50,
position: 'center',
backdrop: true,
backdropOpacity: 0.5,
backdropColor: 'black',
backdropContent: null,
animationDuration: 400,
backButtonClose: false,
easing: Easing.elastic(0.8),
coverScreen: false,
keyboardTopOffset: Platform.OS == 'ios' ? 22 : 0,
useNativeDriver: true
};
constructor(props) {
super(props);
this.onBackPress = this.onBackPress.bind(this);
this.handleOpenning = this.handleOpenning.bind(this);
this.onKeyboardHide = this.onKeyboardHide.bind(this);
this.onKeyboardChange = this.onKeyboardChange.bind(this);
this.animateBackdropOpen = this.animateBackdropOpen.bind(this);
this.animateBackdropClose = this.animateBackdropClose.bind(this);
this.stopAnimateOpen = this.stopAnimateOpen.bind(this);
this.animateOpen = this.animateOpen.bind(this);
this.stopAnimateClose = this.stopAnimateClose.bind(this);
this.animateClose = this.animateClose.bind(this);
this.calculateModalPosition = this.calculateModalPosition.bind(this);
this.createPanResponder = this.createPanResponder.bind(this);
this.onViewLayout = this.onViewLayout.bind(this);
this.onContainerLayout = this.onContainerLayout.bind(this);
this.renderBackdrop = this.renderBackdrop.bind(this);
this.renderContent = this.renderContent.bind(this);
this.open = this.open.bind(this);
this.close = this.close.bind(this);
const position = props.startOpen
? new Animated.Value(0)
: new Animated.Value(
props.entry === 'top' ? -SCREEN_HEIGHT : SCREEN_HEIGHT
);
this.state = {
position,
backdropOpacity: new Animated.Value(0),
isOpen: props.startOpen,
isAnimateClose: false,
isAnimateOpen: false,
swipeToClose: false,
height: SCREEN_HEIGHT,
width: SCREEN_WIDTH,
containerHeight: SCREEN_HEIGHT,
containerWidth: SCREEN_WIDTH,
isInitialized: false,
keyboardOffset: 0,
pan: this.createPanResponder(position)
};
// Needed for iOS because the keyboard covers the screen
if (Platform.OS === 'ios') {
this.subscriptions = [
Keyboard.addListener('keyboardWillChangeFrame', this.onKeyboardChange),
Keyboard.addListener('keyboardDidHide', this.onKeyboardHide)
];
}
}
componentDidMount() {
this.handleOpenning();
}
componentDidUpdate(prevProps) {
if (this.props.isOpen != prevProps.isOpen) {
this.handleOpenning();
}
}
componentWillUnmount() {
if (this.subscriptions) this.subscriptions.forEach(sub => sub.remove());
if (this.props.backButtonClose && Platform.OS === 'android')
BackHandler.removeEventListener('hardwareBackPress', this.onBackPress);
}
onBackPress() {
this.close();
return true;
}
handleOpenning() {
if (typeof this.props.isOpen == 'undefined') return;
if (this.props.isOpen) this.open();
else this.close();
}
/****************** ANIMATIONS **********************/
/*
* The keyboard is hidden (IOS only)
*/
onKeyboardHide(evt) {
this.setState({keyboardOffset: 0});
}
/*
* The keyboard frame changed, used to detect when the keyboard open, faster than keyboardDidShow (IOS only)
*/
onKeyboardChange(evt) {
if (!evt) return;
if (!this.state.isOpen) return;
const keyboardFrame = evt.endCoordinates;
const keyboardHeight = this.state.containerHeight - keyboardFrame.screenY;
this.setState({keyboardOffset: keyboardHeight}, () => {
this.animateOpen();
});
}
/*
* Open animation for the backdrop, will fade in
*/
animateBackdropOpen() {
if (this.state.isAnimateBackdrop && this.state.animBackdrop) {
this.state.animBackdrop.stop();
}
this.setState({isAnimateBackdrop: true});
let animBackdrop = Animated.timing(this.state.backdropOpacity, {
toValue: 1,
duration: this.props.animationDuration,
easing: this.props.easing,
useNativeDriver: this.props.useNativeDriver
}).start(() => {
this.setState({
isAnimateBackdrop: false,
animBackdrop
});
});
}
/*
* Close animation for the backdrop, will fade out
*/
animateBackdropClose() {
if (this.state.isAnimateBackdrop && this.state.animBackdrop) {
this.state.animBackdrop.stop();
}
this.setState({isAnimateBackdrop: true});
let animBackdrop = Animated.timing(this.state.backdropOpacity, {
toValue: 0,
duration: this.props.animationDuration,
easing: this.props.easing,
useNativeDriver: this.props.useNativeDriver
}).start(() => {
this.setState({
isAnimateBackdrop: false,
animBackdrop
});
});
}
/*
* Stop opening animation
*/
stopAnimateOpen() {
if (this.state.isAnimateOpen) {
if (this.state.animOpen) this.state.animOpen.stop();
this.setState({isAnimateOpen: false});
}
}
/*
* Open animation for the modal, will move up
*/
animateOpen() {
this.stopAnimateClose();
// Backdrop fadeIn
if (this.props.backdrop) this.animateBackdropOpen();
this.setState(
{
isAnimateOpen: true,
isOpen: true
},
() => {
requestAnimationFrame(() => {
// Detecting modal position
let positionDest = this.calculateModalPosition(
this.state.containerHeight - this.state.keyboardOffset,
this.state.containerWidth
);
if (
this.state.keyboardOffset &&
positionDest < this.props.keyboardTopOffset
) {
positionDest = this.props.keyboardTopOffset;
}
let animOpen = Animated.timing(this.state.position, {
toValue: positionDest,
duration: this.props.animationDuration,
easing: this.props.easing,
useNativeDriver: this.props.useNativeDriver
}).start(() => {
this.setState({
isAnimateOpen: false,
animOpen,
positionDest
});
if (this.props.onOpened) this.props.onOpened();
});
});
}
);
}
/*
* Stop closing animation
*/
stopAnimateClose() {
if (this.state.isAnimateClose) {
if (this.state.animClose) this.state.animClose.stop();
this.setState({isAnimateClose: false});
}
}
/*
* Close animation for the modal, will move down
*/
animateClose() {
this.stopAnimateOpen();
// Backdrop fadeout
if (this.props.backdrop) this.animateBackdropClose();
this.setState(
{
isAnimateClose: true,
isOpen: false
},
() => {
let animClose = Animated.timing(this.state.position, {
toValue:
this.props.entry === 'top'
? -this.state.containerHeight
: this.state.containerHeight,
duration: this.props.animationDuration,
easing: this.props.easing,
useNativeDriver: this.props.useNativeDriver
}).start(() => {
// Keyboard.dismiss(); // make this optional. Easily user defined in .onClosed() callback
this.setState({
isAnimateClose: false,
animClose
}, () => {
/* Set the state to the starting position of the modal, preventing from animating where the swipe stopped */
this.state.position.setValue(this.props.entry === 'top' ? -this.state.containerHeight : this.state.containerHeight);
});
if (this.props.onClosed) this.props.onClosed();
});
}
);
}
/*
* Calculate when should be placed the modal
*/
calculateModalPosition(containerHeight, containerWidth) {
let position = 0;
if (this.props.position == 'bottom') {
position = containerHeight - this.state.height;
} else if (this.props.position == 'center') {
position = containerHeight / 2 - this.state.height / 2;
}
// Checking if the position >= 0
if (position < 0) position = 0;
return position;
}
/*
* Create the pan responder to detect gesture
* Only used if swipeToClose is enabled
*/
createPanResponder(position) {
let closingState = false;
let inSwipeArea = false;
const onPanStart = (evt, state) => {
if (
!this.props.swipeToClose ||
this.props.isDisabled ||
(this.props.swipeArea &&
evt.nativeEvent.pageY - this.state.positionDest >
this.props.swipeArea)
) {
inSwipeArea = false;
return false;
}
inSwipeArea = true;
return true;
};
const animEvt = Animated.event([null, {customY: position}], {useNativeDriver: false});
const onPanMove = (evt, state) => {
const newClosingState =
this.props.entry === 'top'
? -state.dy > this.props.swipeThreshold
: state.dy > this.props.swipeThreshold;
if (this.props.entry === 'top' ? state.dy > 0 : state.dy < 0) return;
if (newClosingState != closingState && this.props.onClosingState)
this.props.onClosingState(newClosingState);
closingState = newClosingState;
state.customY = state.dy + this.state.positionDest;
animEvt(evt, state);
};
const onPanRelease = (evt, state) => {
if (!inSwipeArea) return;
inSwipeArea = false;
if (
this.props.entry === 'top'
? -state.dy > this.props.swipeThreshold
: state.dy > this.props.swipeThreshold
) {
this.close();
} else if (!this.state.isOpen) {
this.animateOpen();
}
};
return PanResponder.create({
onStartShouldSetPanResponder: onPanStart,
onPanResponderMove: onPanMove,
onPanResponderRelease: onPanRelease,
onPanResponderTerminate: onPanRelease
});
}
/*
* Event called when the modal view layout is calculated
*/
onViewLayout(evt) {
const height = evt.nativeEvent.layout.height;
const width = evt.nativeEvent.layout.width;
// If the dimensions are still the same we're done
let newState = {};
if (height !== this.state.height) newState.height = height;
if (width !== this.state.width) newState.width = width;
this.setState(newState);
if (this.onViewLayoutCalculated) this.onViewLayoutCalculated();
}
/*
* Event called when the container view layout is calculated
*/
onContainerLayout(evt) {
const height = evt.nativeEvent.layout.height;
const width = evt.nativeEvent.layout.width;
// If the container size is still the same we're done
if (
height == this.state.containerHeight &&
width == this.state.containerWidth
) {
this.setState({isInitialized: true});
return;
}
if (this.state.isOpen || this.state.isAnimateOpen) {
this.animateOpen();
}
if (this.props.onLayout) this.props.onLayout(evt);
this.setState({
isInitialized: true,
containerHeight: height,
containerWidth: width
});
}
/*
* Render the backdrop element
*/
renderBackdrop() {
let backdrop = null;
if (this.props.backdrop) {
backdrop = (
<TouchableWithoutFeedback
onPress={this.props.backdropPressToClose ? this.close : null}>
<Animated.View
importantForAccessibility="no"
style={[styles.absolute, {opacity: this.state.backdropOpacity}]}>
<View
style={[
styles.absolute,
{
backgroundColor: this.props.backdropColor,
opacity: this.props.backdropOpacity
}
]}
/>
{this.props.backdropContent || []}
</Animated.View>
</TouchableWithoutFeedback>
);
}
return backdrop;
}
renderContent() {
const size = {
height: this.state.containerHeight,
width: this.state.containerWidth
};
const offsetX = (this.state.containerWidth - this.state.width) / 2;
return (
<Animated.View
onLayout={this.onViewLayout}
style={[
styles.wrapper,
size,
this.props.style,
{
transform: [
{translateY: this.state.position},
{translateX: offsetX}
]
}
]}
{...this.state.pan.panHandlers}>
{this.props.children}
</Animated.View>
);
}
/*
* Render the component
*/
render() {
const visible =
this.state.isOpen ||
this.state.isAnimateOpen ||
this.state.isAnimateClose;
if (!visible) return <View />;
const content = (
<View
importantForAccessibility="yes"
accessibilityViewIsModal={true}
style={[styles.transparent, styles.absolute]}
pointerEvents={'box-none'}>
<View
style={{flex: 1}}
pointerEvents={'box-none'}
onLayout={this.onContainerLayout}>
{visible && this.renderBackdrop()}
{visible && this.renderContent()}
</View>
</View>
);
if (!this.props.coverScreen) return content;
return (
<Modal
onRequestClose={() => {
if (this.props.backButtonClose) {
this.close();
}
}}
supportedOrientations={[
'landscape',
'portrait',
'portrait-upside-down'
]}
transparent
visible={visible}
hardwareAccelerated={true}>
{content}
</Modal>
);
}
/****************** PUBLIC METHODS **********************/
open() {
if (this.props.isDisabled) return;
if (
!this.state.isAnimateOpen &&
(!this.state.isOpen || this.state.isAnimateClose)
) {
this.onViewLayoutCalculated = () => {
this.animateOpen();
if (this.props.backButtonClose && Platform.OS === 'android')
BackHandler.addEventListener('hardwareBackPress', this.onBackPress);
this.onViewLayoutCalculated = null;
};
this.setState({isAnimateOpen: true});
}
}
close() {
if (this.props.isDisabled) return;
if (
!this.state.isAnimateClose &&
(this.state.isOpen || this.state.isAnimateOpen)
) {
this.animateClose();
if (this.props.backButtonClose && Platform.OS === 'android')
BackHandler.removeEventListener('hardwareBackPress', this.onBackPress);
}
}
}