/** * ImageViewer — a picture that opens out of the page to fill the screen. * * Pressing a trigger lifts its image from where it sits, grows it to the whole * picture in the middle of the screen, and blurs the page behind it. From there * it can be pinched and double-tapped to zoom, swiped sideways to the next * image under the same root, and dragged away to put it back. * * ```tsx * * {photos.map((photo) => ( * * ))} * * ``` * * ## The image travels; nothing appears * * The viewer never fades a second copy of the picture in over the first. The * thumbnail is hidden and the same image is drawn in a frame that starts at the * thumbnail's exact rectangle and corner radius and ends at the fitted one. The * picture inside that frame is always drawn at the size that *covers* it, so the * crop a thumbnail shows opens out into the whole image as the frame grows — and * closes back into the crop on the way home. That continuity is the point: the * picture on screen is the one that was pressed, not a picture of it. * * The frame animates its bounds rather than a transform, because a transform * cannot change a crop. It is one view with one child, so the layout pass it * costs per frame is small. * * ## Every trigger under one root is a page * * Triggers register themselves with the root as they mount, in render order or * by an explicit `index`. Opening one opens the gallery at its page, and the * thumbnail hidden underneath follows the page — close on the third image and it * flies back into the third thumbnail. * * ## One gesture surface, decided at the start * * Pinching, panning a zoomed image, turning the page and dragging to dismiss all * begin as a one- or two-finger drag, and which one it is gets decided the * moment it starts: two fingers zoom, one finger on a zoomed image pans it, one * finger moving sideways across a gallery turns the page, and anything else * drags the image away. Deciding once keeps a drag from changing its mind * halfway, which is what makes a viewer feel loose. */ import { createContext, useCallback, useContext, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore, type ReactNode, } from 'react'; import { Image, Pressable, StyleSheet, View, useWindowDimensions, type AccessibilityActionEvent, type ImageSourcePropType, type LayoutChangeEvent, type PressableProps, } from 'react-native'; import { Gesture, GestureDetector } from 'react-native-gesture-handler'; import Animated, { cancelAnimation, runOnJS, runOnUI, useAnimatedProps, useAnimatedStyle, useDerivedValue, useReducedMotion, useSharedValue, withDecay, withDelay, withSpring, withTiming, type SharedValue, } from 'react-native-reanimated'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useCSSVariable } from 'uniwind'; import { XIcon } from '../../icons'; import { useControllableState } from '../../primitives/controllable-state'; import { ModalPortal } from '../../primitives/portal'; import { Scrim } from '../../primitives/scrim'; import { Text, textChildren } from '../../primitives/text'; import { useBackHandler } from '../../hooks/use-back-handler'; import { useThemeMode } from '../../theme/use-theme'; import { cn } from '../../utils/cn'; import { selectionTick } from '../../utils/haptics'; import { clamp, coverSize, dragFade, dragScale, fitWithin, focalTranslation, hitsRect, lerp, lerpRect, panBound, rubberBandClamp, shouldDismiss, snapPage, type Rect, type Size, } from './image-viewer-geometry'; /** Space between two pages of a gallery, so a page turn reads as two pictures. */ const PAGE_GAP = 16; /** * Out of the page. Just short of critically damped: enough give that the * picture seems to arrive rather than stop, not enough to wobble. */ const OPEN_SPRING = { damping: 30, stiffness: 260, mass: 1 } as const; /** * Back into the page. Clamped, because a frame that overshoots its thumbnail * shrinks past it and grows back — a visible bounce at the one moment the image * is supposed to be settling into place. */ const CLOSE_SPRING = { damping: 32, stiffness: 300, mass: 1, overshootClamping: true } as const; /** Zoom settling and page turns. Firm, so a flick lands without drifting. */ const SETTLE_SPRING = { damping: 34, stiffness: 320, mass: 1 } as const; /** * Margin between the open picture and the screen, in points. Enough to show the * blur on all four sides, so the picture reads as held up over the page rather * than as a new screen that happens to be a photograph. */ const DEFAULT_INSET = 16; /** Corners of the open picture, matching the library's large surfaces. */ const DEFAULT_CORNER_RADIUS = 16; /** How strongly the blur comes up behind the picture. */ const BLUR_INTENSITY = 50; const PHASE_OPENING = 0; const PHASE_OPEN = 1; const PHASE_CLOSING = 2; const MODE_NONE = 0; const MODE_PAN = 1; const MODE_PAGE = 2; const MODE_DISMISS = 3; /* -------------------------------------------------------------------------- */ /* Registry */ /* -------------------------------------------------------------------------- */ interface ViewerItem { id: string; order: number; index?: number; source: ImageSourcePropType; fullSource?: ImageSourcePropType; alt?: string; caption?: ReactNode; radius: number; size: Size | null; measure: (done: (rect: Rect | null) => void) => void; } /** * The triggers under one root, in page order. * * An external store rather than state on the root, because a trigger updating * its caption should re-render the open viewer and nothing else — root state * would re-render every trigger, which would register again, which would * re-render the root. */ class ViewerStore { private items = new Map(); private listeners = new Set<() => void>(); private counter = 0; private version = 0; private sorted: ViewerItem[] = []; nextOrder = () => this.counter++; set = (item: ViewerItem) => { this.items.set(item.id, item); this.emit(); }; delete = (id: string) => { if (this.items.delete(id)) this.emit(); }; list = (): ViewerItem[] => this.sorted; subscribe = (listener: () => void) => { this.listeners.add(listener); return () => { this.listeners.delete(listener); }; }; getSnapshot = () => this.version; private emit() { this.version += 1; this.sorted = [...this.items.values()].sort( (a, b) => (a.index ?? a.order) - (b.index ?? b.order) ); this.listeners.forEach((listener) => listener()); } } interface ViewerContextValue { store: ViewerStore; /** The trigger whose picture is out on the screen, and so hidden in the page. */ hiddenId: string | null; openItem: (id: string) => void; } const ViewerContext = createContext(null); function useViewer(part: string): ViewerContextValue { const context = useContext(ViewerContext); if (!context) throw new Error(`${part} must be used inside .`); return context; } /* -------------------------------------------------------------------------- */ /* Root */ /* -------------------------------------------------------------------------- */ export interface ImageViewerProps { children?: ReactNode; /** Controlled open state. */ open?: boolean; /** Initial open state when uncontrolled. */ defaultOpen?: boolean; onOpenChange?: (open: boolean) => void; /** Controlled page — which trigger's image is showing, in page order. */ index?: number; /** Initial page when uncontrolled. */ defaultIndex?: number; onIndexChange?: (index: number) => void; /** * Blur the page behind the picture. Needs the optional `expo-blur`, and dims * instead without it. Reduce Transparency draws an opaque backdrop. */ blur?: boolean; /** How far a pinch can zoom in, as a multiple of the fitted size. */ maxScale?: number; /** Where a double tap zooms to, as a multiple of the fitted size. */ doubleTapScale?: number; /** Tick as a drag to dismiss begins. Needs the optional `expo-haptics`. */ haptics?: boolean; /** Draw the close button. Tapping outside the picture and dragging it away still close it. */ showClose?: boolean; /** Read out for the close button. */ closeLabel?: string; /** * Space kept between the open picture and the edges of the screen, in points. * Measured from the safe area at the top and bottom, and the larger of the * two is used for both, so the picture stays centred. `0` fills the screen. */ inset?: number; /** * Corner radius of the open picture, in points. The corners stay this size on * screen while the picture is zoomed or dragged. `0` squares them. */ cornerRadius?: number; } function ImageViewerRoot({ children, open, defaultOpen = false, onOpenChange, index, defaultIndex = 0, onIndexChange, blur = true, maxScale = 4, doubleTapScale = 2.5, haptics = true, showClose = true, closeLabel = 'Close', inset = DEFAULT_INSET, cornerRadius = DEFAULT_CORNER_RADIUS, }: ImageViewerProps) { const storeRef = useRef(null); storeRef.current ??= new ViewerStore(); const store = storeRef.current; const { value: isOpen, setValue: setOpen } = useControllableState({ value: open, defaultValue: defaultOpen, onChange: onOpenChange, }); const { value: page, setValue: setPage } = useControllableState({ value: index, defaultValue: defaultIndex, onChange: onIndexChange, }); // Mounted from the moment it opens until the picture is back in the page, // which is after `open` has already gone false. const [mounted, setMounted] = useState(isOpen); if (isOpen && !mounted) setMounted(true); const [hiddenId, setHiddenId] = useState(null); const openItem = useCallback( (id: string) => { const at = store.list().findIndex((item) => item.id === id); if (at < 0) return; setPage(at); setOpen(true); }, [setOpen, setPage, store] ); const requestClose = useCallback(() => setOpen(false), [setOpen]); const exited = useCallback(() => { setMounted(false); setHiddenId(null); }, []); useBackHandler(isOpen, requestClose); const context = useMemo( () => ({ store, hiddenId, openItem }), [store, hiddenId, openItem] ); return ( {children} {mounted ? ( ) : null} ); } /* -------------------------------------------------------------------------- */ /* Trigger */ /* -------------------------------------------------------------------------- */ export interface ImageViewerTriggerProps extends Omit { /** * Classes on the pressable. Without `children` the image fills it, so give it * a size — `h-48 w-full`, `aspect-square flex-1`. */ className?: string; /** The picture. Shown in the page and, until `fullSource` loads, in the viewer. */ source: ImageSourcePropType; /** * A larger copy to show once the viewer is open. It loads when the viewer * opens and replaces `source` when it arrives, so a feed can carry thumbnails. */ fullSource?: ImageSourcePropType; /** Described for a screen reader, on the trigger and in the viewer. */ alt?: string; /** Shown under the picture while it is open. A string is set as text. */ caption?: ReactNode; /** Page order within the root. Render order when left out. */ index?: number; /** * The corner radius the picture has in the page, in points, so the flight * starts from the same shape. Match it to the `rounded-*` class you gave the * trigger — a class cannot be read back. */ radius?: number; /** * The image's own width and height, when known. Saves looking it up, and is * the only way the viewer knows the proportions before a remote image loads. */ width?: number; height?: number; /** Nothing opens, and the press is not reported. */ disabled?: boolean; /** * Draw the picture yourself — a `Post.Media`, a card. It should show `source` * cropped to fill, the way the viewer's own flight starts. Left out, the * trigger draws `source` to fill its box. */ children?: ReactNode; } /** The image's proportions from what can be known without loading it. */ function knownSize(source: ImageSourcePropType, width?: number, height?: number): Size | null { if (width && height) return { width, height }; if (typeof source === 'number') { const asset = Image.resolveAssetSource(source); if (asset?.width && asset?.height) return { width: asset.width, height: asset.height }; } return null; } function uriOf(source: ImageSourcePropType): string | undefined { if (source && typeof source === 'object' && !Array.isArray(source)) { return (source as { uri?: string }).uri; } return undefined; } /** * The thing pressed to open the viewer. * * It registers its picture with the root, so the gallery is simply every * trigger under that root. While its picture is out on the screen it is drawn * transparent — not removed, so the page does not reflow under the blur and the * flight home has somewhere to land. */ function ImageViewerTrigger({ className, source, fullSource, alt, caption, index, radius = 0, width, height, disabled = false, children, onPress, onLayout, ...props }: ImageViewerTriggerProps) { const { store, hiddenId, openItem } = useViewer('ImageViewer.Trigger'); const id = useId(); const ref = useRef(null); const orderRef = useRef(null); orderRef.current ??= store.nextOrder(); const [size, setSize] = useState(() => knownSize(source, width, height)); const [box, setBox] = useState(null); const uri = uriOf(source); useEffect(() => { const known = knownSize(source, width, height); if (known) { setSize(known); return; } if (!uri) return; let live = true; Image.getSize( uri, (w, h) => { if (live) setSize({ width: w, height: h }); }, () => {} ); return () => { live = false; }; // `source` is compared through its uri; an inline `{ uri }` is new every render. // eslint-disable-next-line react-hooks/exhaustive-deps }, [uri, width, height]); const measure = useCallback((done: (rect: Rect | null) => void) => { const node = ref.current; if (!node) { done(null); return; } node.measureInWindow((x, y, w, h) => { done(w > 0 && h > 0 ? { x, y, width: w, height: h } : null); }); }, []); useLayoutEffect(() => { store.set({ id, order: orderRef.current ?? 0, index, source, fullSource, alt, caption, radius, // The box's own proportions stand in until the image's are known, which // is right for a thumbnail that shows the picture uncropped and close // enough for one that does not. size: size ?? box, measure, }); }, [store, id, index, source, fullSource, alt, caption, radius, size, box, measure]); useLayoutEffect(() => () => store.delete(id), [store, id]); const handleLayout = (event: LayoutChangeEvent) => { onLayout?.(event); const { width: w, height: h } = event.nativeEvent.layout; if (w > 0 && h > 0) setBox({ width: w, height: h }); }; return ( { onPress?.(event); openItem(id); }} onLayout={handleLayout} className={cn('overflow-hidden', className)} style={[radius ? { borderRadius: radius } : null, { opacity: hiddenId === id ? 0 : 1 }]} {...props} > {children ?? ( { if (size) return; const loaded = event.nativeEvent.source; if (loaded?.width && loaded?.height) { setSize({ width: loaded.width, height: loaded.height }); } }} style={StyleSheet.absoluteFill} /> )} ); } /* -------------------------------------------------------------------------- */ /* Overlay */ /* -------------------------------------------------------------------------- */ interface ViewerOverlayProps { store: ViewerStore; open: boolean; index: number; onIndexChange: (index: number) => void; onRequestClose: () => void; onExited: () => void; onShow: (id: string | null) => void; blur: boolean; maxScale: number; doubleTapScale: number; haptics: boolean; showClose: boolean; closeLabel: string; inset: number; cornerRadius: number; } function ViewerOverlay({ store, open, index, onIndexChange, onRequestClose, onExited, onShow, blur, maxScale, doubleTapScale, haptics, showClose, closeLabel, inset, cornerRadius, }: ViewerOverlayProps) { const version = useSyncExternalStore(store.subscribe, store.getSnapshot); // eslint-disable-next-line react-hooks/exhaustive-deps const items = useMemo(() => store.list(), [store, version]); const count = items.length; const page = count ? clamp(index, 0, count - 1) : 0; const item = items[page]; const { width: W, height: H } = useWindowDimensions(); const insets = useSafeAreaInsets(); const reduced = useReducedMotion(); const { mode } = useThemeMode(); const foreground = useCSSVariable('--color-foreground'); const pageWidth = W + PAGE_GAP; /* * The area the picture is fitted into. The same margin on both sides of each * axis, so the fitted picture is centred on the screen — the zoom and pan * arithmetic is measured from the screen's centre and relies on that. */ const box = useMemo(() => { const vertical = Math.max(insets.top, insets.bottom) + inset; return { x: inset, y: vertical, width: Math.max(W - inset * 2, 1), height: Math.max(H - vertical * 2, 1), }; }, [H, W, inset, insets.bottom, insets.top]); const fit = useMemo( () => fitWithin(item?.size ?? box, box), [item?.size, box] ); // The flight, 0 in the page and 1 on the screen. const t = useSharedValue(0); const phase = useSharedValue(PHASE_OPENING); const origin = useSharedValue(null); const radius = useSharedValue(0); // Zoom of the current page, measured from the screen's centre. const scale = useSharedValue(1); const tx = useSharedValue(0); const ty = useSharedValue(0); const startScale = useSharedValue(1); const startX = useSharedValue(0); const startY = useSharedValue(0); const focalOffsetX = useSharedValue(0); const focalOffsetY = useSharedValue(0); const lastFocalX = useSharedValue(0); const lastFocalY = useSharedValue(0); const pinching = useSharedValue(false); // Dragging the picture away. const dragX = useSharedValue(0); const dragY = useSharedValue(0); const shrink = useSharedValue(1); const fade = useSharedValue(1); // The gallery. const pagerX = useSharedValue(-page * pageWidth); const pagerStart = useSharedValue(0); const activePage = useSharedValue(page); const gestureMode = useSharedValue(MODE_NONE); const chrome = useSharedValue(0); const chromeOn = useSharedValue(true); const backdrop = useDerivedValue(() => clamp(t.value, 0, 1) * fade.value); const itemRef = useRef(item); itemRef.current = item; /** * Where the current page's thumbnail is now. Measured again on every page * change and before every close, because the flight home has to land where * the thumbnail actually is. A thumbnail that has left the screen gives no * rectangle, and the picture fades instead of flying somewhere invisible. */ const measureCurrent = useCallback( (then?: () => void) => { const current = itemRef.current; if (!current) { origin.value = null; then?.(); return; } current.measure((rect) => { const onScreen = rect !== null && rect.y + rect.height > 0 && rect.y < H && rect.x + rect.width > 0 && rect.x < W; origin.value = onScreen ? rect : null; radius.value = current.radius; then?.(); }); }, [H, W, origin, radius] ); const startClose = useCallback( (velocityX: number, velocityY: number) => { 'worklet'; phase.value = PHASE_CLOSING; gestureMode.value = MODE_NONE; chrome.value = withTiming(0, { duration: 120 }); const done = (finished?: boolean) => { 'worklet'; if (finished && phase.value === PHASE_CLOSING) runOnJS(onExited)(); }; if (reduced) { t.value = withTiming(0, { duration: 180 }, done); return; } t.value = withSpring(0, CLOSE_SPRING, done); dragX.value = withSpring(0, { ...CLOSE_SPRING, velocity: velocityX }); dragY.value = withSpring(0, { ...CLOSE_SPRING, velocity: velocityY }); shrink.value = withSpring(1, CLOSE_SPRING); scale.value = withSpring(1, CLOSE_SPRING); tx.value = withSpring(0, CLOSE_SPRING); ty.value = withSpring(0, CLOSE_SPRING); }, [chrome, dragX, dragY, gestureMode, onExited, phase, reduced, scale, shrink, t, tx, ty] ); const settleOpen = useCallback(() => { phase.value = PHASE_OPENING; const done = (finished?: boolean) => { 'worklet'; if (finished) phase.value = PHASE_OPEN; }; t.value = reduced ? withTiming(1, { duration: 200 }, done) : withSpring(1, OPEN_SPRING, done); fade.value = withTiming(1, { duration: 150 }); chrome.value = withDelay(120, withTiming(chromeOn.value ? 1 : 0, { duration: 200 })); }, [chrome, chromeOn, fade, phase, reduced, t]); // Opening and closing follow `open`, whoever changed it. const hasOpened = useRef(false); useEffect(() => { if (!itemRef.current) { onRequestClose(); onExited(); return; } if (open) { if (hasOpened.current) { settleOpen(); return; } hasOpened.current = true; activePage.value = page; pagerX.value = -page * pageWidth; measureCurrent(() => { onShow(itemRef.current?.id ?? null); settleOpen(); }); return; } // A gesture that closed the viewer has already started the flight home. if (phase.value === PHASE_CLOSING) return; measureCurrent(() => runOnUI(startClose)(0, 0)); // eslint-disable-next-line react-hooks/exhaustive-deps }, [open]); // A page change from anywhere: the pager, the accessibility actions, or the owner. const firstPage = useRef(true); useEffect(() => { if (firstPage.current) { firstPage.current = false; return; } if (!itemRef.current) return; if (activePage.value !== page) { activePage.value = page; scale.value = 1; tx.value = 0; ty.value = 0; pagerX.value = reduced ? -page * pageWidth : withSpring(-page * pageWidth, SETTLE_SPRING); } measureCurrent(); onShow(itemRef.current.id); // eslint-disable-next-line react-hooks/exhaustive-deps }, [page]); // A rotation re-fits everything, and a zoom that no longer means anything goes. const firstSize = useRef(true); useEffect(() => { if (firstSize.current) { firstSize.current = false; return; } cancelAnimation(pagerX); pagerX.value = -activePage.value * pageWidth; scale.value = 1; tx.value = 0; ty.value = 0; // eslint-disable-next-line react-hooks/exhaustive-deps }, [W, H]); const settleZoom = useCallback(() => { 'worklet'; const target = clamp(scale.value, 1, maxScale); if (target <= 1.01) { scale.value = withSpring(1, SETTLE_SPRING); tx.value = withSpring(0, SETTLE_SPRING); ty.value = withSpring(0, SETTLE_SPRING); chrome.value = withTiming(chromeOn.value ? 1 : 0, { duration: 180 }); return; } const nextX = target === scale.value ? tx.value : focalTranslation(lastFocalX.value, focalOffsetX.value, startScale.value, target); const nextY = target === scale.value ? ty.value : focalTranslation(lastFocalY.value, focalOffsetY.value, startScale.value, target); const boundX = panBound(fit.width, target, W); const boundY = panBound(fit.height, target, H); scale.value = withSpring(target, SETTLE_SPRING); tx.value = withSpring(clamp(nextX, -boundX, boundX), SETTLE_SPRING); ty.value = withSpring(clamp(nextY, -boundY, boundY), SETTLE_SPRING); }, [ H, W, chrome, chromeOn, fit.height, fit.width, focalOffsetX, focalOffsetY, lastFocalX, lastFocalY, maxScale, scale, startScale, tx, ty, ]); const gesture = useMemo(() => { const pinch = Gesture.Pinch() .onStart((event) => { 'worklet'; if (phase.value !== PHASE_OPEN) return; pinching.value = true; cancelAnimation(scale); cancelAnimation(tx); cancelAnimation(ty); startScale.value = scale.value; lastFocalX.value = event.focalX - W / 2; lastFocalY.value = event.focalY - H / 2; focalOffsetX.value = lastFocalX.value - tx.value; focalOffsetY.value = lastFocalY.value - ty.value; chrome.value = withTiming(0, { duration: 120 }); }) .onUpdate((event) => { 'worklet'; if (!pinching.value) return; const next = rubberBandClamp(startScale.value * event.scale, 1, maxScale, maxScale); lastFocalX.value = event.focalX - W / 2; lastFocalY.value = event.focalY - H / 2; scale.value = next; tx.value = focalTranslation(lastFocalX.value, focalOffsetX.value, startScale.value, next); ty.value = focalTranslation(lastFocalY.value, focalOffsetY.value, startScale.value, next); }) .onEnd(() => { 'worklet'; if (!pinching.value) return; pinching.value = false; settleZoom(); }); const pan = Gesture.Pan() .maxPointers(1) .minDistance(8) .onStart((event) => { 'worklet'; if (phase.value !== PHASE_OPEN || pinching.value) { gestureMode.value = MODE_NONE; return; } if (scale.value > 1.01) { gestureMode.value = MODE_PAN; cancelAnimation(tx); cancelAnimation(ty); startX.value = tx.value; startY.value = ty.value; return; } if (count > 1 && Math.abs(event.translationX) > Math.abs(event.translationY)) { gestureMode.value = MODE_PAGE; cancelAnimation(pagerX); pagerStart.value = pagerX.value; return; } gestureMode.value = MODE_DISMISS; chrome.value = withTiming(0, { duration: 120 }); if (haptics) runOnJS(selectionTick)(); }) .onUpdate((event) => { 'worklet'; if (gestureMode.value === MODE_PAN) { const boundX = panBound(fit.width, scale.value, W); const boundY = panBound(fit.height, scale.value, H); tx.value = rubberBandClamp(startX.value + event.translationX, -boundX, boundX, W); ty.value = rubberBandClamp(startY.value + event.translationY, -boundY, boundY, H); } else if (gestureMode.value === MODE_PAGE) { pagerX.value = rubberBandClamp( pagerStart.value + event.translationX, -(count - 1) * pageWidth, 0, W ); } else if (gestureMode.value === MODE_DISMISS) { dragX.value = event.translationX; dragY.value = event.translationY; shrink.value = dragScale(event.translationY, H); fade.value = dragFade(event.translationY, H); } }) .onEnd((event, success) => { 'worklet'; const current = gestureMode.value; gestureMode.value = MODE_NONE; if (current === MODE_PAN) { const boundX = panBound(fit.width, scale.value, W); const boundY = panBound(fit.height, scale.value, H); if (boundX === 0 || Math.abs(tx.value) > boundX) { tx.value = withSpring(clamp(tx.value, -boundX, boundX), SETTLE_SPRING); } else { tx.value = withDecay({ velocity: event.velocityX, clamp: [-boundX, boundX] }); } if (boundY === 0 || Math.abs(ty.value) > boundY) { ty.value = withSpring(clamp(ty.value, -boundY, boundY), SETTLE_SPRING); } else { ty.value = withDecay({ velocity: event.velocityY, clamp: [-boundY, boundY] }); } } else if (current === MODE_PAGE) { const target = snapPage(pagerX.value, event.velocityX, activePage.value, count, pageWidth); pagerX.value = withSpring(-target * pageWidth, { ...SETTLE_SPRING, velocity: event.velocityX, }); if (target !== activePage.value) { activePage.value = target; runOnJS(onIndexChange)(target); } } else if (current === MODE_DISMISS) { if (success && shouldDismiss(event.translationY, event.velocityY)) { startClose(event.velocityX, event.velocityY); runOnJS(onRequestClose)(); return; } dragX.value = withSpring(0, SETTLE_SPRING); dragY.value = withSpring(0, SETTLE_SPRING); shrink.value = withSpring(1, SETTLE_SPRING); fade.value = withTiming(1, { duration: 180 }); chrome.value = withTiming(chromeOn.value ? 1 : 0, { duration: 180 }); } }); const doubleTap = Gesture.Tap() .numberOfTaps(2) .maxDelay(250) .maxDistance(24) .onEnd((event, success) => { 'worklet'; if (!success || phase.value !== PHASE_OPEN) return; if (scale.value > 1.01) { scale.value = withSpring(1, SETTLE_SPRING); tx.value = withSpring(0, SETTLE_SPRING); ty.value = withSpring(0, SETTLE_SPRING); chrome.value = withTiming(chromeOn.value ? 1 : 0, { duration: 180 }); return; } const target = Math.min(doubleTapScale, maxScale); const focalX = event.x - W / 2; const focalY = event.y - H / 2; const boundX = panBound(fit.width, target, W); const boundY = panBound(fit.height, target, H); scale.value = withSpring(target, SETTLE_SPRING); tx.value = withSpring( clamp(focalTranslation(focalX, focalX, 1, target), -boundX, boundX), SETTLE_SPRING ); ty.value = withSpring( clamp(focalTranslation(focalY, focalY, 1, target), -boundY, boundY), SETTLE_SPRING ); chrome.value = withTiming(0, { duration: 120 }); }); const singleTap = Gesture.Tap() .maxDistance(10) .onEnd((event, success) => { 'worklet'; if (!success || phase.value !== PHASE_OPEN) return; const onImage = hitsRect(event.x, event.y, fit, scale.value, tx.value, ty.value); if (!onImage) { startClose(0, 0); runOnJS(onRequestClose)(); return; } chromeOn.value = !chromeOn.value; chrome.value = withTiming(chromeOn.value ? 1 : 0, { duration: 180 }); }); return Gesture.Race( Gesture.Simultaneous(pinch, pan), Gesture.Exclusive(doubleTap, singleTap) ); }, [ H, W, activePage, chrome, chromeOn, count, doubleTapScale, dragX, dragY, fade, fit, focalOffsetX, focalOffsetY, gestureMode, haptics, lastFocalX, lastFocalY, maxScale, onIndexChange, onRequestClose, pageWidth, pagerStart, pagerX, phase, pinching, scale, settleZoom, shrink, startClose, startScale, startX, startY, tx, ty, ]); const pagerStyle = useAnimatedStyle(() => ({ transform: [{ translateX: pagerX.value }] })); const chromeStyle = useAnimatedStyle(() => ({ opacity: chrome.value * clamp(t.value, 0, 1) * fade.value, })); const chromeProps = useAnimatedProps(() => ({ pointerEvents: (chrome.value > 0.5 ? 'box-none' : 'none') as 'box-none' | 'none', })); const onAccessibilityAction = (event: AccessibilityActionEvent) => { const name = event.nativeEvent.actionName; if (name === 'increment' && page < count - 1) onIndexChange(page + 1); else if (name === 'decrement' && page > 0) onIndexChange(page - 1); else if (name === 'escape' || name === 'magicTap') onRequestClose(); }; if (!item) return null; const visiblePages = items .map((entry, at) => ({ entry, at })) .filter(({ at }) => Math.abs(at - page) <= 1); const iconColour = typeof foreground === 'string' ? foreground : '#0a0a0a'; const caption = item.caption; return ( 1 ? 'adjustable' : 'image'} accessibilityLabel={item.alt} accessibilityValue={count > 1 ? { text: `${page + 1} of ${count}` } : undefined} accessibilityActions={[ ...(count > 1 ? [{ name: 'increment' }, { name: 'decrement' }] : []), { name: 'escape' }, { name: 'magicTap' }, ]} onAccessibilityAction={onAccessibilityAction} > {visiblePages.map(({ entry, at }) => ( ))} {/* Close at the leading edge, where the platform puts the way out of a full-screen view — and clear of the trailing corner, where an app's own floating controls tend to live. */} {showClose ? ( ) : ( )} {count > 1 ? ( {`${page + 1} of ${count}`} ) : null} {caption !== undefined && caption !== null ? ( {textChildren(caption, (text) => ( {text} ))} ) : null} ); } interface ViewerPageProps { item: ViewerItem; at: number; left: number; viewport: Size; box: Rect; cornerRadius: number; reduced: boolean; activePage: SharedValue; t: SharedValue; origin: SharedValue; radius: SharedValue; scale: SharedValue; tx: SharedValue; ty: SharedValue; dragX: SharedValue; dragY: SharedValue; shrink: SharedValue; } /** * One picture in the pager. * * Only the current page flies, zooms and drags; its neighbours sit fitted and * still, waiting to be turned to. That is also why one set of zoom values is * enough for the whole gallery — a page is reset before it stops being current. */ function ViewerPage({ item, at, left, viewport, box, cornerRadius, reduced, activePage, t, origin, radius, scale, tx, ty, dragX, dragY, shrink, }: ViewerPageProps) { const size = item.size ?? box; const fit = fitWithin(size, box); const [fullLoaded, setFullLoaded] = useState(false); const frameStyle = useAnimatedStyle(() => { if (activePage.value !== at) { return { left: fit.x, top: fit.y, width: fit.width, height: fit.height, borderRadius: cornerRadius, opacity: 1, transform: [{ translateX: 0 }, { translateY: 0 }, { scale: 1 }], }; } const progress = t.value; const from = origin.value; const flies = from !== null && !reduced; const rect = flies ? lerpRect(from, fit, progress) : fit; const settled = clamp(progress, 0, 1); const visualScale = scale.value * shrink.value * (flies ? 1 : lerp(0.92, 1, settled)); // The radius is drawn inside the scale, so it is divided back out: a // picture zoomed to four times keeps corners the size they were, rather // than rounding into a pill. const corner = flies ? lerp(radius.value, cornerRadius, progress) : cornerRadius; return { left: rect.x, top: rect.y, width: Math.max(rect.width, 1), height: Math.max(rect.height, 1), borderRadius: Math.max(0, corner) / Math.max(visualScale, 0.01), opacity: flies ? 1 : settled, transform: [ { translateX: tx.value + dragX.value }, { translateY: ty.value + dragY.value }, { scale: visualScale }, ], }; }); const pictureStyle = useAnimatedStyle(() => { const active = activePage.value === at; const from = origin.value; const flies = active && from !== null && !reduced; const rect = flies ? lerpRect(from, fit, t.value) : fit; const cover = coverSize(size, rect); return { left: (rect.width - cover.width) / 2, top: (rect.height - cover.height) / 2, width: cover.width, height: cover.height, }; }); return ( {item.fullSource ? ( setFullLoaded(true)} style={[StyleSheet.absoluteFill, { opacity: fullLoaded ? 1 : 0 }]} /> ) : null} ); } export const ImageViewer = Object.assign(ImageViewerRoot, { Trigger: ImageViewerTrigger, });