import React, { useEffect, useImperativeHandle, useMemo, useState, } from "react"; import { Animated, LayoutChangeEvent, LayoutRectangle, Modal, Platform, StyleProp, ViewStyle, } from "react-native"; import Svg, { Defs, Mask, Rect, rgbaArray } from "react-native-svg"; import { vhDP, vwDP } from "../../helpers/responsive"; import { Align, Position, SpotlightTourCtx } from "../SpotlightTour.context"; import { OverlayView, TipView } from "./TourOverlay.styles"; export interface TourOverlayRef { hideTip(): Promise; } interface TourOverlayProps { color?: string | number | rgbaArray; opacity?: number | string; tour: SpotlightTourCtx; } const AnimatedRect = Animated.createAnimatedComponent(Rect); export const TourOverlay = React.forwardRef( (props, ref) => { const { color = "black", opacity = 0.45, tour } = props; const { current, next, previous, spot, steps, stop, onBackdropTap } = tour; if (!spot || current === undefined) { return null; } const [tourStep, setTourStep] = useState(steps[current]); const [tipStyle, setTipStyle] = useState>(); const [radius] = useState(new Animated.Value(0)); const [center] = useState(new Animated.ValueXY({ x: 0, y: 0 })); const [tipOpacity] = useState(new Animated.Value(0)); const r = (Math.max(spot.width, spot.height) / 2) * 1.15; const cx = spot.x + spot.width / 2; const cy = spot.y + spot.height / 2; const useNativeDriver = useMemo( () => Platform.select({ android: false, default: false, ios: true, }), [Platform.OS] ); const getTipStyles = (tipLayout: LayoutRectangle): StyleProp => { const tipMargin: string = "2%"; const align = tourStep.alignTo ?? Align.SPOT; switch (tourStep.position) { case Position.BOTTOM: return { left: align === Align.SPOT ? Math.round(cx - tipLayout.width / 2) : Math.round((vwDP(100) - tipLayout.width) / 2), marginTop: tipMargin, top: Math.round(cy + r), }; case Position.TOP: const topPost = Math.round(spot.y - 8 - tipLayout.height); return { left: align === Align.SPOT ? Math.round(cx - tipLayout.width / 2) : Math.round((vwDP(100) - tipLayout.width) / 2), marginBottom: tipMargin, top: topPost < 0 ? 90 : topPost < 90 ? topPost + 90 : topPost, }; case Position.LEFT: return { left: Math.round(cx - r - tipLayout.width), marginRight: tipMargin, top: Math.round(cy - tipLayout.height / 2), }; case Position.RIGHT: return { left: Math.round(cx + r), marginLeft: tipMargin, top: Math.round(cy - tipLayout.height / 2), }; } }; const measureTip = (event: LayoutChangeEvent) => { setTipStyle(getTipStyles(event.nativeEvent.layout)); }; useEffect(() => { const moveIn = Animated.parallel([ Animated.spring(center, { damping: 50, mass: 5, stiffness: 300, toValue: { x: cx, y: cy }, useNativeDriver, }), Animated.spring(radius, { damping: 30, mass: 5, stiffness: 300, toValue: r, useNativeDriver, }), Animated.timing(tipOpacity, { delay: 500, duration: 500, toValue: 1, useNativeDriver, }), ]); moveIn.stop(); setTourStep(steps[current]); /** * We need to start the animation asynchronously or the layout callback may * overlap, causing different behaviors in iOS and than Android. * TODO: Refactor the animation flow to better handle the layout callback. */ setTimeout(() => { setTipStyle(undefined); moveIn.start(); }); }, [spot, current]); useImperativeHandle(ref, () => ({ hideTip() { return new Promise((resolve, reject) => { Animated.timing(tipOpacity, { duration: 200, toValue: 0, useNativeDriver, }).start(({ finished }) => (finished ? resolve() : reject())); }); }, })); return ( {tourStep.render({ current, isFirst: current === 0, isLast: current === steps.length - 1, next, previous, stop, })} ); } );