/** * Tour — the walkthrough that introduces a screen one control at a time. * * An empty state explains a screen before there is anything on it; a tour * explains it once there is. It dims everything, cuts a hole around one control * and puts a card beside it, then moves the hole to the next control. What * makes that work is the hole: a caption alone has to describe where to look, * and "the button at the top right" is a sentence people read twice and still * get wrong. * * ```tsx * * * } onPress={openLibrary} /> * * * * } onPress={compose} /> * * * ``` * * A step wraps the control it is about, so the two live together in the tree * and cannot drift apart — a step whose target has been deleted goes with it * rather than pointing at empty space. `order` is what puts the steps in a * sequence, and it is the author's numbering rather than the tree's, because a * walkthrough usually crosses a header, a list and a tab bar in an order the * layout knows nothing about. * * The target is measured in window coordinates each time its step becomes * current, and again when the window changes size — a rect measured in portrait * describes nothing after a rotation, and a spotlight in the wrong place is * worse than none. A target that has scrolled out of view is the one case this * cannot fix by itself: bring it back with `onStepChange`, which fires with the * step about to be shown. * * The hole is one path with an even-odd fill — the screen rectangle and the * cutout in a single `d`, animated on the UI thread — rather than four views * arranged around a gap. Four views cannot have rounded corners between them, * and the corner is most of what makes the hole read as *this control* instead * of as a rectangle that happens to contain it. */ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode, type RefObject, } from 'react'; import { StyleSheet, useWindowDimensions, View, type LayoutChangeEvent, type ViewProps, } from 'react-native'; import Animated, { FadeIn, FadeOut, useAnimatedProps, useReducedMotion, useSharedValue, withSpring, } from 'react-native-reanimated'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import Svg, { Path } from 'react-native-svg'; import { ChevronLeftIcon, XIcon } from '../../icons'; import { useBackHandler } from '../../hooks/use-back-handler'; import { Portal } from '../../primitives/portal'; import { Text } from '../../primitives/text'; import { cn } from '../../utils/cn'; import { Button } from '../button'; import { currentTourCardHeight, nextTourCardMeasurement, type TourCardMeasurement, } from './tour-card-measurement'; const AnimatedPath = Animated.createAnimatedComponent(Path); /** Room left between the cutout and the target inside it. */ const DEFAULT_PADDING = 8; /** Corner radius of a rectangular cutout. */ const DEFAULT_RADIUS = 12; /** Gap between the cutout and the card. */ const CARD_OFFSET = 12; /** Smallest gap allowed between the card and the edge of the safe area. */ const SCREEN_MARGIN = 16; /** Ceiling on the card's width, so it does not run edge to edge on a tablet. */ const MAX_CARD_WIDTH = 420; /** How the spotlight travels from one target to the next. */ const SPRING = { damping: 20, stiffness: 180, mass: 0.6 }; /** The dim laid over everything outside the cutout. */ const DEFAULT_OVERLAY = 'rgba(0, 0, 0, 0.66)'; export type TourShape = 'rect' | 'circle'; export type TourPlacement = 'top' | 'bottom' | 'auto'; /** The words on the card's controls, for a tour that is not in English. */ export interface TourLabels { next?: string; back?: string; done?: string; skip?: string; close?: string; } const DEFAULT_LABELS: Required = { next: 'Next', back: 'Back', done: 'Done', skip: 'Skip', close: 'End tour', }; interface Rect { x: number; y: number; width: number; height: number; } /** * One step as the root sees it: what to draw the hole around, and what to say * about it. The ref rather than a measured rect, because a rect taken at * registration is stale by the time the step comes up. */ interface TourStepEntry { order: number; title?: string; description?: string; shape?: TourShape; padding?: number; radius?: number; placement?: TourPlacement; target: RefObject; } interface TourContextValue { register: (entry: TourStepEntry) => void; unregister: (entry: TourStepEntry) => void; } const TourContext = createContext(null); function useTour(component: string): TourContextValue { const context = useContext(TourContext); if (!context) { throw new Error(`${component} must be used within a `); } return context; } /** * The screen with a rounded rectangle taken out of it, as one path. * * Two subpaths and `fillRule="evenodd"`: the outer one covers the screen, the * inner one falls inside it, and even-odd makes the overlap a hole regardless * of which way either is wound. That last part is why the inner rectangle is * written in the natural direction rather than reversed — the winding is not * load-bearing, and a reversed path is the kind of thing that gets tidied up * by someone who cannot see why it was backwards. */ function cutoutPath( screenWidth: number, screenHeight: number, x: number, y: number, width: number, height: number, radius: number ): string { 'worklet'; const r = Math.max(0, Math.min(radius, width / 2, height / 2)); const right = x + width; const bottom = y + height; return ( `M0 0H${screenWidth}V${screenHeight}H0Z ` + `M${x + r} ${y}` + `H${right - r}A${r} ${r} 0 0 1 ${right} ${y + r}` + `V${bottom - r}A${r} ${r} 0 0 1 ${right - r} ${bottom}` + `H${x + r}A${r} ${r} 0 0 1 ${x} ${bottom - r}` + `V${y + r}A${r} ${r} 0 0 1 ${x + r} ${y}Z` ); } /** * The target's bounds grown into the shape the hole will take. * * A circle is squared around the target's centre rather than drawn inside its * bounds, because the controls that want one — an avatar, a floating action * button — are square already, and squaring off the longer side is what keeps * a hole round instead of letting it collapse to a slot. */ function spotlightFor( rect: Rect, shape: TourShape, padding: number, radius: number ): Rect & { radius: number } { if (shape === 'circle') { const diameter = Math.max(rect.width, rect.height) + padding * 2; return { x: rect.x + rect.width / 2 - diameter / 2, y: rect.y + rect.height / 2 - diameter / 2, width: diameter, height: diameter, radius: diameter / 2, }; } return { x: rect.x - padding, y: rect.y - padding, width: rect.width + padding * 2, height: rect.height + padding * 2, radius, }; } export interface TourProps { children?: ReactNode; /** Whether the walkthrough is running. */ open?: boolean; /** Whether it is running when uncontrolled. */ defaultOpen?: boolean; onOpenChange?: (open: boolean) => void; /** * The current step's `order`, controlled. Note that this is the author's * numbering and not a position in the sequence — the two differ as soon as a * step is conditional. */ step?: number; /** Where an uncontrolled tour starts. Defaults to the lowest `order`. */ defaultStep?: number; /** * Fires with the `order` about to be shown, before it is. This is where a * target inside a scroller is brought back into view: the step is measured * on the next frame, so a `scrollTo` issued here lands first. */ onStepChange?: (step: number) => void; /** The last step was acknowledged. */ onFinish?: () => void; /** The tour was ended early — the skip control, the backdrop, or Android back. */ onSkip?: () => void; /** Room left around every target, in pixels. 8 by default. A step may override it. */ padding?: number; /** Corner radius of a rectangular cutout, in pixels. 12 by default. A step may override it. */ radius?: number; /** Shape of every cutout. A step may override it. */ shape?: TourShape; /** * Which side of the target the card prefers. `auto` puts it below when below * fits and above when it does not, which is the only behaviour that survives * a target near an edge. */ placement?: TourPlacement; /** Ending the tour by pressing the dimmed area, or Android back. Default true. */ dismissible?: boolean; /** Show "2 of 5" above the step's title. Default true. */ showProgress?: boolean; /** Show the skip control. Default true. */ showSkip?: boolean; /** * Leave the spotlit control pressable. * * Off by default: a tour is usually read rather than used, and a control that * reacts under the dim invites people to start doing the thing before they * have been told what it does. Turn it on for the walkthrough that asks you * to try the step — the target keeps its own `onPress`, so advancing the tour * from it is the app's call. */ interactive?: boolean; /** * The dim laid over everything outside the cutout. Black at 66% by default — * dark enough that the hole reads as the only lit thing, light enough that * the screen behind it is still recognisable as the screen you were on. */ overlayColor?: string; /** The words on the card's controls. */ labels?: TourLabels; /** Extra classes for the card. */ cardClassName?: string; } function TourRoot({ children, open, defaultOpen = false, onOpenChange, step, defaultStep, onStepChange, onFinish, onSkip, padding = DEFAULT_PADDING, radius = DEFAULT_RADIUS, shape = 'rect', placement = 'auto', dismissible = true, showProgress = true, showSkip = true, interactive = false, overlayColor = DEFAULT_OVERLAY, labels, cardClassName, }: TourProps) { const [steps, setSteps] = useState([]); const [internalOpen, setInternalOpen] = useState(defaultOpen); const [internalStep, setInternalStep] = useState(defaultStep ?? null); const isOpenControlled = open !== undefined; const isStepControlled = step !== undefined; const resolvedOpen = isOpenControlled ? open : internalOpen; const words = { ...DEFAULT_LABELS, ...labels }; /* * Steps sort themselves by `order` rather than arriving in it, because the * tree decides when each one mounts and a tour that crosses a header, a list * and a tab bar mounts them in whatever order those render. */ const register = useCallback((entry: TourStepEntry) => { setSteps((current) => [...current.filter((other) => other.order !== entry.order), entry].sort( (a, b) => a.order - b.order ) ); }, []); const unregister = useCallback((entry: TourStepEntry) => { setSteps((current) => current.filter((other) => other !== entry)); }, []); const context = useMemo(() => ({ register, unregister }), [register, unregister]); const activeOrder = isStepControlled ? step : (internalStep ?? steps[0]?.order ?? null); const index = steps.findIndex((entry) => entry.order === activeOrder); const active = index >= 0 ? steps[index] : undefined; const isFirst = index <= 0; const isLast = index === steps.length - 1; const setOpen = useCallback( (next: boolean) => { if (!isOpenControlled) setInternalOpen(next); onOpenChange?.(next); }, [isOpenControlled, onOpenChange] ); const goTo = useCallback( (order: number) => { onStepChange?.(order); if (!isStepControlled) setInternalStep(order); }, [isStepControlled, onStepChange] ); // Reopening starts the tour over rather than resuming where it was ended. // Somebody who dismissed a walkthrough and asked for it again wants it from // the top; resuming a half-read tour is a state nobody asked to be in. useEffect(() => { if (resolvedOpen && !isStepControlled) setInternalStep(defaultStep ?? null); // eslint-disable-next-line react-hooks/exhaustive-deps }, [resolvedOpen]); const finish = useCallback(() => { setOpen(false); onFinish?.(); }, [setOpen, onFinish]); const skip = useCallback(() => { setOpen(false); onSkip?.(); }, [setOpen, onSkip]); const next = useCallback(() => { const following = steps[index + 1]; if (following) goTo(following.order); else finish(); }, [steps, index, goTo, finish]); const back = useCallback(() => { const previous = steps[index - 1]; if (previous) goTo(previous.order); }, [steps, index, goTo]); useBackHandler(resolvedOpen && dismissible, skip); return ( {children} {resolvedOpen && steps.length > 0 ? ( ) : null} ); } interface TourOverlayProps { active: TourStepEntry | undefined; index: number; total: number; isFirst: boolean; isLast: boolean; padding: number; radius: number; shape: TourShape; placement: TourPlacement; dismissible: boolean; showProgress: boolean; showSkip: boolean; interactive: boolean; overlayColor: string; words: Required; cardClassName?: string; onNext: () => void; onBack: () => void; onSkip: () => void; } function TourOverlay({ active, index, total, isFirst, isLast, padding, radius, shape, placement, dismissible, showProgress, showSkip, interactive, overlayColor, words, cardClassName, onNext, onBack, onSkip, }: TourOverlayProps) { const { width: screenWidth, height: screenHeight } = useWindowDimensions(); const insets = useSafeAreaInsets(); const reducedMotion = useReducedMotion(); const [spot, setSpot] = useState<(Rect & { radius: number }) | null>(null); const [cardMeasurement, setCardMeasurement] = useState( null ); const cardHeight = currentTourCardHeight(active, cardMeasurement); const stepPadding = active?.padding ?? padding; const stepRadius = active?.radius ?? radius; const stepShape = active?.shape ?? shape; /* * Measured when the step becomes current and again whenever the window * changes size. The second half is the part that is easy to leave out and * impossible to miss once it is wrong: a rect taken in portrait describes * nothing after a rotation, and the hole ends up over the wrong half of a * screen the target is no longer on. */ useEffect(() => { const target = active?.target.current; if (!target) { setSpot(null); return; } let cancelled = false; // A frame late on purpose: a step whose target was just scrolled back into // view is measured where it lands, not where it was leaving. const frame = requestAnimationFrame(() => { target.measureInWindow((x, y, width, height) => { if (cancelled || (width === 0 && height === 0)) return; setSpot( spotlightFor({ x, y, width, height }, stepShape, stepPadding, stepRadius) ); }); }); return () => { cancelled = true; cancelAnimationFrame(frame); }; }, [active, stepShape, stepPadding, stepRadius, screenWidth, screenHeight]); /* * The hole's geometry lives on the UI thread so travelling between two * targets is one spring rather than a state update per frame. `settled` * distinguishes the first target — which appears where it belongs — from * every later one, which slides there. */ const x = useSharedValue(0); const y = useSharedValue(0); const width = useSharedValue(0); const height = useSharedValue(0); const cornerRadius = useSharedValue(0); const settled = useSharedValue(false); useEffect(() => { // A step with nothing to point at collapses the hole rather than leaving // the last one open: the previous target is no longer what is being talked // about, and a hole over it says it is. if (!spot) { width.value = 0; height.value = 0; settled.value = false; return; } const animate = settled.value && !reducedMotion; const to = (value: typeof x, next: number) => { value.value = animate ? withSpring(next, SPRING) : next; }; to(x, spot.x); to(y, spot.y); to(width, spot.width); to(height, spot.height); to(cornerRadius, spot.radius); settled.value = true; }, [spot, reducedMotion, x, y, width, height, cornerRadius, settled]); const pathProps = useAnimatedProps(() => ({ d: cutoutPath( screenWidth, screenHeight, x.value, y.value, width.value, height.value, cornerRadius.value ), })); /* * A step with no measurable target — a welcome card, or one whose control has * gone — gets no hole and a card in the middle of the screen. Dimming the * whole screen and saying nothing about where to look is honest; cutting a * hole at the origin is not. */ const card = cardFrame({ spot, cardHeight, placement: active?.placement ?? placement, screenWidth, screenHeight, insets, }); const onCardLayout = (event: LayoutChangeEvent) => { const measured = event.nativeEvent.layout.height; setCardMeasurement((current) => nextTourCardMeasurement(active, measured, current) ); }; return ( {/* The dim is one path with a hole in it and takes no touches, so what handles them is the layer under it. That layer is a full-screen Pressable normally and a ring of four around the cutout when the target is meant to stay usable — the hole is the gap between them, which is the only way to leave a rectangle of the screen pressable. */} {/* * Two views, and the split is not cosmetic. The entering animation * drives opacity, and so does the gate below that hides the card for the * frame it is being measured in — put on one view they fight, and * Reanimated says so: a layout animation may overwrite a property the * style also sets, and which of them wins is not something to rely on. * The outer view owns the animation and the placement; the inner one * owns the measurement and the gate. */} {dismissible ? ( ) : null} {showProgress && total > 1 && index >= 0 ? ( {`${index + 1} of ${total}`} ) : null} {active?.title ? ( {active.title} ) : null} {active?.description ? ( {active.description} ) : null} {showSkip && !isLast ? ( ) : null} {!isFirst ? ( ) : null} ); } /** * Where the card goes, given the hole and the card's own height. * * Below the target when below fits, above it when it does not, and centred on * the screen when there is no target at all. The card is as wide as the safe * area allows up to a ceiling, because a card narrower than that on a phone * only means a shorter line length and one more thing to get wrong. */ function cardFrame({ spot, cardHeight, placement, screenWidth, screenHeight, insets, }: { spot: Rect | null; cardHeight: number | null; placement: TourPlacement; screenWidth: number; screenHeight: number; insets: { top: number; bottom: number; left: number; right: number }; }): { left: number; top: number; width: number } { const minX = insets.left + SCREEN_MARGIN; const maxX = screenWidth - insets.right - SCREEN_MARGIN; const width = Math.min(maxX - minX, MAX_CARD_WIDTH); const left = minX + (maxX - minX - width) / 2; const minY = insets.top + SCREEN_MARGIN; const maxY = screenHeight - insets.bottom - SCREEN_MARGIN; const height = cardHeight ?? 0; if (!spot) { return { left, top: Math.max(minY, (screenHeight - height) / 2), width }; } const below = spot.y + spot.height + CARD_OFFSET; const above = spot.y - CARD_OFFSET - height; const fitsBelow = below + height <= maxY; const fitsAbove = above >= minY; const goBelow = placement === 'bottom' ? fitsBelow || !fitsAbove : placement === 'top' ? !fitsAbove : fitsBelow; // Neither side fits — a target taller than the room around it. Clamping keeps // the card on screen and lets it overlap the dim rather than the other way // round, which is the lesser of the two failures. const top = goBelow ? Math.min(below, maxY - height) : Math.max(above, minY); return { left, top: Math.max(minY, top), width }; } /** * The layer that takes the touches the dim does not. * * One Pressable over everything, or four around the cutout when the target has * to stay usable. Four rather than one with a hole, because a view cannot have * a hole — the gap between them is the hole, and it is the only construction * that leaves a rectangle of the screen reachable. */ function TourBackdrop({ interactive, dismissible, spot, screenWidth, screenHeight, onDismiss, }: { interactive: boolean; dismissible: boolean; spot: Rect | null; screenWidth: number; screenHeight: number; onDismiss: () => void; }) { const blocking = { onStartShouldSetResponder: () => true }; const press = dismissible ? { onStartShouldSetResponder: () => true, onResponderRelease: onDismiss } : blocking; if (!interactive || !spot) { return ; } const bottom = spot.y + spot.height; const right = spot.x + spot.width; return ( <> ); } export interface TourStepProps extends Omit { /** * Where this step falls in the walkthrough. The author's numbering rather * than the tree's, and unique within a tour — two steps sharing an order * means one of them replaces the other. */ order: number; /** The step's heading. */ title?: string; /** The sentence under it. */ description?: string; /** Shape of this step's cutout, overriding the tour's. */ shape?: TourShape; /** Room around this target, overriding the tour's. */ padding?: number; /** Corner radius of this cutout, overriding the tour's. */ radius?: number; /** Which side of this target the card prefers, overriding the tour's. */ placement?: TourPlacement; className?: string; /** The control this step is about. */ children?: ReactNode; } /** * Wraps the control a step is about, and is what gets measured. * * The child is wrapped in a view rather than handed a ref, because the ref has * to survive whatever the child is — a button, a card, a tab bar — and only a * wrapper we own is guaranteed to be measurable. That wrapper is a plain view * with no sizing of its own, so it takes the width its parent gives it: put * layout classes on the step rather than on the child, the way you would on any * other view in that position. * * It renders its child and nothing else while the tour is closed, and stays * mounted either way — a step is a description of a control that is already on * the screen, not something that appears with the walkthrough. */ function TourStep({ order, title, description, shape, padding, radius, placement, className, children, ...props }: TourStepProps) { const { register, unregister } = useTour('Tour.Step'); const target = useRef(null); const entry = useMemo( () => ({ order, title, description, shape, padding, radius, placement, target, }), [order, title, description, shape, padding, radius, placement] ); useEffect(() => { register(entry); return () => unregister(entry); }, [entry, register, unregister]); return ( {children} ); } TourStep.displayName = 'Tour.Step'; TourRoot.displayName = 'Tour'; export const Tour = Object.assign(TourRoot, { Step: TourStep, });