UNPKG

@react-navigation/stack

Version:

Stack navigator component for iOS and Android with animated transitions and gestures

433 lines (430 loc) 14.4 kB
"use strict"; import Color from 'color'; import * as React from 'react'; import { Animated, InteractionManager, Platform, StyleSheet, View } from 'react-native'; import useLatestCallback from 'use-latest-callback'; import { CardAnimationContext } from "../../utils/CardAnimationContext.js"; import { gestureActivationCriteria } from "../../utils/gestureActivationCriteria.js"; import { getDistanceForDirection } from "../../utils/getDistanceForDirection.js"; import { getInvertedMultiplier } from "../../utils/getInvertedMultiplier.js"; import { getShadowStyle } from "../../utils/getShadowStyle.js"; import { GestureState, PanGestureHandler } from '../GestureHandler'; import { CardContent } from "./CardContent.js"; import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; const GESTURE_VELOCITY_IMPACT = 0.3; const TRUE = 1; const FALSE = 0; const useNativeDriver = Platform.OS !== 'web'; const hasOpacityStyle = style => { if (style) { const flattenedStyle = StyleSheet.flatten(style); return 'opacity' in flattenedStyle && flattenedStyle.opacity != null; } return false; }; const getAnimateToValue = ({ closing: isClosing, layout: currentLayout, gestureDirection: currentGestureDirection, direction: currentDirection, preloaded: isPreloaded }) => { if (!isClosing && !isPreloaded) { return 0; } return getDistanceForDirection(currentLayout, currentGestureDirection, currentDirection === 'rtl'); }; const defaultOverlay = ({ style }) => style ? /*#__PURE__*/_jsx(Animated.View, { pointerEvents: "none", style: [styles.overlay, style] }) : null; function Card({ shadowEnabled = false, gestureEnabled = true, gestureVelocityImpact = GESTURE_VELOCITY_IMPACT, overlay = defaultOverlay, animated, interpolationIndex, opening, closing, next, current, gesture, layout, insets, direction, pageOverflowEnabled, gestureDirection, onOpen, onClose, onTransition, onGestureBegin, onGestureCanceled, onGestureEnd, children, overlayEnabled, gestureResponseDistance, transitionSpec, preloaded, styleInterpolator, containerStyle: customContainerStyle, contentStyle }) { const [, forceUpdate] = React.useReducer(x => x + 1, 0); const didInitiallyAnimate = React.useRef(false); const lastToValueRef = React.useRef(undefined); const interactionHandleRef = React.useRef(undefined); const animationHandleRef = React.useRef(undefined); const pendingGestureCallbackRef = React.useRef(undefined); const [isClosing] = React.useState(() => new Animated.Value(FALSE)); const [inverted] = React.useState(() => new Animated.Value(getInvertedMultiplier(gestureDirection, direction === 'rtl'))); const [layoutAnim] = React.useState(() => ({ width: new Animated.Value(layout.width), height: new Animated.Value(layout.height) })); const [isSwiping] = React.useState(() => new Animated.Value(FALSE)); const onStartInteraction = useLatestCallback(() => { if (interactionHandleRef.current === undefined) { interactionHandleRef.current = InteractionManager.createInteractionHandle(); } }); const onEndInteraction = useLatestCallback(() => { if (interactionHandleRef.current !== undefined) { InteractionManager.clearInteractionHandle(interactionHandleRef.current); interactionHandleRef.current = undefined; } }); const animate = useLatestCallback(({ closing: isClosingParam, velocity }) => { const toValue = getAnimateToValue({ closing: isClosingParam, layout, gestureDirection, direction, preloaded }); lastToValueRef.current = toValue; isClosing.setValue(isClosingParam ? TRUE : FALSE); const spec = isClosingParam ? transitionSpec.close : transitionSpec.open; const animation = spec.animation === 'spring' ? Animated.spring : Animated.timing; clearTimeout(pendingGestureCallbackRef.current); if (animationHandleRef.current !== undefined) { cancelAnimationFrame(animationHandleRef.current); } onTransition?.({ closing: isClosingParam, gesture: velocity !== undefined }); const onFinish = () => { if (isClosingParam) { onClose(); } else { onOpen(); } animationHandleRef.current = requestAnimationFrame(() => { if (didInitiallyAnimate.current) { // Make sure to re-open screen if it wasn't removed forceUpdate(); } }); }; if (animated) { onStartInteraction(); animation(gesture, { ...spec.config, velocity, toValue, useNativeDriver, isInteraction: false }).start(({ finished }) => { onEndInteraction(); clearTimeout(pendingGestureCallbackRef.current); if (finished) { onFinish(); } }); } else { onFinish(); } }); const onGestureStateChange = useLatestCallback(({ nativeEvent }) => { switch (nativeEvent.state) { case GestureState.ACTIVE: isSwiping.setValue(TRUE); onStartInteraction(); onGestureBegin?.(); break; case GestureState.CANCELLED: case GestureState.FAILED: { isSwiping.setValue(FALSE); onEndInteraction(); const velocity = gestureDirection === 'vertical' || gestureDirection === 'vertical-inverted' ? nativeEvent.velocityY : nativeEvent.velocityX; animate({ closing, velocity }); onGestureCanceled?.(); break; } case GestureState.END: { isSwiping.setValue(FALSE); let distance; let translation; let velocity; if (gestureDirection === 'vertical' || gestureDirection === 'vertical-inverted') { distance = layout.height; translation = nativeEvent.translationY; velocity = nativeEvent.velocityY; } else { distance = layout.width; translation = nativeEvent.translationX; velocity = nativeEvent.velocityX; } const shouldClose = (translation + velocity * gestureVelocityImpact) * getInvertedMultiplier(gestureDirection, direction === 'rtl') > distance / 2 ? velocity !== 0 || translation !== 0 : closing; animate({ closing: shouldClose, velocity }); if (shouldClose) { // We call onClose with a delay to make sure that the animation has already started // This will make sure that the state update caused by this doesn't affect start of animation pendingGestureCallbackRef.current = setTimeout(() => { onClose(); // Trigger an update after we dispatch the action to remove the screen // This will make sure that we check if the screen didn't get removed so we can cancel the animation forceUpdate(); }, 32); } onGestureEnd?.(); break; } } }); React.useLayoutEffect(() => { layoutAnim.width.setValue(layout.width); layoutAnim.height.setValue(layout.height); inverted.setValue(getInvertedMultiplier(gestureDirection, direction === 'rtl')); }, [gestureDirection, direction, inverted, layoutAnim.width, layoutAnim.height, layout.width, layout.height]); const previousPropsRef = React.useRef(null); React.useEffect(() => { return () => { onEndInteraction(); if (animationHandleRef.current) { cancelAnimationFrame(animationHandleRef.current); } clearTimeout(pendingGestureCallbackRef.current); }; // We only want to clean up the animation on unmount // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const timeoutRef = React.useRef(null); React.useEffect(() => { if (preloaded) { return; } if (!didInitiallyAnimate.current) { // Animate the card in on initial mount // Wrap in setTimeout to ensure animation starts after // rending of the screen is done. This is especially important // in the new architecture // cf., https://github.com/react-navigation/react-navigation/issues/12401 if (timeoutRef.current) { clearTimeout(timeoutRef.current); } timeoutRef.current = setTimeout(() => { didInitiallyAnimate.current = true; animate({ closing }); }, 0); } else { const previousOpening = previousPropsRef.current?.opening; const previousToValue = previousPropsRef.current ? getAnimateToValue(previousPropsRef.current) : null; const toValue = getAnimateToValue({ closing, layout, gestureDirection, direction, preloaded }); if (previousToValue !== toValue || lastToValueRef.current !== toValue) { // We need to trigger the animation when route was closed // The route might have been closed by a `POP` action or by a gesture // When route was closed due to a gesture, the animation would've happened already // It's still important to trigger the animation so that `onClose` is called // If `onClose` is not called, cleanup step won't be performed for gestures animate({ closing }); } else if (typeof previousOpening === 'boolean' && opening && !previousOpening) { // This can happen when screen somewhere below in the stack comes into focus via rearranging // Also reset the animated value to make sure that the animation starts from the beginning gesture.setValue(getDistanceForDirection(layout, gestureDirection, direction === 'rtl')); animate({ closing }); } } previousPropsRef.current = { opening, closing, layout, gestureDirection, direction, preloaded }; }, [animate, closing, direction, gesture, gestureDirection, layout, opening, preloaded]); const interpolationProps = React.useMemo(() => ({ index: interpolationIndex, current: { progress: current }, next: next && { progress: next }, closing: isClosing, swiping: isSwiping, inverted, layouts: { screen: layout }, insets: { top: insets.top, right: insets.right, bottom: insets.bottom, left: insets.left } }), [interpolationIndex, current, next, isClosing, isSwiping, inverted, layout, insets.top, insets.right, insets.bottom, insets.left]); const { containerStyle, cardStyle, overlayStyle, shadowStyle } = React.useMemo(() => styleInterpolator(interpolationProps), [styleInterpolator, interpolationProps]); const onGestureEvent = React.useMemo(() => gestureEnabled ? Animated.event([{ nativeEvent: gestureDirection === 'vertical' || gestureDirection === 'vertical-inverted' ? { translationY: gesture } : { translationX: gesture } }], { useNativeDriver }) : undefined, [gesture, gestureDirection, gestureEnabled]); const { backgroundColor } = StyleSheet.flatten(contentStyle || {}); const isTransparent = typeof backgroundColor === 'string' ? Color(backgroundColor).alpha() === 0 : false; return /*#__PURE__*/_jsxs(CardAnimationContext.Provider, { value: interpolationProps, children: [Platform.OS !== 'web' ? /*#__PURE__*/_jsx(Animated.View, { style: { // This is a dummy style that doesn't actually change anything visually. // Animated needs the animated value to be used somewhere, otherwise things don't update properly. // If we disable animations and hide header, it could end up making the value unused. // So we have this dummy style that will always be used regardless of what else changed. opacity: current } // Make sure that this view isn't removed. If this view is removed, our style with animated value won't apply , collapsable: false }) : null, overlayEnabled ? /*#__PURE__*/_jsx(View, { pointerEvents: "box-none", style: StyleSheet.absoluteFill, children: overlay({ style: overlayStyle }) }) : null, /*#__PURE__*/_jsx(Animated.View, { pointerEvents: "box-none", style: [styles.container, containerStyle, customContainerStyle], children: /*#__PURE__*/_jsx(PanGestureHandler, { enabled: layout.width !== 0 && gestureEnabled, onGestureEvent: onGestureEvent, onHandlerStateChange: onGestureStateChange, ...gestureActivationCriteria({ layout, direction, gestureDirection, gestureResponseDistance }), children: /*#__PURE__*/_jsxs(Animated.View, { pointerEvents: "box-none", needsOffscreenAlphaCompositing: hasOpacityStyle(cardStyle), style: [styles.container, cardStyle], children: [shadowEnabled && shadowStyle && !isTransparent ? /*#__PURE__*/_jsx(Animated.View, { pointerEvents: "none", style: [styles.shadow, gestureDirection === 'horizontal' ? [styles.shadowHorizontal, styles.shadowStart] : gestureDirection === 'horizontal-inverted' ? [styles.shadowHorizontal, styles.shadowEnd] : gestureDirection === 'vertical' ? [styles.shadowVertical, styles.shadowTop] : [styles.shadowVertical, styles.shadowBottom], { backgroundColor }, shadowStyle] }) : null, /*#__PURE__*/_jsx(CardContent, { enabled: pageOverflowEnabled, layout: layout, style: contentStyle, children: children })] }) }) })] }); } export { Card }; const styles = StyleSheet.create({ container: { flex: 1 }, overlay: { flex: 1, backgroundColor: '#000' }, shadow: { position: 'absolute' }, shadowHorizontal: { top: 0, bottom: 0, width: 3, ...getShadowStyle({ offset: { width: -1, height: 1 }, radius: 5, opacity: 0.3 }) }, shadowStart: { start: 0 }, shadowEnd: { end: 0 }, shadowVertical: { start: 0, end: 0, height: 3, ...getShadowStyle({ offset: { width: 1, height: -1 }, radius: 5, opacity: 0.3 }) }, shadowTop: { top: 0 }, shadowBottom: { bottom: 0 } }); //# sourceMappingURL=Card.js.map