1 | import Color from 'color';
|
2 | import * as React from 'react';
|
3 | import {
|
4 | Animated,
|
5 | InteractionManager,
|
6 | Platform,
|
7 | StyleProp,
|
8 | StyleSheet,
|
9 | View,
|
10 | ViewProps,
|
11 | ViewStyle,
|
12 | } from 'react-native';
|
13 | import type { EdgeInsets } from 'react-native-safe-area-context';
|
14 |
|
15 | import { forModalPresentationIOS } from '../../TransitionConfigs/CardStyleInterpolators';
|
16 | import type {
|
17 | GestureDirection,
|
18 | Layout,
|
19 | StackCardInterpolationProps,
|
20 | StackCardStyleInterpolator,
|
21 | TransitionSpec,
|
22 | } from '../../types';
|
23 | import CardAnimationContext from '../../utils/CardAnimationContext';
|
24 | import getDistanceForDirection from '../../utils/getDistanceForDirection';
|
25 | import getInvertedMultiplier from '../../utils/getInvertedMultiplier';
|
26 | import memoize from '../../utils/memoize';
|
27 | import {
|
28 | GestureState,
|
29 | PanGestureHandler,
|
30 | PanGestureHandlerGestureEvent,
|
31 | } from '../GestureHandler';
|
32 | import ModalStatusBarManager from '../ModalStatusBarManager';
|
33 | import CardSheet, { CardSheetRef } from './CardSheet';
|
34 |
|
35 | type Props = ViewProps & {
|
36 | interpolationIndex: number;
|
37 | closing: boolean;
|
38 | next?: Animated.AnimatedInterpolation;
|
39 | current: Animated.AnimatedInterpolation;
|
40 | gesture: Animated.Value;
|
41 | layout: Layout;
|
42 | insets: EdgeInsets;
|
43 | headerDarkContent: boolean | undefined;
|
44 | pageOverflowEnabled: boolean;
|
45 | gestureDirection: GestureDirection;
|
46 | onOpen: () => void;
|
47 | onClose: () => void;
|
48 | onTransition: (props: { closing: boolean; gesture: boolean }) => void;
|
49 | onGestureBegin: () => void;
|
50 | onGestureCanceled: () => void;
|
51 | onGestureEnd: () => void;
|
52 | children: React.ReactNode;
|
53 | overlay: (props: {
|
54 | style: Animated.WithAnimatedValue<StyleProp<ViewStyle>>;
|
55 | }) => React.ReactNode;
|
56 | overlayEnabled: boolean;
|
57 | shadowEnabled: boolean;
|
58 | gestureEnabled: boolean;
|
59 | gestureResponseDistance?: number;
|
60 | gestureVelocityImpact: number;
|
61 | transitionSpec: {
|
62 | open: TransitionSpec;
|
63 | close: TransitionSpec;
|
64 | };
|
65 | styleInterpolator: StackCardStyleInterpolator;
|
66 | containerStyle?: StyleProp<ViewStyle>;
|
67 | contentStyle?: StyleProp<ViewStyle>;
|
68 | };
|
69 |
|
70 | const GESTURE_VELOCITY_IMPACT = 0.3;
|
71 |
|
72 | const TRUE = 1;
|
73 | const FALSE = 0;
|
74 |
|
75 |
|
76 |
|
77 |
|
78 | const GESTURE_RESPONSE_DISTANCE_HORIZONTAL = 50;
|
79 | const GESTURE_RESPONSE_DISTANCE_VERTICAL = 135;
|
80 |
|
81 | const useNativeDriver = Platform.OS !== 'web';
|
82 |
|
83 | const hasOpacityStyle = (style: any) => {
|
84 | if (style) {
|
85 | const flattenedStyle = StyleSheet.flatten(style);
|
86 | return flattenedStyle.opacity != null;
|
87 | }
|
88 |
|
89 | return false;
|
90 | };
|
91 |
|
92 | export default class Card extends React.Component<Props> {
|
93 | static defaultProps = {
|
94 | shadowEnabled: false,
|
95 | gestureEnabled: true,
|
96 | gestureVelocityImpact: GESTURE_VELOCITY_IMPACT,
|
97 | overlay: ({
|
98 | style,
|
99 | }: {
|
100 | style: Animated.WithAnimatedValue<StyleProp<ViewStyle>>;
|
101 | }) =>
|
102 | style ? (
|
103 | <Animated.View pointerEvents="none" style={[styles.overlay, style]} />
|
104 | ) : null,
|
105 | };
|
106 |
|
107 | componentDidMount() {
|
108 | this.animate({ closing: this.props.closing });
|
109 | this.isCurrentlyMounted = true;
|
110 | }
|
111 |
|
112 | componentDidUpdate(prevProps: Props) {
|
113 | const { layout, gestureDirection, closing } = this.props;
|
114 | const { width, height } = layout;
|
115 |
|
116 | if (width !== prevProps.layout.width) {
|
117 | this.layout.width.setValue(width);
|
118 | }
|
119 |
|
120 | if (height !== prevProps.layout.height) {
|
121 | this.layout.height.setValue(height);
|
122 | }
|
123 |
|
124 | if (gestureDirection !== prevProps.gestureDirection) {
|
125 | this.inverted.setValue(getInvertedMultiplier(gestureDirection));
|
126 | }
|
127 |
|
128 | const toValue = this.getAnimateToValue(this.props);
|
129 |
|
130 | if (
|
131 | this.getAnimateToValue(prevProps) !== toValue ||
|
132 | this.lastToValue !== toValue
|
133 | ) {
|
134 |
|
135 |
|
136 |
|
137 |
|
138 |
|
139 | this.animate({ closing });
|
140 | }
|
141 | }
|
142 |
|
143 | componentWillUnmount() {
|
144 | this.props.gesture.stopAnimation();
|
145 | this.isCurrentlyMounted = false;
|
146 | this.handleEndInteraction();
|
147 | }
|
148 |
|
149 | private isCurrentlyMounted = false;
|
150 |
|
151 | private isClosing = new Animated.Value(FALSE);
|
152 |
|
153 | private inverted = new Animated.Value(
|
154 | getInvertedMultiplier(this.props.gestureDirection)
|
155 | );
|
156 |
|
157 | private layout = {
|
158 | width: new Animated.Value(this.props.layout.width),
|
159 | height: new Animated.Value(this.props.layout.height),
|
160 | };
|
161 |
|
162 | private isSwiping = new Animated.Value(FALSE);
|
163 |
|
164 | private interactionHandle: number | undefined;
|
165 |
|
166 | private pendingGestureCallback: number | undefined;
|
167 |
|
168 | private lastToValue: number | undefined;
|
169 |
|
170 | private animate = ({
|
171 | closing,
|
172 | velocity,
|
173 | }: {
|
174 | closing: boolean;
|
175 | velocity?: number;
|
176 | }) => {
|
177 | const { gesture, transitionSpec, onOpen, onClose, onTransition } =
|
178 | this.props;
|
179 |
|
180 | const toValue = this.getAnimateToValue({
|
181 | ...this.props,
|
182 | closing,
|
183 | });
|
184 |
|
185 | this.lastToValue = toValue;
|
186 |
|
187 | this.isClosing.setValue(closing ? TRUE : FALSE);
|
188 |
|
189 | const spec = closing ? transitionSpec.close : transitionSpec.open;
|
190 |
|
191 | const animation =
|
192 | spec.animation === 'spring' ? Animated.spring : Animated.timing;
|
193 |
|
194 | this.setPointerEventsEnabled(!closing);
|
195 | this.handleStartInteraction();
|
196 |
|
197 | clearTimeout(this.pendingGestureCallback);
|
198 |
|
199 | onTransition?.({ closing, gesture: velocity !== undefined });
|
200 | animation(gesture, {
|
201 | ...spec.config,
|
202 | velocity,
|
203 | toValue,
|
204 | useNativeDriver,
|
205 | isInteraction: false,
|
206 | }).start(({ finished }) => {
|
207 | this.handleEndInteraction();
|
208 |
|
209 | clearTimeout(this.pendingGestureCallback);
|
210 |
|
211 | if (finished) {
|
212 | if (closing) {
|
213 | onClose();
|
214 | } else {
|
215 | onOpen();
|
216 | }
|
217 |
|
218 | if (this.isCurrentlyMounted) {
|
219 |
|
220 | this.forceUpdate();
|
221 | }
|
222 | }
|
223 | });
|
224 | };
|
225 |
|
226 | private getAnimateToValue = ({
|
227 | closing,
|
228 | layout,
|
229 | gestureDirection,
|
230 | }: {
|
231 | closing?: boolean;
|
232 | layout: Layout;
|
233 | gestureDirection: GestureDirection;
|
234 | }) => {
|
235 | if (!closing) {
|
236 | return 0;
|
237 | }
|
238 |
|
239 | return getDistanceForDirection(layout, gestureDirection);
|
240 | };
|
241 |
|
242 | private setPointerEventsEnabled = (enabled: boolean) => {
|
243 | const pointerEvents = enabled ? 'box-none' : 'none';
|
244 |
|
245 | this.ref.current?.setPointerEvents(pointerEvents);
|
246 | };
|
247 |
|
248 | private handleStartInteraction = () => {
|
249 | if (this.interactionHandle === undefined) {
|
250 | this.interactionHandle = InteractionManager.createInteractionHandle();
|
251 | }
|
252 | };
|
253 |
|
254 | private handleEndInteraction = () => {
|
255 | if (this.interactionHandle !== undefined) {
|
256 | InteractionManager.clearInteractionHandle(this.interactionHandle);
|
257 | this.interactionHandle = undefined;
|
258 | }
|
259 | };
|
260 |
|
261 | private handleGestureStateChange = ({
|
262 | nativeEvent,
|
263 | }: PanGestureHandlerGestureEvent) => {
|
264 | const {
|
265 | layout,
|
266 | onClose,
|
267 | onGestureBegin,
|
268 | onGestureCanceled,
|
269 | onGestureEnd,
|
270 | gestureDirection,
|
271 | gestureVelocityImpact,
|
272 | } = this.props;
|
273 |
|
274 | switch (nativeEvent.state) {
|
275 | case GestureState.ACTIVE:
|
276 | this.isSwiping.setValue(TRUE);
|
277 | this.handleStartInteraction();
|
278 | onGestureBegin?.();
|
279 | break;
|
280 | case GestureState.CANCELLED: {
|
281 | this.isSwiping.setValue(FALSE);
|
282 | this.handleEndInteraction();
|
283 |
|
284 | const velocity =
|
285 | gestureDirection === 'vertical' ||
|
286 | gestureDirection === 'vertical-inverted'
|
287 | ? nativeEvent.velocityY
|
288 | : nativeEvent.velocityX;
|
289 |
|
290 | this.animate({ closing: this.props.closing, velocity });
|
291 |
|
292 | onGestureCanceled?.();
|
293 | break;
|
294 | }
|
295 | case GestureState.END: {
|
296 | this.isSwiping.setValue(FALSE);
|
297 |
|
298 | let distance;
|
299 | let translation;
|
300 | let velocity;
|
301 |
|
302 | if (
|
303 | gestureDirection === 'vertical' ||
|
304 | gestureDirection === 'vertical-inverted'
|
305 | ) {
|
306 | distance = layout.height;
|
307 | translation = nativeEvent.translationY;
|
308 | velocity = nativeEvent.velocityY;
|
309 | } else {
|
310 | distance = layout.width;
|
311 | translation = nativeEvent.translationX;
|
312 | velocity = nativeEvent.velocityX;
|
313 | }
|
314 |
|
315 | const closing =
|
316 | (translation + velocity * gestureVelocityImpact) *
|
317 | getInvertedMultiplier(gestureDirection) >
|
318 | distance / 2
|
319 | ? velocity !== 0 || translation !== 0
|
320 | : this.props.closing;
|
321 |
|
322 | this.animate({ closing, velocity });
|
323 |
|
324 | if (closing) {
|
325 |
|
326 |
|
327 | this.pendingGestureCallback = setTimeout(() => {
|
328 | onClose();
|
329 |
|
330 |
|
331 |
|
332 | this.forceUpdate();
|
333 | }, 32) as any as number;
|
334 | }
|
335 |
|
336 | onGestureEnd?.();
|
337 | break;
|
338 | }
|
339 | }
|
340 | };
|
341 |
|
342 |
|
343 | private getInterpolatedStyle = memoize(
|
344 | (
|
345 | styleInterpolator: StackCardStyleInterpolator,
|
346 | animation: StackCardInterpolationProps
|
347 | ) => styleInterpolator(animation)
|
348 | );
|
349 |
|
350 |
|
351 | private getCardAnimation = memoize(
|
352 | (
|
353 | interpolationIndex: number,
|
354 | current: Animated.AnimatedInterpolation,
|
355 | next: Animated.AnimatedInterpolation | undefined,
|
356 | layout: Layout,
|
357 | insetTop: number,
|
358 | insetRight: number,
|
359 | insetBottom: number,
|
360 | insetLeft: number
|
361 | ) => ({
|
362 | index: interpolationIndex,
|
363 | current: { progress: current },
|
364 | next: next && { progress: next },
|
365 | closing: this.isClosing,
|
366 | swiping: this.isSwiping,
|
367 | inverted: this.inverted,
|
368 | layouts: {
|
369 | screen: layout,
|
370 | },
|
371 | insets: {
|
372 | top: insetTop,
|
373 | right: insetRight,
|
374 | bottom: insetBottom,
|
375 | left: insetLeft,
|
376 | },
|
377 | })
|
378 | );
|
379 |
|
380 | private gestureActivationCriteria() {
|
381 | const { layout, gestureDirection, gestureResponseDistance } = this.props;
|
382 | const enableTrackpadTwoFingerGesture = true;
|
383 |
|
384 | const distance =
|
385 | gestureResponseDistance !== undefined
|
386 | ? gestureResponseDistance
|
387 | : gestureDirection === 'vertical' ||
|
388 | gestureDirection === 'vertical-inverted'
|
389 | ? GESTURE_RESPONSE_DISTANCE_VERTICAL
|
390 | : GESTURE_RESPONSE_DISTANCE_HORIZONTAL;
|
391 |
|
392 | if (gestureDirection === 'vertical') {
|
393 | return {
|
394 | maxDeltaX: 15,
|
395 | minOffsetY: 5,
|
396 | hitSlop: { bottom: -layout.height + distance },
|
397 | enableTrackpadTwoFingerGesture,
|
398 | };
|
399 | } else if (gestureDirection === 'vertical-inverted') {
|
400 | return {
|
401 | maxDeltaX: 15,
|
402 | minOffsetY: -5,
|
403 | hitSlop: { top: -layout.height + distance },
|
404 | enableTrackpadTwoFingerGesture,
|
405 | };
|
406 | } else {
|
407 | const hitSlop = -layout.width + distance;
|
408 | const invertedMultiplier = getInvertedMultiplier(gestureDirection);
|
409 |
|
410 | if (invertedMultiplier === 1) {
|
411 | return {
|
412 | minOffsetX: 5,
|
413 | maxDeltaY: 20,
|
414 | hitSlop: { right: hitSlop },
|
415 | enableTrackpadTwoFingerGesture,
|
416 | };
|
417 | } else {
|
418 | return {
|
419 | minOffsetX: -5,
|
420 | maxDeltaY: 20,
|
421 | hitSlop: { left: hitSlop },
|
422 | enableTrackpadTwoFingerGesture,
|
423 | };
|
424 | }
|
425 | }
|
426 | }
|
427 |
|
428 | private ref = React.createRef<CardSheetRef>();
|
429 |
|
430 | render() {
|
431 | const {
|
432 | styleInterpolator,
|
433 | interpolationIndex,
|
434 | current,
|
435 | gesture,
|
436 | next,
|
437 | layout,
|
438 | insets,
|
439 | overlay,
|
440 | overlayEnabled,
|
441 | shadowEnabled,
|
442 | gestureEnabled,
|
443 | gestureDirection,
|
444 | pageOverflowEnabled,
|
445 | headerDarkContent,
|
446 | children,
|
447 | containerStyle: customContainerStyle,
|
448 | contentStyle,
|
449 | ...rest
|
450 | } = this.props;
|
451 |
|
452 | const interpolationProps = this.getCardAnimation(
|
453 | interpolationIndex,
|
454 | current,
|
455 | next,
|
456 | layout,
|
457 | insets.top,
|
458 | insets.right,
|
459 | insets.bottom,
|
460 | insets.left
|
461 | );
|
462 |
|
463 | const interpolatedStyle = this.getInterpolatedStyle(
|
464 | styleInterpolator,
|
465 | interpolationProps
|
466 | );
|
467 |
|
468 | const { containerStyle, cardStyle, overlayStyle, shadowStyle } =
|
469 | interpolatedStyle;
|
470 |
|
471 | const handleGestureEvent = gestureEnabled
|
472 | ? Animated.event(
|
473 | [
|
474 | {
|
475 | nativeEvent:
|
476 | gestureDirection === 'vertical' ||
|
477 | gestureDirection === 'vertical-inverted'
|
478 | ? { translationY: gesture }
|
479 | : { translationX: gesture },
|
480 | },
|
481 | ],
|
482 | { useNativeDriver }
|
483 | )
|
484 | : undefined;
|
485 |
|
486 | const { backgroundColor } = StyleSheet.flatten(contentStyle || {});
|
487 | const isTransparent =
|
488 | typeof backgroundColor === 'string'
|
489 | ? Color(backgroundColor).alpha() === 0
|
490 | : false;
|
491 |
|
492 | return (
|
493 | <CardAnimationContext.Provider value={interpolationProps}>
|
494 | {
|
495 |
|
496 |
|
497 | Platform.OS === 'ios' &&
|
498 | overlayEnabled &&
|
499 | next &&
|
500 | getIsModalPresentation(styleInterpolator) ? (
|
501 | <ModalStatusBarManager
|
502 | dark={headerDarkContent}
|
503 | layout={layout}
|
504 | insets={insets}
|
505 | style={cardStyle}
|
506 | />
|
507 | ) : null
|
508 | }
|
509 | <Animated.View
|
510 | style={{
|
511 |
|
512 |
|
513 |
|
514 |
|
515 | opacity: current,
|
516 | }}
|
517 |
|
518 | collapsable={false}
|
519 | />
|
520 | <View pointerEvents="box-none" {...rest}>
|
521 | {overlayEnabled ? (
|
522 | <View pointerEvents="box-none" style={StyleSheet.absoluteFill}>
|
523 | {overlay({ style: overlayStyle })}
|
524 | </View>
|
525 | ) : null}
|
526 | <Animated.View
|
527 | style={[styles.container, containerStyle, customContainerStyle]}
|
528 | pointerEvents="box-none"
|
529 | >
|
530 | <PanGestureHandler
|
531 | enabled={layout.width !== 0 && gestureEnabled}
|
532 | onGestureEvent={handleGestureEvent}
|
533 | onHandlerStateChange={this.handleGestureStateChange}
|
534 | {...this.gestureActivationCriteria()}
|
535 | >
|
536 | <Animated.View
|
537 | needsOffscreenAlphaCompositing={hasOpacityStyle(cardStyle)}
|
538 | style={[styles.container, cardStyle]}
|
539 | >
|
540 | {shadowEnabled && shadowStyle && !isTransparent ? (
|
541 | <Animated.View
|
542 | style={[
|
543 | styles.shadow,
|
544 | gestureDirection === 'horizontal'
|
545 | ? [styles.shadowHorizontal, styles.shadowLeft]
|
546 | : gestureDirection === 'horizontal-inverted'
|
547 | ? [styles.shadowHorizontal, styles.shadowRight]
|
548 | : gestureDirection === 'vertical'
|
549 | ? [styles.shadowVertical, styles.shadowTop]
|
550 | : [styles.shadowVertical, styles.shadowBottom],
|
551 | { backgroundColor },
|
552 | shadowStyle,
|
553 | ]}
|
554 | pointerEvents="none"
|
555 | />
|
556 | ) : null}
|
557 | <CardSheet
|
558 | ref={this.ref}
|
559 | enabled={pageOverflowEnabled}
|
560 | layout={layout}
|
561 | style={contentStyle}
|
562 | >
|
563 | {children}
|
564 | </CardSheet>
|
565 | </Animated.View>
|
566 | </PanGestureHandler>
|
567 | </Animated.View>
|
568 | </View>
|
569 | </CardAnimationContext.Provider>
|
570 | );
|
571 | }
|
572 | }
|
573 |
|
574 | export const getIsModalPresentation = (
|
575 | cardStyleInterpolator: StackCardStyleInterpolator
|
576 | ) => {
|
577 | return (
|
578 | cardStyleInterpolator === forModalPresentationIOS ||
|
579 |
|
580 | cardStyleInterpolator.name === 'forModalPresentationIOS'
|
581 | );
|
582 | };
|
583 |
|
584 | const styles = StyleSheet.create({
|
585 | container: {
|
586 | flex: 1,
|
587 | },
|
588 | overlay: {
|
589 | flex: 1,
|
590 | backgroundColor: '#000',
|
591 | },
|
592 | shadow: {
|
593 | position: 'absolute',
|
594 | shadowRadius: 5,
|
595 | shadowColor: '#000',
|
596 | shadowOpacity: 0.3,
|
597 | },
|
598 | shadowHorizontal: {
|
599 | top: 0,
|
600 | bottom: 0,
|
601 | width: 3,
|
602 | shadowOffset: { width: -1, height: 1 },
|
603 | },
|
604 | shadowLeft: {
|
605 | left: 0,
|
606 | },
|
607 | shadowRight: {
|
608 | right: 0,
|
609 | },
|
610 | shadowVertical: {
|
611 | left: 0,
|
612 | right: 0,
|
613 | height: 3,
|
614 | shadowOffset: { width: 1, height: -1 },
|
615 | },
|
616 | shadowTop: {
|
617 | top: 0,
|
618 | },
|
619 | shadowBottom: {
|
620 | bottom: 0,
|
621 | },
|
622 | });
|