@panter/react-native-modalbox
Version:
A <Modal/> component for react-native
543 lines (467 loc) • 14.8 kB
JavaScript
const React = require('react')
const PropTypes = require('prop-types')
const {
View,
StyleSheet,
PanResponder,
Animated,
TouchableWithoutFeedback,
Dimensions,
Easing,
BackAndroid,
BackHandler,
Platform,
Modal,
Keyboard,
} = require('react-native')
const createReactClass = require('create-react-class')
const BackButton = BackHandler || BackAndroid
const screen = 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,
},
})
const ModalBox = createReactClass({
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,
},
getDefaultProps() {
return {
startOpen: false,
backdropPressToClose: true,
swipeToClose: true,
swipeThreshold: 50,
center: true,
position: null, // deprecated, use 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,
}
},
getInitialState() {
return {
position: new Animated.Value(0),
backdropOpacity: new Animated.Value(0),
isOpen: this.props.startOpen,
isAnimateClose: false,
isAnimateOpen: false,
swipeToClose: false,
height: screen.height,
width: screen.width,
containerHeight: screen.height,
containerWidth: screen.width,
isInitialized: false,
keyboardOffset: 0,
}
},
onBackPress() {
this.close()
return true
},
componentWillMount() {
const position = this.props.startOpen ?
this.getOpenPosition() :
this.getClosePosition()
this.setState({position: new Animated.Value(position)})
this.createPanResponder()
this.handleOpenning(this.props)
// 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),
]
}
},
componentWillUnmount() {
if (this.subscriptions) this.subscriptions.forEach(sub => sub.remove())
},
componentWillReceiveProps(props) {
if (this.props.isOpen != props.isOpen) {
this.handleOpenning(props)
}
},
handleOpenning(props) {
if (typeof props.isOpen === 'undefined') return
if (props.isOpen) { this.open() } else { this.close() }
},
/** **************** ANIMATIONS ********************* */
/*
* The keyboard is hidden (IOS only)
*/
onKeyboardHide(evt) {
this.state.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.state.keyboardOffset = keyboardHeight
this.animateOpen()
},
/*
* Open animation for the backdrop, will fade in
*/
animateBackdropOpen() {
if (this.state.isAnimateBackdrop) {
this.state.animBackdrop.stop()
this.state.isAnimateBackdrop = false
}
this.state.isAnimateBackdrop = true
this.state.animBackdrop = Animated.timing(
this.state.backdropOpacity,
{
toValue: 1,
duration: this.props.animationDuration,
},
)
this.state.animBackdrop.start(() => {
this.state.isAnimateBackdrop = false
})
},
/*
* Close animation for the backdrop, will fade out
*/
animateBackdropClose() {
if (this.state.isAnimateBackdrop) {
this.state.animBackdrop.stop()
this.state.isAnimateBackdrop = false
}
this.state.isAnimateBackdrop = true
this.state.animBackdrop = Animated.timing(
this.state.backdropOpacity,
{
toValue: 0,
duration: this.props.animationDuration,
},
)
this.state.animBackdrop.start(() => {
this.state.isAnimateBackdrop = false
})
},
/*
* Stop opening animation
*/
stopAnimateOpen() {
if (this.state.isAnimateOpen) {
if (this.state.animOpen) this.state.animOpen.stop()
this.state.isAnimateOpen = false
}
},
/*
* Open animation for the modal, will move up
*/
animateOpen() {
this.stopAnimateClose()
// Backdrop fadeIn
if (this.props.backdrop) { this.animateBackdropOpen() }
this.state.isAnimateOpen = true
requestAnimationFrame(() => {
// Detecting modal position
this.state.positionDest = this.getOpenPosition()
if (this.state.keyboardOffset && (this.state.positionDest < this.props.keyboardTopOffset)) {
this.state.positionDest = this.props.keyboardTopOffset
}
this.state.animOpen = Animated.timing(
this.state.position,
{
toValue: this.state.positionDest,
duration: this.props.animationDuration,
easing: this.props.easing,
},
)
this.state.animOpen.start(() => {
if (!this.state.isOpen && this.props.onOpened) this.props.onOpened()
this.state.isAnimateOpen = false
this.state.isOpen = true
})
})
},
/*
* Stop closing animation
*/
stopAnimateClose() {
if (this.state.isAnimateClose) {
if (this.state.animClose) this.state.animClose.stop()
this.state.isAnimateClose = false
}
},
/*
* Close animation for the modal, will move down
*/
animateClose() {
this.stopAnimateOpen()
// Backdrop fadeout
if (this.props.backdrop) { this.animateBackdropClose() }
this.state.isAnimateClose = true
const toValue = this.getClosePosition()
this.state.animClose = Animated.timing(
this.state.position,
{
toValue,
duration: this.props.animationDuration,
},
)
this.state.animClose.start(() => {
Keyboard.dismiss()
this.state.isAnimateClose = false
this.state.isOpen = false
this.setState({})
if (this.props.onClosed) this.props.onClosed()
})
},
getClosePosition() {
let position
if (this.props.entry === 'left') {
position = -this.state.containerWidth
} else if (this.props.entry === 'right') {
position = this.state.containerWidth
} else if (this.props.entry === 'top') {
position = -this.state.containerHeight
} else {
position = this.state.containerHeight
}
return position;
},
/*
* Calculate when should be placed the modal
*/
getOpenPosition() {
const containerHeight = this.state.containerHeight - this.state.keyboardOffset;
const containerWidth = this.state.containerWidth
let position = 0
const center = this.props.center || this.props.position === 'center'
if (this.props.entry === 'left' || this.props.entry === 'right') {
if (center) {
position = containerWidth / 2 - this.state.width / 2
} else if (this.props.entry === 'left') {
position = -containerWidth + this.state.width
} else {
position = containerWidth - this.state.width
}
} else if (center) {
position = containerHeight / 2 - this.state.height / 2
} else if (this.props.entry === 'top') {
position = -containerHeight + this.state.height
} else {
position = containerHeight - this.state.height
}
// Checking if the position >= 0
if (position < 0) position = 0
return position
},
getMoveDistance(panState) {
let moveDistance = 0
if(this.props.entry === "left") {
moveDistance = -panState.dx
} else if (this.props.entry === "right") {
moveDistance = panState.dx
} else if (this.props.entry === "top") {
moveDistance = -panState.dy
} else {
moveDistance = panState.dy
}
return moveDistance;
},
/*
* Create the pan responder to detect gesture
* Only used if swipeToClose is enabled
*/
createPanResponder() {
let closingState = false
let inSwipeArea = false
const onPanRelease = (evt, state) => {
if (!inSwipeArea) return
inSwipeArea = false
const moveDistance = this.getMoveDistance(state);
if (moveDistance > this.props.swipeThreshold) {
this.animateClose()
} else {
this.animateOpen()
}
}
const animEvt = Animated.event([null, { moveDistance: this.state.position }])
const onPanMove = (evt, state) => {
const moveDistance = this.getMoveDistance(state);
const newClosingState = moveDistance > this.props.swipeThreshold
if (moveDistance < 0) return
if (newClosingState != closingState && this.props.onClosingState) {
this.props.onClosingState(newClosingState)
}
closingState = newClosingState
state.moveDistance = this.state.positionDest - Math.abs(moveDistance)
animEvt(evt, state)
}
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
}
this.state.pan = 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
const 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 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
const offsetY = (this.state.containerHeight - this.state.height) / 2
let transform
if (this.props.entry === 'left') {
transform = [{ translateX: this.state.position }, { translateY: offsetY }]
} else if (this.props.entry === 'right') {
transform = [{ translateX: this.state.position }, { translateY: offsetY }]
} else if (this.props.entry === 'top') {
transform = [{ translateY: this.state.position }, { translateX: offsetX }]
} else { transform = [{ translateY: this.state.position }, { translateX: offsetX }] }
return (
<Animated.View
onLayout={this.onViewLayout}
style={[styles.wrapper, size, this.props.style, { transform }]}
{...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 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={() => this.close()} supportedOrientations={['landscape', 'portrait']} transparent visible={visible}>
{content}
</Modal>
)
},
/** **************** PUBLIC METHODS ********************* */
open() {
if (this.props.isDisabled) return
if (!this.state.isAnimateOpen && (!this.state.isOpen || this.state.isAnimateClose)) {
this.onViewLayoutCalculated = () => {
this.setState({})
this.animateOpen()
if (this.props.backButtonClose && Platform.OS === 'android') BackButton.addEventListener('hardwareBackPress', this.onBackPress)
delete this.onViewLayoutCalculated
}
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') BackButton.removeEventListener('hardwareBackPress', this.onBackPress)
}
},
})
module.exports = ModalBox