UNPKG

23.7 kBJavaScriptView Raw
1import * as React from 'react';
2import { Animated, DeviceEventEmitter, Dimensions, KeyboardAvoidingView, Modal, PanResponder, Platform, TouchableWithoutFeedback, View, } from 'react-native';
3import * as 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('screen').width,
25 deviceHeight: Dimensions.get('screen').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 useNativeDriver: false,
139 }).start();
140 if (this.props.scrollTo) {
141 if (this.props.scrollOffset > this.props.scrollOffsetMax) {
142 this.props.scrollTo({
143 y: this.props.scrollOffsetMax,
144 animated: true,
145 });
146 }
147 }
148 },
149 });
150 };
151 this.getAccDistancePerDirection = (gestureState) => {
152 switch (this.currentSwipingDirection) {
153 case 'up':
154 return -gestureState.dy;
155 case 'down':
156 return gestureState.dy;
157 case 'right':
158 return gestureState.dx;
159 case 'left':
160 return -gestureState.dx;
161 default:
162 return 0;
163 }
164 };
165 this.getSwipingDirection = (gestureState) => {
166 if (Math.abs(gestureState.dx) > Math.abs(gestureState.dy)) {
167 return gestureState.dx > 0 ? 'right' : 'left';
168 }
169 return gestureState.dy > 0 ? 'down' : 'up';
170 };
171 this.calcDistancePercentage = (gestureState) => {
172 switch (this.currentSwipingDirection) {
173 case 'down':
174 return ((gestureState.moveY - gestureState.y0) /
175 ((this.props.deviceHeight || this.state.deviceHeight) -
176 gestureState.y0));
177 case 'up':
178 return reversePercentage(gestureState.moveY / gestureState.y0);
179 case 'left':
180 return reversePercentage(gestureState.moveX / gestureState.x0);
181 case 'right':
182 return ((gestureState.moveX - gestureState.x0) /
183 ((this.props.deviceWidth || this.state.deviceWidth) - gestureState.x0));
184 default:
185 return 0;
186 }
187 };
188 this.createAnimationEventForSwipe = () => {
189 if (this.currentSwipingDirection === 'right' ||
190 this.currentSwipingDirection === 'left') {
191 return Animated.event([null, { dx: this.state.pan.x }], {
192 useNativeDriver: false,
193 });
194 }
195 else {
196 return Animated.event([null, { dy: this.state.pan.y }], {
197 useNativeDriver: false,
198 });
199 }
200 };
201 this.isDirectionIncluded = (direction) => {
202 return Array.isArray(this.props.swipeDirection)
203 ? this.props.swipeDirection.includes(direction)
204 : this.props.swipeDirection === direction;
205 };
206 this.isSwipeDirectionAllowed = ({ dy, dx }) => {
207 const draggedDown = dy > 0;
208 const draggedUp = dy < 0;
209 const draggedLeft = dx < 0;
210 const draggedRight = dx > 0;
211 if (this.currentSwipingDirection === 'up' &&
212 this.isDirectionIncluded('up') &&
213 draggedUp) {
214 return true;
215 }
216 else if (this.currentSwipingDirection === 'down' &&
217 this.isDirectionIncluded('down') &&
218 draggedDown) {
219 return true;
220 }
221 else if (this.currentSwipingDirection === 'right' &&
222 this.isDirectionIncluded('right') &&
223 draggedRight) {
224 return true;
225 }
226 else if (this.currentSwipingDirection === 'left' &&
227 this.isDirectionIncluded('left') &&
228 draggedLeft) {
229 return true;
230 }
231 return false;
232 };
233 this.handleDimensionsUpdate = () => {
234 if (!this.props.deviceHeight && !this.props.deviceWidth) {
235 // Here we update the device dimensions in the state if the layout changed
236 // (triggering a render)
237 const deviceWidth = Dimensions.get('screen').width;
238 const deviceHeight = Dimensions.get('screen').height;
239 if (deviceWidth !== this.state.deviceWidth ||
240 deviceHeight !== this.state.deviceHeight) {
241 this.setState({ deviceWidth, deviceHeight });
242 }
243 }
244 };
245 this.open = () => {
246 if (this.isTransitioning) {
247 return;
248 }
249 this.isTransitioning = true;
250 if (this.backdropRef) {
251 this.backdropRef.transitionTo({ opacity: this.props.backdropOpacity }, this.props.backdropTransitionInTiming);
252 }
253 // This is for resetting the pan position,otherwise the modal gets stuck
254 // at the last released position when you try to open it.
255 // TODO: Could certainly be improved - no idea for the moment.
256 if (this.state.isSwipeable) {
257 this.state.pan.setValue({ x: 0, y: 0 });
258 }
259 if (this.contentRef) {
260 this.props.onModalWillShow && this.props.onModalWillShow();
261 this.contentRef
262 .animate(this.animationIn, this.props.animationInTiming)
263 .then(() => {
264 this.isTransitioning = false;
265 if (!this.props.isVisible) {
266 this.close();
267 }
268 else {
269 this.props.onModalShow();
270 }
271 });
272 }
273 };
274 this.close = () => {
275 if (this.isTransitioning) {
276 return;
277 }
278 this.isTransitioning = true;
279 if (this.backdropRef) {
280 this.backdropRef.transitionTo({ opacity: 0 }, this.props.backdropTransitionOutTiming);
281 }
282 let animationOut = this.animationOut;
283 if (this.inSwipeClosingState) {
284 this.inSwipeClosingState = false;
285 if (this.currentSwipingDirection === 'up') {
286 animationOut = 'slideOutUp';
287 }
288 else if (this.currentSwipingDirection === 'down') {
289 animationOut = 'slideOutDown';
290 }
291 else if (this.currentSwipingDirection === 'right') {
292 animationOut = 'slideOutRight';
293 }
294 else if (this.currentSwipingDirection === 'left') {
295 animationOut = 'slideOutLeft';
296 }
297 }
298 if (this.contentRef) {
299 this.props.onModalWillHide && this.props.onModalWillHide();
300 this.contentRef
301 .animate(animationOut, this.props.animationOutTiming)
302 .then(() => {
303 this.isTransitioning = false;
304 if (this.props.isVisible) {
305 this.open();
306 }
307 else {
308 this.setState({
309 showContent: false,
310 }, () => {
311 this.setState({
312 isVisible: false,
313 }, () => {
314 this.props.onModalHide();
315 });
316 });
317 }
318 });
319 }
320 };
321 const { animationIn, animationOut } = buildAnimations(extractAnimationFromProps(props));
322 this.animationIn = animationIn;
323 this.animationOut = animationOut;
324 if (this.state.isSwipeable) {
325 this.state = {
326 ...this.state,
327 pan: new Animated.ValueXY(),
328 };
329 this.buildPanResponder();
330 }
331 if (props.isVisible) {
332 this.state = {
333 ...this.state,
334 isVisible: true,
335 showContent: true,
336 };
337 }
338 }
339 static getDerivedStateFromProps(nextProps, state) {
340 if (!state.isVisible && nextProps.isVisible) {
341 return { isVisible: true, showContent: true };
342 }
343 return null;
344 }
345 componentDidMount() {
346 // Show deprecation message
347 if (this.props.onSwipe) {
348 console.warn('`<Modal onSwipe="..." />` is deprecated and will be removed starting from 13.0.0. Use `<Modal onSwipeComplete="..." />` instead.');
349 }
350 DeviceEventEmitter.addListener('didUpdateDimensions', this.handleDimensionsUpdate);
351 if (this.state.isVisible) {
352 this.open();
353 }
354 }
355 componentWillUnmount() {
356 DeviceEventEmitter.removeListener('didUpdateDimensions', this.handleDimensionsUpdate);
357 }
358 componentDidUpdate(prevProps) {
359 // If the animations have been changed then rebuild them to make sure we're
360 // using the most up-to-date ones
361 if (this.props.animationIn !== prevProps.animationIn ||
362 this.props.animationOut !== prevProps.animationOut) {
363 const { animationIn, animationOut } = buildAnimations(extractAnimationFromProps(this.props));
364 this.animationIn = animationIn;
365 this.animationOut = animationOut;
366 }
367 // If backdrop opacity has been changed then make sure to update it
368 if (this.props.backdropOpacity !== prevProps.backdropOpacity &&
369 this.backdropRef) {
370 this.backdropRef.transitionTo({ opacity: this.props.backdropOpacity }, this.props.backdropTransitionInTiming);
371 }
372 // On modal open request, we slide the view up and fade in the backdrop
373 if (this.props.isVisible && !prevProps.isVisible) {
374 this.open();
375 }
376 else if (!this.props.isVisible && prevProps.isVisible) {
377 // On modal close request, we slide the view down and fade out the backdrop
378 this.close();
379 }
380 }
381 render() {
382 /* eslint-disable @typescript-eslint/no-unused-vars */
383 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;
384 const deviceWidth = deviceWidthProp || this.state.deviceWidth;
385 const deviceHeight = deviceHeightProp || this.state.deviceHeight;
386 const computedStyle = [
387 { margin: deviceWidth * 0.05, transform: [{ translateY: 0 }] },
388 styles.content,
389 style,
390 ];
391 let panHandlers = {};
392 let panPosition = {};
393 if (this.state.isSwipeable) {
394 panHandlers = { ...this.panResponder.panHandlers };
395 if (useNativeDriver) {
396 panPosition = {
397 transform: this.state.pan.getTranslateTransform(),
398 };
399 }
400 else {
401 panPosition = this.state.pan.getLayout();
402 }
403 }
404 const _children = this.props.hideModalContentWhileAnimating &&
405 this.props.useNativeDriver &&
406 !this.state.showContent ? (React.createElement(animatable.View, null)) : (children);
407 const containerView = (React.createElement(animatable.View, Object.assign({}, panHandlers, { ref: ref => (this.contentRef = ref), style: [panPosition, computedStyle], pointerEvents: "box-none", useNativeDriver: useNativeDriver }, otherProps), _children));
408 const hasCustomBackdrop = React.isValidElement(customBackdrop);
409 const backdropComputedStyle = [
410 {
411 width: deviceWidth,
412 height: deviceHeight,
413 backgroundColor: this.state.showContent && !hasCustomBackdrop
414 ? backdropColor
415 : 'transparent',
416 },
417 ];
418 const backdropContent = (React.createElement(animatable.View, { ref: ref => (this.backdropRef = ref), useNativeDriver: useNativeDriver, style: [styles.backdrop, backdropComputedStyle] }, hasCustomBackdrop && customBackdrop));
419 let backdrop = null;
420 if (hasCustomBackdrop) {
421 backdrop = backdropContent;
422 }
423 else {
424 // If there's no custom backdrop, handle presses with
425 // TouchableWithoutFeedback
426 backdrop = (React.createElement(TouchableWithoutFeedback, { onPress: onBackdropPress }, backdropContent));
427 }
428 if (!coverScreen && this.state.isVisible) {
429 return (React.createElement(View, { pointerEvents: "box-none", style: [styles.backdrop, styles.containerBox] },
430 hasBackdrop && backdrop,
431 containerView));
432 }
433 return (React.createElement(Modal, Object.assign({ transparent: true, animationType: 'none', visible: this.state.isVisible, onRequestClose: onBackButtonPress }, otherProps),
434 hasBackdrop && backdrop,
435 avoidKeyboard && (React.createElement(KeyboardAvoidingView, { behavior: Platform.OS === 'ios' ? 'padding' : undefined, pointerEvents: "box-none", style: computedStyle.concat([{ margin: 0 }]) }, containerView)),
436 !avoidKeyboard && containerView));
437 }
438}
439ReactNativeModal.propTypes = {
440 animationIn: PropTypes.oneOfType([PropTypes.string, PropTypes.object]),
441 animationInTiming: PropTypes.number,
442 animationOut: PropTypes.oneOfType([PropTypes.string, PropTypes.object]),
443 animationOutTiming: PropTypes.number,
444 avoidKeyboard: PropTypes.bool,
445 coverScreen: PropTypes.bool,
446 hasBackdrop: PropTypes.bool,
447 backdropColor: PropTypes.string,
448 backdropOpacity: PropTypes.number,
449 backdropTransitionInTiming: PropTypes.number,
450 backdropTransitionOutTiming: PropTypes.number,
451 customBackdrop: PropTypes.node,
452 children: PropTypes.node.isRequired,
453 deviceHeight: PropTypes.number,
454 deviceWidth: PropTypes.number,
455 isVisible: PropTypes.bool.isRequired,
456 hideModalContentWhileAnimating: PropTypes.bool,
457 propagateSwipe: PropTypes.bool,
458 onModalShow: PropTypes.func,
459 onModalWillShow: PropTypes.func,
460 onModalHide: PropTypes.func,
461 onModalWillHide: PropTypes.func,
462 onBackButtonPress: PropTypes.func,
463 onBackdropPress: PropTypes.func,
464 onSwipeStart: PropTypes.func,
465 onSwipeMove: PropTypes.func,
466 onSwipeComplete: PropTypes.func,
467 onSwipeCancel: PropTypes.func,
468 swipeThreshold: PropTypes.number,
469 swipeDirection: PropTypes.oneOfType([
470 PropTypes.arrayOf(PropTypes.oneOf(['up', 'down', 'left', 'right'])),
471 PropTypes.oneOf(['up', 'down', 'left', 'right']),
472 ]),
473 useNativeDriver: PropTypes.bool,
474 style: PropTypes.any,
475 scrollTo: PropTypes.func,
476 scrollOffset: PropTypes.number,
477 scrollOffsetMax: PropTypes.number,
478 scrollHorizontal: PropTypes.bool,
479 supportedOrientations: PropTypes.arrayOf(PropTypes.oneOf([
480 'portrait',
481 'portrait-upside-down',
482 'landscape',
483 'landscape-left',
484 'landscape-right',
485 ])),
486};
487ReactNativeModal.defaultProps = {
488 animationIn: 'slideInUp',
489 animationInTiming: 300,
490 animationOut: 'slideOutDown',
491 animationOutTiming: 300,
492 avoidKeyboard: false,
493 coverScreen: true,
494 hasBackdrop: true,
495 backdropColor: 'black',
496 backdropOpacity: 0.7,
497 backdropTransitionInTiming: 300,
498 backdropTransitionOutTiming: 300,
499 customBackdrop: null,
500 useNativeDriver: false,
501 deviceHeight: null,
502 deviceWidth: null,
503 hideModalContentWhileAnimating: false,
504 propagateSwipe: false,
505 isVisible: false,
506 onModalShow: () => null,
507 onModalWillShow: () => null,
508 onModalHide: () => null,
509 onModalWillHide: () => null,
510 onBackdropPress: () => null,
511 onBackButtonPress: () => null,
512 swipeThreshold: 100,
513 scrollTo: null,
514 scrollOffset: 0,
515 scrollOffsetMax: 0,
516 scrollHorizontal: false,
517 supportedOrientations: ['portrait', 'landscape'],
518};
519export default ReactNativeModal;