1 | import * as React from 'react';
|
2 | import { Animated, DeviceEventEmitter, Dimensions, KeyboardAvoidingView, Modal, PanResponder, Platform, TouchableWithoutFeedback, View, } from 'react-native';
|
3 | import 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('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 |
|
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 | }).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 |
|
231 |
|
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 |
|
249 |
|
250 |
|
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 |
|
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 |
|
351 |
|
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 |
|
359 | if (this.props.backdropOpacity !== prevProps.backdropOpacity &&
|
360 | this.backdropRef) {
|
361 | this.backdropRef.transitionTo({ opacity: this.props.backdropOpacity }, this.props.backdropTransitionInTiming);
|
362 | }
|
363 |
|
364 | if (this.props.isVisible && !prevProps.isVisible) {
|
365 | this.open();
|
366 | }
|
367 | else if (!this.props.isVisible && prevProps.isVisible) {
|
368 |
|
369 | this.close();
|
370 | }
|
371 | }
|
372 | render() {
|
373 |
|
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 |
|
416 |
|
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 | }
|
430 | ReactNativeModal.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 | };
|
478 | ReactNativeModal.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 | };
|
510 | export default ReactNativeModal;
|