import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState, } from 'react'; import { Animated, Easing, Modal, NativeModules, Platform, StatusBar, View, type LayoutChangeEvent, type LayoutRectangle, type ViewStyle, } from 'react-native'; import { useTour } from '../hooks/useTour'; import type { TourOptions } from '../types'; import { StepNumber } from './default-ui/StepNumber'; import { Tooltip } from './default-ui/Tooltip'; import { SvgMask } from './SvgMask'; import { ViewMask } from './ViewMask'; import { ARROW_SIZE, MARGIN, STEP_NUMBER_DIAMETER, STEP_NUMBER_RADIUS, styles, } from './style'; type Props = TourOptions; const noop = () => {}; const makeDefaultLayout = (): LayoutRectangle => ({ x: 0, y: 0, width: 0, height: 0, }); export interface TourModalHandle { animateMove: (obj: LayoutRectangle) => Promise; } export const TourModal = forwardRef(function TourModal( { easing = Easing.elastic(0.7), animationDuration = 400, tooltipComponent: TooltipComponent = Tooltip, tooltipStyle = {}, stepNumberComponent: StepNumberComponent = StepNumber, overlay = typeof NativeModules.RNSVGSvgViewManager !== 'undefined' ? 'svg' : 'view', animated = typeof NativeModules.RNSVGSvgViewManager !== 'undefined', androidStatusBarVisible = false, backdropColor = 'rgba(0, 0, 0, 0.4)', labels = { finish: 'Finish', next: 'Next', previous: 'Previous', skip: 'Skip', }, svgMaskPath, stopOnOutsideClick = false, arrowColor = '#fff', arrowSize = ARROW_SIZE, margin = MARGIN, }, ref ) { const { stop, currentStep, visible, goToNext, goToPrev, isFirstStep, isLastStep, currentStepNumber, } = useTour(); const [tooltipStyles, setTooltipStyles] = useState({}); const [arrowStyles, setArrowStyles] = useState({}); const [animatedValues] = useState({ top: new Animated.Value(0), stepNumberLeft: new Animated.Value(0), }); const layoutRef = useRef(makeDefaultLayout()); const [layout, setLayout] = useState(undefined); const [maskRect, setMaskRect] = useState(); const [isAnimated, setIsAnimated] = useState(false); const [containerVisible, setContainerVisible] = useState(false); useEffect(() => { if (visible) { setContainerVisible(true); } }, [visible]); useEffect(() => { if (!visible) { reset(); } }, [visible]); const handleLayoutChange = ({ nativeEvent: { layout: newLayout }, }: LayoutChangeEvent) => { layoutRef.current = newLayout; }; const measure = async (): Promise => { return await new Promise((resolve) => { const updateLayout = () => { if (layoutRef.current.width !== 0) { resolve(layoutRef.current); } else { requestAnimationFrame(updateLayout); } }; updateLayout(); }); }; const _animateMove = useCallback( async (rect: LayoutRectangle) => { const newMeasuredLayout = await measure(); if (!androidStatusBarVisible && Platform.OS === 'android') { rect.y -= StatusBar.currentHeight ?? 0; } let stepNumberLeft = rect.x - STEP_NUMBER_RADIUS; if (stepNumberLeft < 0) { stepNumberLeft = rect.x + rect.width - STEP_NUMBER_RADIUS; if (stepNumberLeft > newMeasuredLayout.width - STEP_NUMBER_DIAMETER) { stepNumberLeft = newMeasuredLayout.width - STEP_NUMBER_DIAMETER; } } const center = { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2, }; const relativeToLeft = center.x; const relativeToTop = center.y; const relativeToBottom = Math.abs(center.y - newMeasuredLayout.height); const relativeToRight = Math.abs(center.x - newMeasuredLayout.width); const verticalPosition = relativeToBottom > relativeToTop ? 'bottom' : 'top'; const horizontalPosition = relativeToLeft > relativeToRight ? 'left' : 'right'; const tooltip: ViewStyle = {}; const arrow: ViewStyle = {}; arrow.position = 'absolute'; if (verticalPosition === 'bottom') { tooltip.top = rect.y + rect.height + margin; arrow.borderBottomColor = arrowColor; arrow.borderTopColor = 'transparent'; arrow.borderLeftColor = 'transparent'; arrow.borderRightColor = 'transparent'; arrow.top = tooltip.top - arrowSize * 2; } else { tooltip.bottom = newMeasuredLayout.height - (rect.y - margin); arrow.borderTopColor = arrowColor; arrow.borderLeftColor = 'transparent'; arrow.borderRightColor = 'transparent'; arrow.borderBottomColor = 'transparent'; arrow.bottom = tooltip.bottom - arrowSize * 2; } if (horizontalPosition === 'left') { tooltip.right = Math.max( newMeasuredLayout.width - (rect.x + rect.width), 0 ); tooltip.right = tooltip.right === 0 ? tooltip.right + margin : tooltip.right; tooltip.maxWidth = newMeasuredLayout.width - tooltip.right - margin; arrow.right = tooltip.right + margin; } else { tooltip.left = Math.max(rect.x, 0); tooltip.left = tooltip.left === 0 ? tooltip.left + margin : tooltip.left; tooltip.maxWidth = newMeasuredLayout.width - tooltip.left - margin; arrow.left = tooltip.left + margin; } sanitize(arrow); sanitize(tooltip); sanitize(rect); const animate = [ ['top', rect.y], ['stepNumberLeft', stepNumberLeft], ] as const; if (isAnimated) { Animated.parallel( animate.map(([key, value]) => { return Animated.timing(animatedValues[key], { toValue: value, duration: animationDuration, easing, useNativeDriver: false, }); }) ).start(); } else { animate.forEach(([key, value]) => { animatedValues[key].setValue(value); }); } setTooltipStyles(tooltip); setArrowStyles(arrow); setLayout(newMeasuredLayout); setMaskRect({ width: rect.width, height: rect.height, x: Math.floor(Math.max(rect.x, 0)), y: Math.floor(Math.max(rect.y, 0)), }); }, [ androidStatusBarVisible, animatedValues, animationDuration, arrowColor, easing, isAnimated, arrowSize, margin, ] ); const animateMove = useCallback( async (rect) => { await new Promise((resolve) => { const frame = async () => { await _animateMove(rect); resolve(); }; setContainerVisible(true); requestAnimationFrame(() => { frame(); }); }); }, [_animateMove] ); const reset = () => { setIsAnimated(false); setContainerVisible(false); setLayout(undefined); }; const handleStop = () => { reset(); stop(); }; const handleMaskClick = () => { if (stopOnOutsideClick) { handleStop(); } }; useImperativeHandle(ref, () => { return { animateMove, }; }, [animateMove]); const modalVisible = containerVisible || visible; const contentVisible = layout != null && containerVisible; if (!modalVisible) { return null; } return ( {contentVisible && renderMask()} {contentVisible && renderTooltip()} ); function renderMask() { const MaskComponent = overlay === 'svg' ? SvgMask : ViewMask; const size = maskRect && { x: maskRect.width, y: maskRect.height, }; const position = maskRect; return ( ); } function renderTooltip() { if (!currentStep) { return null; } return ( <> {!!arrowSize && ( )} ); } }); const floorify = (obj: Record) => { Object.keys(obj).forEach((key) => { if (typeof obj[key] === 'number') { obj[key] = Math.floor(obj[key]); } }); }; const removeNan = (obj: Record) => { Object.keys(obj).forEach((key) => { if (typeof obj[key] === 'number' && isNaN(obj[key])) { delete obj[key]; } }); }; const sanitize = (obj: Record) => { floorify(obj); removeNan(obj); };