import * as React from 'react'; import { View, I18nManager, StyleSheet, ViewProps, StyleProp, ViewStyle, Platform, InteractionManager, } from 'react-native'; import Animated from 'react-native-reanimated'; import { PanGestureHandler, State as GestureState, } from 'react-native-gesture-handler'; import { TransitionSpec, CardStyleInterpolator, Layout, SpringConfig, TimingConfig, } from '../../types'; import memoize from '../../utils/memoize'; import StackGestureContext from '../../utils/StackGestureContext'; import PointerEventsView from './PointerEventsView'; type Props = ViewProps & { index: number; active: boolean; closing?: boolean; transparent?: boolean; next?: Animated.Node; current: Animated.Value; layout: Layout; gestureDirection: 'horizontal' | 'vertical' | 'vertical-inverted'; onOpen: (isFinished: boolean) => void; onClose: (isFinished: boolean) => void; onTransitionStart?: (props: { closing: boolean }) => void; onGestureBegin?: () => void; onGestureCanceled?: () => void; onGestureEnd?: () => void; children: React.ReactNode; overlayEnabled: boolean; shadowEnabled: boolean; gestureEnabled: boolean; gestureResponseDistance?: { vertical?: number; horizontal?: number; }; transitionSpec: { open: TransitionSpec; close: TransitionSpec; }; styleInterpolator: CardStyleInterpolator; containerStyle?: StyleProp; contentStyle?: StyleProp; }; type AnimatedSpringConfig = { damping: Animated.Value; mass: Animated.Value; stiffness: Animated.Value; restSpeedThreshold: Animated.Value; restDisplacementThreshold: Animated.Value; overshootClamping: Animated.Value; }; export type AnimatedTimingConfig = { duration: Animated.Value; easing: Animated.EasingFunction; }; type Binary = 0 | 1; const TRUE = 1; const TRUE_NODE = new Animated.Value(TRUE); const FALSE = 0; const FALSE_NODE = new Animated.Value(FALSE); const NOOP_NODE = FALSE_NODE; const UNSET = -1; const UNSET_NODE = new Animated.Value(UNSET); const MINUS_ONE_NODE = UNSET_NODE; const TOP = -1; const BOTTOM = 1; const DIRECTION_VERTICAL = -1; const DIRECTION_HORIZONTAL = 1; const SWIPE_VELOCITY_IMPACT = 0.3; /** * The distance of touch start from the edge of the screen where the gesture will be recognized */ const GESTURE_RESPONSE_DISTANCE_HORIZONTAL = 50; const GESTURE_RESPONSE_DISTANCE_VERTICAL = 135; const { abs, add, block, call, cond, divide, eq, greaterThan, lessThan, max, min, multiply, neq, onChange, set, spring, sub, timing, startClock, stopClock, clockRunning, Clock, Value, } = Animated; // We need to be prepared for both version of reanimated. With and w/out proc let memoizedSpring = spring; // @ts-ignore if (Animated.proc) { // @ts-ignore const springHelper = Animated.proc( ( finished: Animated.Value, velocity: Animated.Value, position: Animated.Value, time: Animated.Value, prevPosition: Animated.Value, toValue: Animated.Adaptable, damping: Animated.Adaptable, mass: Animated.Adaptable, stiffness: Animated.Adaptable, overshootClamping: Animated.Adaptable, restSpeedThreshold: Animated.Adaptable, restDisplacementThreshold: Animated.Adaptable, clock: Animated.Clock ) => spring( clock, { finished, velocity, position, time, // @ts-ignore prevPosition, }, { toValue, damping, mass, stiffness, overshootClamping, restDisplacementThreshold, restSpeedThreshold, } ) ); // @ts-ignore memoizedSpring = function( clock: Animated.Clock, state: { finished: Animated.Value; velocity: Animated.Value; position: Animated.Value; time: Animated.Value; }, config: { toValue: Animated.Adaptable; damping: Animated.Adaptable; mass: Animated.Adaptable; stiffness: Animated.Adaptable; overshootClamping: Animated.Adaptable; restSpeedThreshold: Animated.Adaptable; restDisplacementThreshold: Animated.Adaptable; } ) { return springHelper( state.finished, state.velocity, state.position, state.time, new Value(0), config.toValue, config.damping, config.mass, config.stiffness, config.overshootClamping, config.restSpeedThreshold, config.restDisplacementThreshold, clock ); }; } function transformSpringConfigToAnimatedValues( config: SpringConfig ): AnimatedSpringConfig { return { damping: new Animated.Value(config.damping), stiffness: new Animated.Value(config.stiffness), mass: new Animated.Value(config.mass), restDisplacementThreshold: new Animated.Value( config.restDisplacementThreshold ), restSpeedThreshold: new Animated.Value(config.restSpeedThreshold), overshootClamping: new Animated.Value(config.overshootClamping), }; } function transformTimingConfigToAnimatedValues( config: TimingConfig ): AnimatedTimingConfig { return { duration: new Animated.Value(config.duration), easing: config.easing, }; } export default class Card extends React.Component { static defaultProps = { overlayEnabled: Platform.OS !== 'ios', shadowEnabled: true, gestureEnabled: true, }; componentDidUpdate(prevProps: Props) { const { layout, gestureDirection, closing } = this.props; const { width, height } = layout; if (width !== prevProps.layout.width) { this.layout.width.setValue(width); } if (height !== prevProps.layout.height) { this.layout.height.setValue(height); } if (gestureDirection !== prevProps.gestureDirection) { this.direction.setValue( gestureDirection === 'vertical' || gestureDirection === 'vertical-inverted' ? DIRECTION_VERTICAL : DIRECTION_HORIZONTAL ); this.verticalGestureDirection.setValue( gestureDirection === 'vertical-inverted' ? TOP : BOTTOM ); } if (closing !== prevProps.closing) { // If the style updates during render, setting the value here doesn't work // We need to defer it a bit so the animation starts properly requestAnimationFrame(() => requestAnimationFrame(() => this.isClosing.setValue(closing ? TRUE : FALSE) ) ); } } componentWillUnmount(): void { this.handleEndInteraction(); // It might sometimes happen than animation will be unmounted // during running. However, we need to invoke listener onClose // manually in this case if (this.isRunningAnimation || this.noAnimationStartedSoFar) { if (this.isVisibleValue) { this.props.onOpen(false); } else { this.props.onClose(false); } } } private isVisible = new Value(TRUE); private isVisibleValue: Binary = TRUE; private nextIsVisible = new Value(UNSET); private verticalGestureDirection = new Value( this.props.gestureDirection === 'vertical-inverted' ? TOP : BOTTOM ); private isClosing = new Value(FALSE); private noAnimationStartedSoFar = true; private isRunningAnimation = false; private clock = new Clock(); private direction = new Value( this.props.gestureDirection === 'vertical' || this.props.gestureDirection === 'vertical-inverted' ? DIRECTION_VERTICAL : DIRECTION_HORIZONTAL ); private layout = { width: new Value(this.props.layout.width), height: new Value(this.props.layout.height), }; openingSpecConfig = this.props.transitionSpec.open.animation === 'timing' ? transformTimingConfigToAnimatedValues( this.props.transitionSpec.open.config ) : transformSpringConfigToAnimatedValues( this.props.transitionSpec.open.config ); closingSpecConfig = this.props.transitionSpec.close.animation === 'timing' ? transformTimingConfigToAnimatedValues( this.props.transitionSpec.close.config ) : transformSpringConfigToAnimatedValues( this.props.transitionSpec.close.config ); private distance = cond( eq(this.direction, DIRECTION_VERTICAL), this.layout.height, this.layout.width ); private gestureUntraversed = new Value(0); private gesture = new Value(0); private offset = new Value(0); private velocityUntraversed = new Value(0); private velocity = new Value(0); private gestureState = new Value(0); private isSwiping = new Value(FALSE); private isSwipeCancelled = new Value(FALSE); private isSwipeGesture = new Value(FALSE); private toValue = new Value(0); private frameTime = new Value(0); private transitionVelocity = new Value(0); private transitionState = { position: this.props.current, time: new Value(0), finished: new Value(FALSE), }; private interactionHandle: number | undefined; private handleStartInteraction = () => { if (this.interactionHandle === undefined) { this.interactionHandle = InteractionManager.createInteractionHandle(); } }; private handleEndInteraction = () => { if (this.interactionHandle !== undefined) { InteractionManager.clearInteractionHandle(this.interactionHandle); this.interactionHandle = undefined; } }; private handleTransitionEnd = () => { this.handleEndInteraction(); this.isRunningAnimation = false; this.interpolatedStyle = this.getInterpolatedStyle( this.props.styleInterpolator, this.props.index, this.props.current, this.props.next, this.props.layout ); }; private runTransition = (isVisible: Binary | Animated.Node) => { const { open: openingSpec, close: closingSpec } = this.props.transitionSpec; return cond(eq(this.props.current, isVisible), NOOP_NODE, [ cond(clockRunning(this.clock), NOOP_NODE, [ // Animation wasn't running before // Set the initial values and start the clock set(this.toValue, isVisible), // The velocity value is ideal for translating the whole screen // But since we have 0-1 scale, we need to adjust the velocity set( this.transitionVelocity, multiply( cond( this.distance, divide(this.velocity, this.distance), FALSE_NODE ), -1 ) ), set(this.frameTime, FALSE_NODE), set(this.transitionState.time, FALSE_NODE), set(this.transitionState.finished, FALSE_NODE), set(this.isVisible, isVisible), startClock(this.clock), call([this.isVisible], ([value]: ReadonlyArray) => { this.handleStartInteraction(); const { onTransitionStart } = this.props; this.noAnimationStartedSoFar = false; this.isRunningAnimation = true; onTransitionStart && onTransitionStart({ closing: !value }); }), ]), cond( eq(isVisible, TRUE_NODE), openingSpec.animation === 'spring' ? memoizedSpring( this.clock, { ...this.transitionState, velocity: this.transitionVelocity }, // @ts-ignore { ...(this.openingSpecConfig as AnimatedSpringConfig), toValue: this.toValue, } ) : timing( this.clock, { ...this.transitionState, frameTime: this.frameTime }, { ...(this.openingSpecConfig as AnimatedTimingConfig), toValue: this.toValue, } ), closingSpec.animation === 'spring' ? memoizedSpring( this.clock, { ...this.transitionState, velocity: this.transitionVelocity }, // @ts-ignore { ...(this.closingSpecConfig as AnimatedSpringConfig), toValue: this.toValue, } ) : timing( this.clock, { ...this.transitionState, frameTime: this.frameTime }, { ...(this.closingSpecConfig as AnimatedTimingConfig), toValue: this.toValue, } ) ), cond(this.transitionState.finished, [ // Reset values set(this.isSwipeGesture, FALSE_NODE), set(this.gesture, FALSE_NODE), set(this.velocity, FALSE_NODE), // When the animation finishes, stop the clock stopClock(this.clock), call([this.isVisible], ([value]: ReadonlyArray) => { const isOpen = Boolean(value); const { onOpen, onClose } = this.props; this.handleTransitionEnd(); if (isOpen) { onOpen(true); } else { onClose(true); } }), ]), ]); }; private extrapolatedPosition = add( this.gesture, multiply(this.velocity, SWIPE_VELOCITY_IMPACT) ); private exec = [ cond( eq(this.direction, DIRECTION_HORIZONTAL), set( this.gesture, multiply( this.gestureUntraversed, I18nManager.isRTL ? MINUS_ONE_NODE : TRUE_NODE ) ) ), set( this.velocity, multiply( this.velocityUntraversed, I18nManager.isRTL ? MINUS_ONE_NODE : TRUE_NODE ) ), onChange( this.isClosing, cond(this.isClosing, set(this.nextIsVisible, FALSE_NODE)) ), onChange( this.nextIsVisible, cond(neq(this.nextIsVisible, UNSET_NODE), [ // Stop any running animations cond(clockRunning(this.clock), [ call([], this.handleTransitionEnd), stopClock(this.clock), ]), set(this.gesture, FALSE_NODE), // Update the index to trigger the transition set(this.isVisible, this.nextIsVisible), set(this.nextIsVisible, UNSET_NODE), ]) ), onChange( this.isVisible, call([this.isVisible], ([isVisible]) => (this.isVisibleValue = isVisible)) ), ]; private changeVisiblityExec = onChange( this.isVisible, call([this.isVisible], ([isVisible]) => (this.isVisibleValue = isVisible)) ); private execNoGesture = block([ ...this.exec, this.runTransition(this.isVisible), onChange( this.isVisible, call([this.isVisible], ([isVisible]) => (this.isVisibleValue = isVisible)) ), this.changeVisiblityExec, ]); private execWithGesture = block([ ...this.exec, onChange( this.isSwiping, call( [this.isSwiping, this.isSwipeCancelled], ([isSwiping, isSwipeCancelled]: readonly Binary[]) => { const { onGestureBegin, onGestureEnd, onGestureCanceled, } = this.props; if (isSwiping === TRUE) { this.handleStartInteraction(); onGestureBegin && onGestureBegin(); } else { this.handleEndInteraction(); if (isSwipeCancelled === TRUE) { onGestureCanceled && onGestureCanceled(); } else { onGestureEnd && onGestureEnd(); } } } ) ), cond( eq(this.gestureState, GestureState.ACTIVE), [ cond(this.isSwiping, NOOP_NODE, [ // We weren't dragging before, set it to true set(this.isSwipeCancelled, FALSE_NODE), set(this.isSwiping, TRUE_NODE), set(this.isSwipeGesture, TRUE_NODE), // Also update the drag offset to the last position set(this.offset, this.props.current), ]), // Update position with next offset + gesture distance set( this.props.current, min( max( sub( this.offset, cond( this.distance, divide( multiply( this.gestureUntraversed, I18nManager.isRTL ? MINUS_ONE_NODE : TRUE_NODE ), this.distance ), TRUE_NODE ) ), FALSE_NODE ), TRUE_NODE ) ), // Stop animations while we're dragging // and invoke proper listener cond( clockRunning(this.clock), call([this.toValue], ([target]) => { this.isRunningAnimation = false; if (target) { this.props.onOpen(false); } else { this.props.onClose(false); } }) ), stopClock(this.clock), ], [ set( this.isSwipeCancelled, eq(this.gestureState, GestureState.CANCELLED) ), set(this.isSwiping, FALSE_NODE), this.runTransition( cond( greaterThan( abs(this.extrapolatedPosition), divide(this.distance, 2) ), cond( lessThan( cond( eq(this.velocity, FALSE_NODE), this.gesture, this.velocity ), FALSE_NODE ), TRUE_NODE, FALSE_NODE ), this.isVisible ) ), ] ), this.changeVisiblityExec, ]); private handleGestureEventHorizontal = Animated.event([ { nativeEvent: { translationX: this.gestureUntraversed, velocityX: this.velocityUntraversed, state: this.gestureState, }, }, ]); private handleGestureEventVertical = Animated.event([ { nativeEvent: { translationY: this.gesture, velocityY: this.velocity, state: this.gestureState, }, }, ]); // We need to ensure that this style doesn't change unless absolutely needs to // Changing it too often will result in huge frame drops due to detaching and attaching // Changing it during an animations can result in unexpected results private getInterpolatedStyle = memoize( ( styleInterpolator: CardStyleInterpolator, index: number, current: Animated.Node, next: Animated.Node | undefined, layout: Layout ) => styleInterpolator({ index, current: { progress: current }, next: next && { progress: next }, closing: this.isClosing, layouts: { screen: layout, }, }) ); // Keep track of the style in a property to avoid changing the animated node when deps change // The style shouldn't change in the middle of the animation and should refer to what was there at the start of it // Which will be the last value when just before the render which started the animation // We need to make sure to update this when the running animation ends private interpolatedStyle = this.getInterpolatedStyle( this.props.styleInterpolator, this.props.index, this.props.current, this.props.next, this.props.layout ); private gestureActivationCriteria() { const { layout, gestureDirection, gestureResponseDistance } = this.props; // Doesn't make sense for a response distance of 0, so this works fine const distance = gestureDirection === 'vertical' || gestureDirection === 'vertical-inverted' ? (gestureResponseDistance && gestureResponseDistance.vertical) || GESTURE_RESPONSE_DISTANCE_VERTICAL : (gestureResponseDistance && gestureResponseDistance.horizontal) || GESTURE_RESPONSE_DISTANCE_HORIZONTAL; if (gestureDirection === 'vertical') { return { maxDeltaX: 15, minOffsetY: 5, hitSlop: { bottom: -layout.height + distance }, }; } else if (gestureDirection === 'vertical-inverted') { return { maxDeltaX: 15, minOffsetY: -5, hitSlop: { top: -layout.height + distance }, }; } else { const hitSlop = -layout.width + distance; if (I18nManager.isRTL) { return { minOffsetX: -5, maxDeltaY: 20, hitSlop: { left: hitSlop }, }; } else { return { minOffsetX: 5, maxDeltaY: 20, hitSlop: { right: hitSlop }, }; } } } private gestureRef: React.Ref = React.createRef(); render() { const { active, transparent, styleInterpolator, index, current, next, layout, overlayEnabled, shadowEnabled, gestureEnabled, gestureDirection, children, containerStyle: customContainerStyle, contentStyle, ...rest } = this.props; if (!this.isRunningAnimation) { this.interpolatedStyle = this.getInterpolatedStyle( styleInterpolator, index, current, next, layout ); } const { containerStyle, cardStyle, overlayStyle, shadowStyle, } = this.interpolatedStyle; const handleGestureEvent = gestureEnabled ? gestureDirection === 'vertical' || gestureDirection === 'vertical-inverted' ? this.handleGestureEventVertical : this.handleGestureEventHorizontal : undefined; let overrideFlex = null; if (cardStyle) { const style = StyleSheet.flatten(cardStyle) as ViewStyle; if (style.hasOwnProperty('flex')) { overrideFlex = { flex: style.flex }; } } return ( {overlayEnabled && overlayStyle ? ( ) : null} {shadowEnabled && shadowStyle && !transparent ? ( ) : null} {children} ); } } const styles = StyleSheet.create({ container: { flex: 1, }, content: { flex: 1, overflow: 'hidden', }, overlay: { ...StyleSheet.absoluteFillObject, backgroundColor: '#000', }, shadow: { position: 'absolute', backgroundColor: '#fff', shadowRadius: 5, shadowColor: '#000', shadowOpacity: 0.3, }, shadowHorizontal: { top: 0, left: 0, bottom: 0, width: 3, shadowOffset: { width: -1, height: 1 }, }, shadowVertical: { top: 0, left: 0, right: 0, height: 3, shadowOffset: { width: 1, height: -1 }, }, transparent: { backgroundColor: 'transparent', }, opaque: { backgroundColor: '#eee', }, });