UNPKG

23.4 kBJavaScriptView Raw
1import * as React from 'react';
2import { Animated, DeviceEventEmitter, Dimensions, KeyboardAvoidingView, Modal, PanResponder, Platform, TouchableWithoutFeedback, View, } from 'react-native';
3import PropTypes from 'prop-types';
4import * as animatable from 'react-native-animatable';
5import { initializeAnimations, buildAnimations, reversePercentage, } from './utils';
6import styles from './modal.style';
7// Override default react-native-animatable animations
8initializeAnimations();
9const extractAnimationFromProps = (props) => ({
10 animationIn: props.animationIn,
11 animationOut: props.animationOut,
12});
13export class ReactNativeModal extends React.Component {
14 constructor(props) {
15 super(props);
16 // We use an internal state for keeping track of the modal visibility: this allows us to keep
17 // the modal visible during the exit animation, even if the user has already change the
18 // isVisible prop to false.
19 // We store in the state the device width and height so that we can update the modal on
20 // device rotation.
21 this.state = {
22 showContent: true,
23 isVisible: false,
24 deviceWidth: Dimensions.get('window').width,
25 deviceHeight: Dimensions.get('window').height,
26 isSwipeable: this.props.swipeDirection ? true : false,
27 pan: null,
28 };
29 this.isTransitioning = false;
30 this.inSwipeClosingState = false;
31 this.currentSwipingDirection = null;
32 this.panResponder = null;
33 this.buildPanResponder = () => {
34 let animEvt = null;
35 this.panResponder = PanResponder.create({
36 onMoveShouldSetPanResponder: (evt, gestureState) => {
37 // Use propagateSwipe to allow inner content to scroll. See PR:
38 // https://github.com/react-native-community/react-native-modal/pull/246
39 if (!this.props.propagateSwipe) {
40 // The number "4" is just a good tradeoff to make the panResponder
41 // work correctly even when the modal has touchable buttons.
42 // For reference:
43 // https://github.com/react-native-community/react-native-modal/pull/197
44 const shouldSetPanResponder = Math.abs(gestureState.dx) >= 4 || Math.abs(gestureState.dy) >= 4;
45 if (shouldSetPanResponder && this.props.onSwipeStart) {
46 this.props.onSwipeStart();
47 }
48 this.currentSwipingDirection = this.getSwipingDirection(gestureState);
49 animEvt = this.createAnimationEventForSwipe();
50 return shouldSetPanResponder;
51 }
52 return false;
53 },
54 onStartShouldSetPanResponder: () => {
55 if (this.props.scrollTo && this.props.scrollOffset > 0) {
56 return false; // user needs to be able to scroll content back up
57 }
58 if (this.props.onSwipeStart) {
59 this.props.onSwipeStart();
60 }
61 // Cleared so that onPanResponderMove can wait to have some delta
62 // to work with
63 this.currentSwipingDirection = null;
64 return true;
65 },
66 onPanResponderMove: (evt, gestureState) => {
67 // Using onStartShouldSetPanResponder we don't have any delta so we don't know
68 // The direction to which the user is swiping until some move have been done
69 if (!this.currentSwipingDirection) {
70 if (gestureState.dx === 0 && gestureState.dy === 0) {
71 return;
72 }
73 this.currentSwipingDirection = this.getSwipingDirection(gestureState);
74 animEvt = this.createAnimationEventForSwipe();
75 }
76 if (this.isSwipeDirectionAllowed(gestureState)) {
77 // Dim the background while swiping the modal
78 const newOpacityFactor = 1 - this.calcDistancePercentage(gestureState);
79 this.backdropRef &&
80 this.backdropRef.transitionTo({
81 opacity: this.props.backdropOpacity * newOpacityFactor,
82 });
83 animEvt(evt, gestureState);
84 if (this.props.onSwipeMove) {
85 this.props.onSwipeMove(newOpacityFactor);
86 }
87 }
88 else {
89 if (this.props.scrollTo) {
90 if (this.props.scrollHorizontal) {
91 let offsetX = -gestureState.dx;
92 if (offsetX > this.props.scrollOffsetMax) {
93 offsetX -= (offsetX - this.props.scrollOffsetMax) / 2;
94 }
95 this.props.scrollTo({ x: offsetX, animated: false });
96 }
97 else {
98 let offsetY = -gestureState.dy;
99 if (offsetY > this.props.scrollOffsetMax) {
100 offsetY -= (offsetY - this.props.scrollOffsetMax) / 2;
101 }
102 this.props.scrollTo({ y: offsetY, animated: false });
103 }
104 }
105 }
106 },
107 onPanResponderRelease: (evt, gestureState) => {
108 // Call the onSwipe prop if the threshold has been exceeded on the right direction
109 const accDistance = this.getAccDistancePerDirection(gestureState);
110 if (accDistance > this.props.swipeThreshold &&
111 this.isSwipeDirectionAllowed(gestureState)) {
112 if (this.props.onSwipeComplete) {
113 this.inSwipeClosingState = true;
114 this.props.onSwipeComplete({
115 swipingDirection: this.getSwipingDirection(gestureState),
116 });
117 return;
118 }
119 // Deprecated. Remove later.
120 if (this.props.onSwipe) {
121 this.inSwipeClosingState = true;
122 this.props.onSwipe();
123 return;
124 }
125 }
126 //Reset backdrop opacity and modal position
127 if (this.props.onSwipeCancel) {
128 this.props.onSwipeCancel();
129 }
130 if (this.backdropRef) {
131 this.backdropRef.transitionTo({
132 opacity: this.props.backdropOpacity,
133 });
134 }
135 Animated.spring(this.state.pan, {
136 toValue: { x: 0, y: 0 },
137 bounciness: 0,
138 }).start();
139 if (this.props.scrollTo) {
140 if (this.props.scrollOffset > this.props.scrollOffsetMax) {
141 this.props.scrollTo({
142 y: this.props.scrollOffsetMax,
143 animated: true,
144 });
145 }
146 }
147 },
148 });
149 };
150 this.getAccDistancePerDirection = (gestureState) => {
151 switch (this.currentSwipingDirection) {
152 case 'up':
153 return -gestureState.dy;
154 case 'down':
155 return gestureState.dy;
156 case 'right':
157 return gestureState.dx;
158 case 'left':
159 return -gestureState.dx;
160 default:
161 return 0;
162 }
163 };
164 this.getSwipingDirection = (gestureState) => {
165 if (Math.abs(gestureState.dx) > Math.abs(gestureState.dy)) {
166 return gestureState.dx > 0 ? 'right' : 'left';
167 }
168 return gestureState.dy > 0 ? 'down' : 'up';
169 };
170 this.calcDistancePercentage = (gestureState) => {
171 switch (this.currentSwipingDirection) {
172 case 'down':
173 return ((gestureState.moveY - gestureState.y0) /
174 ((this.props.deviceHeight || this.state.deviceHeight) -
175 gestureState.y0));
176 case 'up':
177 return reversePercentage(gestureState.moveY / gestureState.y0);
178 case 'left':
179 return reversePercentage(gestureState.moveX / gestureState.x0);
180 case 'right':
181 return ((gestureState.moveX - gestureState.x0) /
182 ((this.props.deviceWidth || this.state.deviceWidth) - gestureState.x0));
183 default:
184 return 0;
185 }
186 };
187 this.createAnimationEventForSwipe = () => {
188 if (this.currentSwipingDirection === 'right' ||
189 this.currentSwipingDirection === 'left') {
190 return Animated.event([null, { dx: this.state.pan.x }]);
191 }
192 else {
193 return Animated.event([null, { dy: this.state.pan.y }]);
194 }
195 };
196 this.isDirectionIncluded = (direction) => {
197 return Array.isArray(this.props.swipeDirection)
198 ? this.props.swipeDirection.includes(direction)
199 : this.props.swipeDirection === direction;
200 };
201 this.isSwipeDirectionAllowed = ({ dy, dx }) => {
202 const draggedDown = dy > 0;
203 const draggedUp = dy < 0;
204 const draggedLeft = dx < 0;
205 const draggedRight = dx > 0;
206 if (this.currentSwipingDirection === 'up' &&
207 this.isDirectionIncluded('up') &&
208 draggedUp) {
209 return true;
210 }
211 else if (this.currentSwipingDirection === 'down' &&
212 this.isDirectionIncluded('down') &&
213 draggedDown) {
214 return true;
215 }
216 else if (this.currentSwipingDirection === 'right' &&
217 this.isDirectionIncluded('right') &&
218 draggedRight) {
219 return true;
220 }
221 else if (this.currentSwipingDirection === 'left' &&
222 this.isDirectionIncluded('left') &&
223 draggedLeft) {
224 return true;
225 }
226 return false;
227 };
228 this.handleDimensionsUpdate = () => {
229 if (!this.props.deviceHeight && !this.props.deviceWidth) {
230 // Here we update the device dimensions in the state if the layout changed
231 // (triggering a render)
232 const deviceWidth = Dimensions.get('window').width;
233 const deviceHeight = Dimensions.get('window').height;
234 if (deviceWidth !== this.state.deviceWidth ||
235 deviceHeight !== this.state.deviceHeight) {
236 this.setState({ deviceWidth, deviceHeight });
237 }
238 }
239 };
240 this.open = () => {
241 if (this.isTransitioning) {
242 return;
243 }
244 this.isTransitioning = true;
245 if (this.backdropRef) {
246 this.backdropRef.transitionTo({ opacity: this.props.backdropOpacity }, this.props.backdropTransitionInTiming);
247 }
248 // This is for resetting the pan position,otherwise the modal gets stuck
249 // at the last released position when you try to open it.
250 // TODO: Could certainly be improved - no idea for the moment.
251 if (this.state.isSwipeable) {
252 this.state.pan.setValue({ x: 0, y: 0 });
253 }
254 if (this.contentRef) {
255 this.props.onModalWillShow && this.props.onModalWillShow();
256 this.contentRef[this.animationIn](this.props.animationInTiming).then(() => {
257 this.isTransitioning = false;
258 if (!this.props.isVisible) {
259 this.close();
260 }
261 else {
262 this.props.onModalShow();
263 }
264 });
265 }
266 };
267 this.close = () => {
268 if (this.isTransitioning) {
269 return;
270 }
271 this.isTransitioning = true;
272 if (this.backdropRef) {
273 this.backdropRef.transitionTo({ opacity: 0 }, this.props.backdropTransitionOutTiming);
274 }
275 let animationOut = this.animationOut;
276 if (this.inSwipeClosingState) {
277 this.inSwipeClosingState = false;
278 if (this.currentSwipingDirection === 'up') {
279 animationOut = 'slideOutUp';
280 }
281 else if (this.currentSwipingDirection === 'down') {
282 animationOut = 'slideOutDown';
283 }
284 else if (this.currentSwipingDirection === 'right') {
285 animationOut = 'slideOutRight';
286 }
287 else if (this.currentSwipingDirection === 'left') {
288 animationOut = 'slideOutLeft';
289 }
290 }
291 if (this.contentRef) {
292 this.props.onModalWillHide && this.props.onModalWillHide();
293 this.contentRef[animationOut](this.props.animationOutTiming).then(() => {
294 this.isTransitioning = false;
295 if (this.props.isVisible) {
296 this.open();
297 }
298 else {
299 this.setState({
300 showContent: false,
301 }, () => {
302 this.setState({
303 isVisible: false,
304 }, () => {
305 this.props.onModalHide();
306 });
307 });
308 }
309 });
310 }
311 };
312 const { animationIn, animationOut } = buildAnimations(extractAnimationFromProps(props));
313 this.animationIn = animationIn;
314 this.animationOut = animationOut;
315 if (this.state.isSwipeable) {
316 this.state = {
317 ...this.state,
318 pan: new Animated.ValueXY(),
319 };
320 this.buildPanResponder();
321 }
322 if (props.isVisible) {
323 this.state = {
324 ...this.state,
325 isVisible: true,
326 showContent: true,
327 };
328 }
329 }
330 static getDerivedStateFromProps(nextProps, state) {
331 if (!state.isVisible && nextProps.isVisible) {
332 return { isVisible: true, showContent: true };
333 }
334 return null;
335 }
336 componentDidMount() {
337 // Show deprecation message
338 if (this.props.onSwipe) {
339 console.warn('`<Modal onSwipe="..." />` is deprecated and will be removed starting from 13.0.0. Use `<Modal onSwipeComplete="..." />` instead.');
340 }
341 DeviceEventEmitter.addListener('didUpdateDimensions', this.handleDimensionsUpdate);
342 if (this.state.isVisible) {
343 this.open();
344 }
345 }
346 componentWillUnmount() {
347 DeviceEventEmitter.removeListener('didUpdateDimensions', this.handleDimensionsUpdate);
348 }
349 componentDidUpdate(prevProps) {
350 // If the animations have been changed then rebuild them to make sure we're
351 // using the most up-to-date ones
352 if (this.props.animationIn !== prevProps.animationIn ||
353 this.props.animationOut !== prevProps.animationOut) {
354 const { animationIn, animationOut } = buildAnimations(extractAnimationFromProps(this.props));
355 this.animationIn = animationIn;
356 this.animationOut = animationOut;
357 }
358 // If backdrop opacity has been changed then make sure to update it
359 if (this.props.backdropOpacity !== prevProps.backdropOpacity &&
360 this.backdropRef) {
361 this.backdropRef.transitionTo({ opacity: this.props.backdropOpacity }, this.props.backdropTransitionInTiming);
362 }
363 // On modal open request, we slide the view up and fade in the backdrop
364 if (this.props.isVisible && !prevProps.isVisible) {
365 this.open();
366 }
367 else if (!this.props.isVisible && prevProps.isVisible) {
368 // On modal close request, we slide the view down and fade out the backdrop
369 this.close();
370 }
371 }
372 render() {
373 /* eslint-disable @typescript-eslint/no-unused-vars */
374 const { animationIn, animationInTiming, animationOut, animationOutTiming, avoidKeyboard, coverScreen, hasBackdrop, backdropColor, backdropOpacity, backdropTransitionInTiming, backdropTransitionOutTiming, customBackdrop, children, deviceHeight: deviceHeightProp, deviceWidth: deviceWidthProp, isVisible, onModalShow, onBackdropPress, onBackButtonPress, useNativeDriver, propagateSwipe, style, ...otherProps } = this.props;
375 const deviceWidth = deviceWidthProp || this.state.deviceWidth;
376 const deviceHeight = deviceHeightProp || this.state.deviceHeight;
377 const computedStyle = [
378 { margin: deviceWidth * 0.05, transform: [{ translateY: 0 }] },
379 styles.content,
380 style,
381 ];
382 let panHandlers = {};
383 let panPosition = {};
384 if (this.state.isSwipeable) {
385 panHandlers = { ...this.panResponder.panHandlers };
386 if (useNativeDriver) {
387 panPosition = {
388 transform: this.state.pan.getTranslateTransform(),
389 };
390 }
391 else {
392 panPosition = this.state.pan.getLayout();
393 }
394 }
395 const _children = this.props.hideModalContentWhileAnimating &&
396 this.props.useNativeDriver &&
397 !this.state.showContent ? (React.createElement(animatable.View, null)) : (children);
398 const containerView = (React.createElement(animatable.View, Object.assign({}, panHandlers, { ref: ref => (this.contentRef = ref), style: [panPosition, computedStyle], pointerEvents: "box-none", useNativeDriver: useNativeDriver }, otherProps), _children));
399 const hasCustomBackdrop = React.isValidElement(customBackdrop);
400 const backdropComputedStyle = [
401 {
402 width: deviceWidth,
403 height: deviceHeight,
404 backgroundColor: this.state.showContent && !hasCustomBackdrop
405 ? backdropColor
406 : 'transparent',
407 },
408 ];
409 const backdropContent = (React.createElement(animatable.View, { ref: ref => (this.backdropRef = ref), useNativeDriver: useNativeDriver, style: [styles.backdrop, backdropComputedStyle] }, hasCustomBackdrop && customBackdrop));
410 let backdrop = null;
411 if (hasCustomBackdrop) {
412 backdrop = backdropContent;
413 }
414 else {
415 // If there's no custom backdrop, handle presses with
416 // TouchableWithoutFeedback
417 backdrop = (React.createElement(TouchableWithoutFeedback, { onPress: onBackdropPress }, backdropContent));
418 }
419 if (!coverScreen && this.state.isVisible) {
420 return (React.createElement(View, { pointerEvents: "box-none", style: [styles.backdrop, styles.containerBox] },
421 hasBackdrop && backdrop,
422 containerView));
423 }
424 return (React.createElement(Modal, Object.assign({ transparent: true, animationType: 'none', visible: this.state.isVisible, onRequestClose: onBackButtonPress }, otherProps),
425 hasBackdrop && backdrop,
426 avoidKeyboard && (React.createElement(KeyboardAvoidingView, { behavior: Platform.OS === 'ios' ? 'padding' : undefined, pointerEvents: "box-none", style: computedStyle.concat([{ margin: 0 }]) }, containerView)),
427 !avoidKeyboard && containerView));
428 }
429}
430ReactNativeModal.propTypes = {
431 animationIn: PropTypes.oneOfType([PropTypes.string, PropTypes.object]),
432 animationInTiming: PropTypes.number,
433 animationOut: PropTypes.oneOfType([PropTypes.string, PropTypes.object]),
434 animationOutTiming: PropTypes.number,
435 avoidKeyboard: PropTypes.bool,
436 coverScreen: PropTypes.bool,
437 hasBackdrop: PropTypes.bool,
438 backdropColor: PropTypes.string,
439 backdropOpacity: PropTypes.number,
440 backdropTransitionInTiming: PropTypes.number,
441 backdropTransitionOutTiming: PropTypes.number,
442 customBackdrop: PropTypes.node,
443 children: PropTypes.node.isRequired,
444 deviceHeight: PropTypes.number,
445 deviceWidth: PropTypes.number,
446 isVisible: PropTypes.bool.isRequired,
447 hideModalContentWhileAnimating: PropTypes.bool,
448 propagateSwipe: PropTypes.bool,
449 onModalShow: PropTypes.func,
450 onModalWillShow: PropTypes.func,
451 onModalHide: PropTypes.func,
452 onModalWillHide: PropTypes.func,
453 onBackButtonPress: PropTypes.func,
454 onBackdropPress: PropTypes.func,
455 onSwipeStart: PropTypes.func,
456 onSwipeMove: PropTypes.func,
457 onSwipeComplete: PropTypes.func,
458 onSwipeCancel: PropTypes.func,
459 swipeThreshold: PropTypes.number,
460 swipeDirection: PropTypes.oneOfType([
461 PropTypes.arrayOf(PropTypes.oneOf(['up', 'down', 'left', 'right'])),
462 PropTypes.oneOf(['up', 'down', 'left', 'right']),
463 ]),
464 useNativeDriver: PropTypes.bool,
465 style: PropTypes.any,
466 scrollTo: PropTypes.func,
467 scrollOffset: PropTypes.number,
468 scrollOffsetMax: PropTypes.number,
469 scrollHorizontal: PropTypes.bool,
470 supportedOrientations: PropTypes.arrayOf(PropTypes.oneOf([
471 'portrait',
472 'portrait-upside-down',
473 'landscape',
474 'landscape-left',
475 'landscape-right',
476 ])),
477};
478ReactNativeModal.defaultProps = {
479 animationIn: 'slideInUp',
480 animationInTiming: 300,
481 animationOut: 'slideOutDown',
482 animationOutTiming: 300,
483 avoidKeyboard: false,
484 coverScreen: true,
485 hasBackdrop: true,
486 backdropColor: 'black',
487 backdropOpacity: 0.7,
488 backdropTransitionInTiming: 300,
489 backdropTransitionOutTiming: 300,
490 customBackdrop: null,
491 useNativeDriver: false,
492 deviceHeight: null,
493 deviceWidth: null,
494 hideModalContentWhileAnimating: false,
495 propagateSwipe: false,
496 isVisible: false,
497 onModalShow: () => null,
498 onModalWillShow: () => null,
499 onModalHide: () => null,
500 onModalWillHide: () => null,
501 onBackdropPress: () => null,
502 onBackButtonPress: () => null,
503 swipeThreshold: 100,
504 scrollTo: null,
505 scrollOffset: 0,
506 scrollOffsetMax: 0,
507 scrollHorizontal: false,
508 supportedOrientations: ['portrait', 'landscape'],
509};
510export default ReactNativeModal;