1 | import * as React from 'react';
|
2 | import { Animated, DeviceEventEmitter, Dimensions, KeyboardAvoidingView, Modal, PanResponder, Platform, TouchableWithoutFeedback, View, } from 'react-native';
|
3 | import * as PropTypes from 'prop-types';
|
4 | import * as animatable from 'react-native-animatable';
|
5 | import { initializeAnimations, buildAnimations, reversePercentage, } from './utils';
|
6 | import styles from './modal.style';
|
7 |
|
8 | initializeAnimations();
|
9 | const extractAnimationFromProps = (props) => ({
|
10 | animationIn: props.animationIn,
|
11 | animationOut: props.animationOut,
|
12 | });
|
13 | export class ReactNativeModal extends React.Component {
|
14 | constructor(props) {
|
15 | super(props);
|
16 |
|
17 |
|
18 |
|
19 |
|
20 |
|
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 |
|
38 |
|
39 | if (!this.props.propagateSwipe) {
|
40 |
|
41 |
|
42 |
|
43 |
|
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;
|
57 | }
|
58 | if (this.props.onSwipeStart) {
|
59 | this.props.onSwipeStart();
|
60 | }
|
61 |
|
62 |
|
63 | this.currentSwipingDirection = null;
|
64 | return true;
|
65 | },
|
66 | onPanResponderMove: (evt, gestureState) => {
|
67 |
|
68 |
|
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 |
|
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 |
|
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 |
|
120 | if (this.props.onSwipe) {
|
121 | this.inSwipeClosingState = true;
|
122 | this.props.onSwipe();
|
123 | return;
|
124 | }
|
125 | }
|
126 |
|
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 |
|
236 |
|
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 |
|
254 |
|
255 |
|
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 |
|
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 |
|
360 |
|
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 |
|
368 | if (this.props.backdropOpacity !== prevProps.backdropOpacity &&
|
369 | this.backdropRef) {
|
370 | this.backdropRef.transitionTo({ opacity: this.props.backdropOpacity }, this.props.backdropTransitionInTiming);
|
371 | }
|
372 |
|
373 | if (this.props.isVisible && !prevProps.isVisible) {
|
374 | this.open();
|
375 | }
|
376 | else if (!this.props.isVisible && prevProps.isVisible) {
|
377 |
|
378 | this.close();
|
379 | }
|
380 | }
|
381 | render() {
|
382 |
|
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 |
|
425 |
|
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 | }
|
439 | ReactNativeModal.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 | };
|
487 | ReactNativeModal.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 | };
|
519 | export default ReactNativeModal;
|