UNPKG

17.4 kBTypeScriptView Raw
1import Color from 'color';
2import * as React from 'react';
3import {
4 Animated,
5 InteractionManager,
6 Platform,
7 StyleProp,
8 StyleSheet,
9 View,
10 ViewProps,
11 ViewStyle,
12} from 'react-native';
13import type { EdgeInsets } from 'react-native-safe-area-context';
14
15import { forModalPresentationIOS } from '../../TransitionConfigs/CardStyleInterpolators';
16import type {
17 GestureDirection,
18 Layout,
19 StackCardInterpolationProps,
20 StackCardStyleInterpolator,
21 TransitionSpec,
22} from '../../types';
23import CardAnimationContext from '../../utils/CardAnimationContext';
24import getDistanceForDirection from '../../utils/getDistanceForDirection';
25import getInvertedMultiplier from '../../utils/getInvertedMultiplier';
26import memoize from '../../utils/memoize';
27import {
28 GestureState,
29 PanGestureHandler,
30 PanGestureHandlerGestureEvent,
31} from '../GestureHandler';
32import ModalStatusBarManager from '../ModalStatusBarManager';
33import CardSheet, { CardSheetRef } from './CardSheet';
34
35type 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
70const GESTURE_VELOCITY_IMPACT = 0.3;
71
72const TRUE = 1;
73const FALSE = 0;
74
75/**
76 * The distance of touch start from the edge of the screen where the gesture will be recognized
77 */
78const GESTURE_RESPONSE_DISTANCE_HORIZONTAL = 50;
79const GESTURE_RESPONSE_DISTANCE_VERTICAL = 135;
80
81const useNativeDriver = Platform.OS !== 'web';
82
83const hasOpacityStyle = (style: any) => {
84 if (style) {
85 const flattenedStyle = StyleSheet.flatten(style);
86 return flattenedStyle.opacity != null;
87 }
88
89 return false;
90};
91
92export 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 // We need to trigger the animation when route was closed
135 // Thr route might have been closed by a `POP` action or by a gesture
136 // When route was closed due to a gesture, the animation would've happened already
137 // It's still important to trigger the animation so that `onClose` is called
138 // If `onClose` is not called, cleanup step won't be performed for gestures
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 // Make sure to re-open screen if it wasn't removed
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 // We call onClose with a delay to make sure that the animation has already started
326 // This will make sure that the state update caused by this doesn't affect start of animation
327 this.pendingGestureCallback = setTimeout(() => {
328 onClose();
329
330 // Trigger an update after we dispatch the action to remove the screen
331 // This will make sure that we check if the screen didn't get removed so we can cancel the animation
332 this.forceUpdate();
333 }, 32) as any as number;
334 }
335
336 onGestureEnd?.();
337 break;
338 }
339 }
340 };
341
342 // Memoize this to avoid extra work on re-render
343 private getInterpolatedStyle = memoize(
344 (
345 styleInterpolator: StackCardStyleInterpolator,
346 animation: StackCardInterpolationProps
347 ) => styleInterpolator(animation)
348 );
349
350 // Keep track of the animation context when deps changes.
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 // StatusBar messes with translucent status bar on Android
496 // So we should only enable it on iOS
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 // This is a dummy style that doesn't actually change anything visually.
512 // Animated needs the animated value to be used somewhere, otherwise things don't update properly.
513 // If we disable animations and hide header, it could end up making the value unused.
514 // So we have this dummy style that will always be used regardless of what else changed.
515 opacity: current,
516 }}
517 // Make sure that this view isn't removed. If this view is removed, our style with animated value won't apply
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
574export const getIsModalPresentation = (
575 cardStyleInterpolator: StackCardStyleInterpolator
576) => {
577 return (
578 cardStyleInterpolator === forModalPresentationIOS ||
579 // Handle custom modal presentation interpolators as well
580 cardStyleInterpolator.name === 'forModalPresentationIOS'
581 );
582};
583
584const 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});