panelui-native 0.97.0 → 0.99.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/README.md +1 -0
  2. package/lib/module/components/image-viewer/image-viewer-geometry.js +203 -0
  3. package/lib/module/components/image-viewer/image-viewer-geometry.js.map +1 -0
  4. package/lib/module/components/image-viewer/index.js +1100 -0
  5. package/lib/module/components/image-viewer/index.js.map +1 -0
  6. package/lib/module/components/stack-card/index.js +1204 -0
  7. package/lib/module/components/stack-card/index.js.map +1 -0
  8. package/lib/module/components/stack-card/stack-card-geometry.js +261 -0
  9. package/lib/module/components/stack-card/stack-card-geometry.js.map +1 -0
  10. package/lib/module/index.js +2 -0
  11. package/lib/module/index.js.map +1 -1
  12. package/lib/module/primitives/scrim.js +65 -1
  13. package/lib/module/primitives/scrim.js.map +1 -1
  14. package/lib/typescript/src/components/image-viewer/image-viewer-geometry.d.ts +83 -0
  15. package/lib/typescript/src/components/image-viewer/image-viewer-geometry.d.ts.map +1 -0
  16. package/lib/typescript/src/components/image-viewer/index.d.ts +148 -0
  17. package/lib/typescript/src/components/image-viewer/index.d.ts.map +1 -0
  18. package/lib/typescript/src/components/stack-card/index.d.ts +472 -0
  19. package/lib/typescript/src/components/stack-card/index.d.ts.map +1 -0
  20. package/lib/typescript/src/components/stack-card/stack-card-geometry.d.ts +126 -0
  21. package/lib/typescript/src/components/stack-card/stack-card-geometry.d.ts.map +1 -0
  22. package/lib/typescript/src/index.d.ts +2 -0
  23. package/lib/typescript/src/index.d.ts.map +1 -1
  24. package/lib/typescript/src/primitives/scrim.d.ts +11 -1
  25. package/lib/typescript/src/primitives/scrim.d.ts.map +1 -1
  26. package/package.json +8 -1
  27. package/src/components/image-viewer/image-viewer-geometry.ts +205 -0
  28. package/src/components/image-viewer/index.tsx +1329 -0
  29. package/src/components/stack-card/index.tsx +1423 -0
  30. package/src/components/stack-card/stack-card-geometry.ts +279 -0
  31. package/src/index.ts +19 -0
  32. package/src/primitives/scrim.tsx +96 -1
@@ -0,0 +1,1329 @@
1
+ /**
2
+ * ImageViewer — a picture that opens out of the page to fill the screen.
3
+ *
4
+ * Pressing a trigger lifts its image from where it sits, grows it to the whole
5
+ * picture in the middle of the screen, and blurs the page behind it. From there
6
+ * it can be pinched and double-tapped to zoom, swiped sideways to the next
7
+ * image under the same root, and dragged away to put it back.
8
+ *
9
+ * ```tsx
10
+ * <ImageViewer>
11
+ * {photos.map((photo) => (
12
+ * <ImageViewer.Trigger
13
+ * key={photo.id}
14
+ * source={photo.source}
15
+ * alt={photo.alt}
16
+ * radius={12}
17
+ * className="h-40 flex-1 rounded-xl"
18
+ * />
19
+ * ))}
20
+ * </ImageViewer>
21
+ * ```
22
+ *
23
+ * ## The image travels; nothing appears
24
+ *
25
+ * The viewer never fades a second copy of the picture in over the first. The
26
+ * thumbnail is hidden and the same image is drawn in a frame that starts at the
27
+ * thumbnail's exact rectangle and corner radius and ends at the fitted one. The
28
+ * picture inside that frame is always drawn at the size that *covers* it, so the
29
+ * crop a thumbnail shows opens out into the whole image as the frame grows — and
30
+ * closes back into the crop on the way home. That continuity is the point: the
31
+ * picture on screen is the one that was pressed, not a picture of it.
32
+ *
33
+ * The frame animates its bounds rather than a transform, because a transform
34
+ * cannot change a crop. It is one view with one child, so the layout pass it
35
+ * costs per frame is small.
36
+ *
37
+ * ## Every trigger under one root is a page
38
+ *
39
+ * Triggers register themselves with the root as they mount, in render order or
40
+ * by an explicit `index`. Opening one opens the gallery at its page, and the
41
+ * thumbnail hidden underneath follows the page — close on the third image and it
42
+ * flies back into the third thumbnail.
43
+ *
44
+ * ## One gesture surface, decided at the start
45
+ *
46
+ * Pinching, panning a zoomed image, turning the page and dragging to dismiss all
47
+ * begin as a one- or two-finger drag, and which one it is gets decided the
48
+ * moment it starts: two fingers zoom, one finger on a zoomed image pans it, one
49
+ * finger moving sideways across a gallery turns the page, and anything else
50
+ * drags the image away. Deciding once keeps a drag from changing its mind
51
+ * halfway, which is what makes a viewer feel loose.
52
+ */
53
+ import {
54
+ createContext,
55
+ useCallback,
56
+ useContext,
57
+ useEffect,
58
+ useId,
59
+ useLayoutEffect,
60
+ useMemo,
61
+ useRef,
62
+ useState,
63
+ useSyncExternalStore,
64
+ type ReactNode,
65
+ } from 'react';
66
+ import {
67
+ Image,
68
+ Pressable,
69
+ StyleSheet,
70
+ View,
71
+ useWindowDimensions,
72
+ type AccessibilityActionEvent,
73
+ type ImageSourcePropType,
74
+ type LayoutChangeEvent,
75
+ type PressableProps,
76
+ } from 'react-native';
77
+ import { Gesture, GestureDetector } from 'react-native-gesture-handler';
78
+ import Animated, {
79
+ cancelAnimation,
80
+ runOnJS,
81
+ runOnUI,
82
+ useAnimatedProps,
83
+ useAnimatedStyle,
84
+ useDerivedValue,
85
+ useReducedMotion,
86
+ useSharedValue,
87
+ withDecay,
88
+ withDelay,
89
+ withSpring,
90
+ withTiming,
91
+ type SharedValue,
92
+ } from 'react-native-reanimated';
93
+ import { useSafeAreaInsets } from 'react-native-safe-area-context';
94
+ import { useCSSVariable } from 'uniwind';
95
+ import { XIcon } from '../../icons';
96
+ import { useControllableState } from '../../primitives/controllable-state';
97
+ import { ModalPortal } from '../../primitives/portal';
98
+ import { Scrim } from '../../primitives/scrim';
99
+ import { Text, textChildren } from '../../primitives/text';
100
+ import { useBackHandler } from '../../hooks/use-back-handler';
101
+ import { useThemeMode } from '../../theme/use-theme';
102
+ import { cn } from '../../utils/cn';
103
+ import { selectionTick } from '../../utils/haptics';
104
+ import {
105
+ clamp,
106
+ coverSize,
107
+ dragFade,
108
+ dragScale,
109
+ fitWithin,
110
+ focalTranslation,
111
+ hitsRect,
112
+ lerp,
113
+ lerpRect,
114
+ panBound,
115
+ rubberBandClamp,
116
+ shouldDismiss,
117
+ snapPage,
118
+ type Rect,
119
+ type Size,
120
+ } from './image-viewer-geometry';
121
+
122
+ /** Space between two pages of a gallery, so a page turn reads as two pictures. */
123
+ const PAGE_GAP = 16;
124
+
125
+ /**
126
+ * Out of the page. Just short of critically damped: enough give that the
127
+ * picture seems to arrive rather than stop, not enough to wobble.
128
+ */
129
+ const OPEN_SPRING = { damping: 30, stiffness: 260, mass: 1 } as const;
130
+
131
+ /**
132
+ * Back into the page. Clamped, because a frame that overshoots its thumbnail
133
+ * shrinks past it and grows back — a visible bounce at the one moment the image
134
+ * is supposed to be settling into place.
135
+ */
136
+ const CLOSE_SPRING = { damping: 32, stiffness: 300, mass: 1, overshootClamping: true } as const;
137
+
138
+ /** Zoom settling and page turns. Firm, so a flick lands without drifting. */
139
+ const SETTLE_SPRING = { damping: 34, stiffness: 320, mass: 1 } as const;
140
+
141
+ /**
142
+ * Margin between the open picture and the screen, in points. Enough to show the
143
+ * blur on all four sides, so the picture reads as held up over the page rather
144
+ * than as a new screen that happens to be a photograph.
145
+ */
146
+ const DEFAULT_INSET = 16;
147
+
148
+ /** Corners of the open picture, matching the library's large surfaces. */
149
+ const DEFAULT_CORNER_RADIUS = 16;
150
+
151
+ /** How strongly the blur comes up behind the picture. */
152
+ const BLUR_INTENSITY = 50;
153
+
154
+ const PHASE_OPENING = 0;
155
+ const PHASE_OPEN = 1;
156
+ const PHASE_CLOSING = 2;
157
+
158
+ const MODE_NONE = 0;
159
+ const MODE_PAN = 1;
160
+ const MODE_PAGE = 2;
161
+ const MODE_DISMISS = 3;
162
+
163
+ /* -------------------------------------------------------------------------- */
164
+ /* Registry */
165
+ /* -------------------------------------------------------------------------- */
166
+
167
+ interface ViewerItem {
168
+ id: string;
169
+ order: number;
170
+ index?: number;
171
+ source: ImageSourcePropType;
172
+ fullSource?: ImageSourcePropType;
173
+ alt?: string;
174
+ caption?: ReactNode;
175
+ radius: number;
176
+ size: Size | null;
177
+ measure: (done: (rect: Rect | null) => void) => void;
178
+ }
179
+
180
+ /**
181
+ * The triggers under one root, in page order.
182
+ *
183
+ * An external store rather than state on the root, because a trigger updating
184
+ * its caption should re-render the open viewer and nothing else — root state
185
+ * would re-render every trigger, which would register again, which would
186
+ * re-render the root.
187
+ */
188
+ class ViewerStore {
189
+ private items = new Map<string, ViewerItem>();
190
+ private listeners = new Set<() => void>();
191
+ private counter = 0;
192
+ private version = 0;
193
+ private sorted: ViewerItem[] = [];
194
+
195
+ nextOrder = () => this.counter++;
196
+
197
+ set = (item: ViewerItem) => {
198
+ this.items.set(item.id, item);
199
+ this.emit();
200
+ };
201
+
202
+ delete = (id: string) => {
203
+ if (this.items.delete(id)) this.emit();
204
+ };
205
+
206
+ list = (): ViewerItem[] => this.sorted;
207
+
208
+ subscribe = (listener: () => void) => {
209
+ this.listeners.add(listener);
210
+ return () => {
211
+ this.listeners.delete(listener);
212
+ };
213
+ };
214
+
215
+ getSnapshot = () => this.version;
216
+
217
+ private emit() {
218
+ this.version += 1;
219
+ this.sorted = [...this.items.values()].sort(
220
+ (a, b) => (a.index ?? a.order) - (b.index ?? b.order)
221
+ );
222
+ this.listeners.forEach((listener) => listener());
223
+ }
224
+ }
225
+
226
+ interface ViewerContextValue {
227
+ store: ViewerStore;
228
+ /** The trigger whose picture is out on the screen, and so hidden in the page. */
229
+ hiddenId: string | null;
230
+ openItem: (id: string) => void;
231
+ }
232
+
233
+ const ViewerContext = createContext<ViewerContextValue | null>(null);
234
+
235
+ function useViewer(part: string): ViewerContextValue {
236
+ const context = useContext(ViewerContext);
237
+ if (!context) throw new Error(`${part} must be used inside <ImageViewer>.`);
238
+ return context;
239
+ }
240
+
241
+ /* -------------------------------------------------------------------------- */
242
+ /* Root */
243
+ /* -------------------------------------------------------------------------- */
244
+
245
+ export interface ImageViewerProps {
246
+ children?: ReactNode;
247
+ /** Controlled open state. */
248
+ open?: boolean;
249
+ /** Initial open state when uncontrolled. */
250
+ defaultOpen?: boolean;
251
+ onOpenChange?: (open: boolean) => void;
252
+ /** Controlled page — which trigger's image is showing, in page order. */
253
+ index?: number;
254
+ /** Initial page when uncontrolled. */
255
+ defaultIndex?: number;
256
+ onIndexChange?: (index: number) => void;
257
+ /**
258
+ * Blur the page behind the picture. Needs the optional `expo-blur`, and dims
259
+ * instead without it. Reduce Transparency draws an opaque backdrop.
260
+ */
261
+ blur?: boolean;
262
+ /** How far a pinch can zoom in, as a multiple of the fitted size. */
263
+ maxScale?: number;
264
+ /** Where a double tap zooms to, as a multiple of the fitted size. */
265
+ doubleTapScale?: number;
266
+ /** Tick as a drag to dismiss begins. Needs the optional `expo-haptics`. */
267
+ haptics?: boolean;
268
+ /** Draw the close button. Tapping outside the picture and dragging it away still close it. */
269
+ showClose?: boolean;
270
+ /** Read out for the close button. */
271
+ closeLabel?: string;
272
+ /**
273
+ * Space kept between the open picture and the edges of the screen, in points.
274
+ * Measured from the safe area at the top and bottom, and the larger of the
275
+ * two is used for both, so the picture stays centred. `0` fills the screen.
276
+ */
277
+ inset?: number;
278
+ /**
279
+ * Corner radius of the open picture, in points. The corners stay this size on
280
+ * screen while the picture is zoomed or dragged. `0` squares them.
281
+ */
282
+ cornerRadius?: number;
283
+ }
284
+
285
+ function ImageViewerRoot({
286
+ children,
287
+ open,
288
+ defaultOpen = false,
289
+ onOpenChange,
290
+ index,
291
+ defaultIndex = 0,
292
+ onIndexChange,
293
+ blur = true,
294
+ maxScale = 4,
295
+ doubleTapScale = 2.5,
296
+ haptics = true,
297
+ showClose = true,
298
+ closeLabel = 'Close',
299
+ inset = DEFAULT_INSET,
300
+ cornerRadius = DEFAULT_CORNER_RADIUS,
301
+ }: ImageViewerProps) {
302
+ const storeRef = useRef<ViewerStore | null>(null);
303
+ storeRef.current ??= new ViewerStore();
304
+ const store = storeRef.current;
305
+
306
+ const { value: isOpen, setValue: setOpen } = useControllableState({
307
+ value: open,
308
+ defaultValue: defaultOpen,
309
+ onChange: onOpenChange,
310
+ });
311
+ const { value: page, setValue: setPage } = useControllableState({
312
+ value: index,
313
+ defaultValue: defaultIndex,
314
+ onChange: onIndexChange,
315
+ });
316
+
317
+ // Mounted from the moment it opens until the picture is back in the page,
318
+ // which is after `open` has already gone false.
319
+ const [mounted, setMounted] = useState(isOpen);
320
+ if (isOpen && !mounted) setMounted(true);
321
+ const [hiddenId, setHiddenId] = useState<string | null>(null);
322
+
323
+ const openItem = useCallback(
324
+ (id: string) => {
325
+ const at = store.list().findIndex((item) => item.id === id);
326
+ if (at < 0) return;
327
+ setPage(at);
328
+ setOpen(true);
329
+ },
330
+ [setOpen, setPage, store]
331
+ );
332
+
333
+ const requestClose = useCallback(() => setOpen(false), [setOpen]);
334
+ const exited = useCallback(() => {
335
+ setMounted(false);
336
+ setHiddenId(null);
337
+ }, []);
338
+
339
+ useBackHandler(isOpen, requestClose);
340
+
341
+ const context = useMemo<ViewerContextValue>(
342
+ () => ({ store, hiddenId, openItem }),
343
+ [store, hiddenId, openItem]
344
+ );
345
+
346
+ return (
347
+ <ViewerContext.Provider value={context}>
348
+ {children}
349
+ {mounted ? (
350
+ <ModalPortal>
351
+ <ViewerOverlay
352
+ store={store}
353
+ open={isOpen}
354
+ index={page}
355
+ onIndexChange={setPage}
356
+ onRequestClose={requestClose}
357
+ onExited={exited}
358
+ onShow={setHiddenId}
359
+ blur={blur}
360
+ maxScale={maxScale}
361
+ doubleTapScale={doubleTapScale}
362
+ haptics={haptics}
363
+ showClose={showClose}
364
+ closeLabel={closeLabel}
365
+ inset={inset}
366
+ cornerRadius={cornerRadius}
367
+ />
368
+ </ModalPortal>
369
+ ) : null}
370
+ </ViewerContext.Provider>
371
+ );
372
+ }
373
+
374
+ /* -------------------------------------------------------------------------- */
375
+ /* Trigger */
376
+ /* -------------------------------------------------------------------------- */
377
+
378
+ export interface ImageViewerTriggerProps
379
+ extends Omit<PressableProps, 'children' | 'style' | 'disabled'> {
380
+ /**
381
+ * Classes on the pressable. Without `children` the image fills it, so give it
382
+ * a size — `h-48 w-full`, `aspect-square flex-1`.
383
+ */
384
+ className?: string;
385
+ /** The picture. Shown in the page and, until `fullSource` loads, in the viewer. */
386
+ source: ImageSourcePropType;
387
+ /**
388
+ * A larger copy to show once the viewer is open. It loads when the viewer
389
+ * opens and replaces `source` when it arrives, so a feed can carry thumbnails.
390
+ */
391
+ fullSource?: ImageSourcePropType;
392
+ /** Described for a screen reader, on the trigger and in the viewer. */
393
+ alt?: string;
394
+ /** Shown under the picture while it is open. A string is set as text. */
395
+ caption?: ReactNode;
396
+ /** Page order within the root. Render order when left out. */
397
+ index?: number;
398
+ /**
399
+ * The corner radius the picture has in the page, in points, so the flight
400
+ * starts from the same shape. Match it to the `rounded-*` class you gave the
401
+ * trigger — a class cannot be read back.
402
+ */
403
+ radius?: number;
404
+ /**
405
+ * The image's own width and height, when known. Saves looking it up, and is
406
+ * the only way the viewer knows the proportions before a remote image loads.
407
+ */
408
+ width?: number;
409
+ height?: number;
410
+ /** Nothing opens, and the press is not reported. */
411
+ disabled?: boolean;
412
+ /**
413
+ * Draw the picture yourself — a `Post.Media`, a card. It should show `source`
414
+ * cropped to fill, the way the viewer's own flight starts. Left out, the
415
+ * trigger draws `source` to fill its box.
416
+ */
417
+ children?: ReactNode;
418
+ }
419
+
420
+ /** The image's proportions from what can be known without loading it. */
421
+ function knownSize(source: ImageSourcePropType, width?: number, height?: number): Size | null {
422
+ if (width && height) return { width, height };
423
+ if (typeof source === 'number') {
424
+ const asset = Image.resolveAssetSource(source);
425
+ if (asset?.width && asset?.height) return { width: asset.width, height: asset.height };
426
+ }
427
+ return null;
428
+ }
429
+
430
+ function uriOf(source: ImageSourcePropType): string | undefined {
431
+ if (source && typeof source === 'object' && !Array.isArray(source)) {
432
+ return (source as { uri?: string }).uri;
433
+ }
434
+ return undefined;
435
+ }
436
+
437
+ /**
438
+ * The thing pressed to open the viewer.
439
+ *
440
+ * It registers its picture with the root, so the gallery is simply every
441
+ * trigger under that root. While its picture is out on the screen it is drawn
442
+ * transparent — not removed, so the page does not reflow under the blur and the
443
+ * flight home has somewhere to land.
444
+ */
445
+ function ImageViewerTrigger({
446
+ className,
447
+ source,
448
+ fullSource,
449
+ alt,
450
+ caption,
451
+ index,
452
+ radius = 0,
453
+ width,
454
+ height,
455
+ disabled = false,
456
+ children,
457
+ onPress,
458
+ onLayout,
459
+ ...props
460
+ }: ImageViewerTriggerProps) {
461
+ const { store, hiddenId, openItem } = useViewer('ImageViewer.Trigger');
462
+ const id = useId();
463
+ const ref = useRef<View>(null);
464
+ const orderRef = useRef<number | null>(null);
465
+ orderRef.current ??= store.nextOrder();
466
+
467
+ const [size, setSize] = useState<Size | null>(() => knownSize(source, width, height));
468
+ const [box, setBox] = useState<Size | null>(null);
469
+ const uri = uriOf(source);
470
+
471
+ useEffect(() => {
472
+ const known = knownSize(source, width, height);
473
+ if (known) {
474
+ setSize(known);
475
+ return;
476
+ }
477
+ if (!uri) return;
478
+ let live = true;
479
+ Image.getSize(
480
+ uri,
481
+ (w, h) => {
482
+ if (live) setSize({ width: w, height: h });
483
+ },
484
+ () => {}
485
+ );
486
+ return () => {
487
+ live = false;
488
+ };
489
+ // `source` is compared through its uri; an inline `{ uri }` is new every render.
490
+ // eslint-disable-next-line react-hooks/exhaustive-deps
491
+ }, [uri, width, height]);
492
+
493
+ const measure = useCallback((done: (rect: Rect | null) => void) => {
494
+ const node = ref.current;
495
+ if (!node) {
496
+ done(null);
497
+ return;
498
+ }
499
+ node.measureInWindow((x, y, w, h) => {
500
+ done(w > 0 && h > 0 ? { x, y, width: w, height: h } : null);
501
+ });
502
+ }, []);
503
+
504
+ useLayoutEffect(() => {
505
+ store.set({
506
+ id,
507
+ order: orderRef.current ?? 0,
508
+ index,
509
+ source,
510
+ fullSource,
511
+ alt,
512
+ caption,
513
+ radius,
514
+ // The box's own proportions stand in until the image's are known, which
515
+ // is right for a thumbnail that shows the picture uncropped and close
516
+ // enough for one that does not.
517
+ size: size ?? box,
518
+ measure,
519
+ });
520
+ }, [store, id, index, source, fullSource, alt, caption, radius, size, box, measure]);
521
+
522
+ useLayoutEffect(() => () => store.delete(id), [store, id]);
523
+
524
+ const handleLayout = (event: LayoutChangeEvent) => {
525
+ onLayout?.(event);
526
+ const { width: w, height: h } = event.nativeEvent.layout;
527
+ if (w > 0 && h > 0) setBox({ width: w, height: h });
528
+ };
529
+
530
+ return (
531
+ <Pressable
532
+ ref={ref}
533
+ collapsable={false}
534
+ accessibilityRole="imagebutton"
535
+ accessibilityLabel={alt}
536
+ accessibilityHint="Opens the image"
537
+ accessibilityState={{ disabled }}
538
+ disabled={disabled}
539
+ onPress={(event) => {
540
+ onPress?.(event);
541
+ openItem(id);
542
+ }}
543
+ onLayout={handleLayout}
544
+ className={cn('overflow-hidden', className)}
545
+ style={[radius ? { borderRadius: radius } : null, { opacity: hiddenId === id ? 0 : 1 }]}
546
+ {...props}
547
+ >
548
+ {children ?? (
549
+ <Image
550
+ source={source}
551
+ resizeMode="cover"
552
+ accessible={false}
553
+ onLoad={(event) => {
554
+ if (size) return;
555
+ const loaded = event.nativeEvent.source;
556
+ if (loaded?.width && loaded?.height) {
557
+ setSize({ width: loaded.width, height: loaded.height });
558
+ }
559
+ }}
560
+ style={StyleSheet.absoluteFill}
561
+ />
562
+ )}
563
+ </Pressable>
564
+ );
565
+ }
566
+
567
+ /* -------------------------------------------------------------------------- */
568
+ /* Overlay */
569
+ /* -------------------------------------------------------------------------- */
570
+
571
+ interface ViewerOverlayProps {
572
+ store: ViewerStore;
573
+ open: boolean;
574
+ index: number;
575
+ onIndexChange: (index: number) => void;
576
+ onRequestClose: () => void;
577
+ onExited: () => void;
578
+ onShow: (id: string | null) => void;
579
+ blur: boolean;
580
+ maxScale: number;
581
+ doubleTapScale: number;
582
+ haptics: boolean;
583
+ showClose: boolean;
584
+ closeLabel: string;
585
+ inset: number;
586
+ cornerRadius: number;
587
+ }
588
+
589
+ function ViewerOverlay({
590
+ store,
591
+ open,
592
+ index,
593
+ onIndexChange,
594
+ onRequestClose,
595
+ onExited,
596
+ onShow,
597
+ blur,
598
+ maxScale,
599
+ doubleTapScale,
600
+ haptics,
601
+ showClose,
602
+ closeLabel,
603
+ inset,
604
+ cornerRadius,
605
+ }: ViewerOverlayProps) {
606
+ const version = useSyncExternalStore(store.subscribe, store.getSnapshot);
607
+ // eslint-disable-next-line react-hooks/exhaustive-deps
608
+ const items = useMemo(() => store.list(), [store, version]);
609
+ const count = items.length;
610
+ const page = count ? clamp(index, 0, count - 1) : 0;
611
+ const item = items[page];
612
+
613
+ const { width: W, height: H } = useWindowDimensions();
614
+ const insets = useSafeAreaInsets();
615
+ const reduced = useReducedMotion();
616
+ const { mode } = useThemeMode();
617
+ const foreground = useCSSVariable('--color-foreground');
618
+ const pageWidth = W + PAGE_GAP;
619
+
620
+ /*
621
+ * The area the picture is fitted into. The same margin on both sides of each
622
+ * axis, so the fitted picture is centred on the screen — the zoom and pan
623
+ * arithmetic is measured from the screen's centre and relies on that.
624
+ */
625
+ const box = useMemo<Rect>(() => {
626
+ const vertical = Math.max(insets.top, insets.bottom) + inset;
627
+ return {
628
+ x: inset,
629
+ y: vertical,
630
+ width: Math.max(W - inset * 2, 1),
631
+ height: Math.max(H - vertical * 2, 1),
632
+ };
633
+ }, [H, W, inset, insets.bottom, insets.top]);
634
+
635
+ const fit = useMemo(
636
+ () => fitWithin(item?.size ?? box, box),
637
+ [item?.size, box]
638
+ );
639
+
640
+ // The flight, 0 in the page and 1 on the screen.
641
+ const t = useSharedValue(0);
642
+ const phase = useSharedValue(PHASE_OPENING);
643
+ const origin = useSharedValue<Rect | null>(null);
644
+ const radius = useSharedValue(0);
645
+
646
+ // Zoom of the current page, measured from the screen's centre.
647
+ const scale = useSharedValue(1);
648
+ const tx = useSharedValue(0);
649
+ const ty = useSharedValue(0);
650
+ const startScale = useSharedValue(1);
651
+ const startX = useSharedValue(0);
652
+ const startY = useSharedValue(0);
653
+ const focalOffsetX = useSharedValue(0);
654
+ const focalOffsetY = useSharedValue(0);
655
+ const lastFocalX = useSharedValue(0);
656
+ const lastFocalY = useSharedValue(0);
657
+ const pinching = useSharedValue(false);
658
+
659
+ // Dragging the picture away.
660
+ const dragX = useSharedValue(0);
661
+ const dragY = useSharedValue(0);
662
+ const shrink = useSharedValue(1);
663
+ const fade = useSharedValue(1);
664
+
665
+ // The gallery.
666
+ const pagerX = useSharedValue(-page * pageWidth);
667
+ const pagerStart = useSharedValue(0);
668
+ const activePage = useSharedValue(page);
669
+
670
+ const gestureMode = useSharedValue(MODE_NONE);
671
+ const chrome = useSharedValue(0);
672
+ const chromeOn = useSharedValue(true);
673
+
674
+ const backdrop = useDerivedValue(() => clamp(t.value, 0, 1) * fade.value);
675
+
676
+ const itemRef = useRef(item);
677
+ itemRef.current = item;
678
+
679
+ /**
680
+ * Where the current page's thumbnail is now. Measured again on every page
681
+ * change and before every close, because the flight home has to land where
682
+ * the thumbnail actually is. A thumbnail that has left the screen gives no
683
+ * rectangle, and the picture fades instead of flying somewhere invisible.
684
+ */
685
+ const measureCurrent = useCallback(
686
+ (then?: () => void) => {
687
+ const current = itemRef.current;
688
+ if (!current) {
689
+ origin.value = null;
690
+ then?.();
691
+ return;
692
+ }
693
+ current.measure((rect) => {
694
+ const onScreen =
695
+ rect !== null &&
696
+ rect.y + rect.height > 0 &&
697
+ rect.y < H &&
698
+ rect.x + rect.width > 0 &&
699
+ rect.x < W;
700
+ origin.value = onScreen ? rect : null;
701
+ radius.value = current.radius;
702
+ then?.();
703
+ });
704
+ },
705
+ [H, W, origin, radius]
706
+ );
707
+
708
+ const startClose = useCallback(
709
+ (velocityX: number, velocityY: number) => {
710
+ 'worklet';
711
+ phase.value = PHASE_CLOSING;
712
+ gestureMode.value = MODE_NONE;
713
+ chrome.value = withTiming(0, { duration: 120 });
714
+ const done = (finished?: boolean) => {
715
+ 'worklet';
716
+ if (finished && phase.value === PHASE_CLOSING) runOnJS(onExited)();
717
+ };
718
+ if (reduced) {
719
+ t.value = withTiming(0, { duration: 180 }, done);
720
+ return;
721
+ }
722
+ t.value = withSpring(0, CLOSE_SPRING, done);
723
+ dragX.value = withSpring(0, { ...CLOSE_SPRING, velocity: velocityX });
724
+ dragY.value = withSpring(0, { ...CLOSE_SPRING, velocity: velocityY });
725
+ shrink.value = withSpring(1, CLOSE_SPRING);
726
+ scale.value = withSpring(1, CLOSE_SPRING);
727
+ tx.value = withSpring(0, CLOSE_SPRING);
728
+ ty.value = withSpring(0, CLOSE_SPRING);
729
+ },
730
+ [chrome, dragX, dragY, gestureMode, onExited, phase, reduced, scale, shrink, t, tx, ty]
731
+ );
732
+
733
+ const settleOpen = useCallback(() => {
734
+ phase.value = PHASE_OPENING;
735
+ const done = (finished?: boolean) => {
736
+ 'worklet';
737
+ if (finished) phase.value = PHASE_OPEN;
738
+ };
739
+ t.value = reduced ? withTiming(1, { duration: 200 }, done) : withSpring(1, OPEN_SPRING, done);
740
+ fade.value = withTiming(1, { duration: 150 });
741
+ chrome.value = withDelay(120, withTiming(chromeOn.value ? 1 : 0, { duration: 200 }));
742
+ }, [chrome, chromeOn, fade, phase, reduced, t]);
743
+
744
+ // Opening and closing follow `open`, whoever changed it.
745
+ const hasOpened = useRef(false);
746
+ useEffect(() => {
747
+ if (!itemRef.current) {
748
+ onRequestClose();
749
+ onExited();
750
+ return;
751
+ }
752
+ if (open) {
753
+ if (hasOpened.current) {
754
+ settleOpen();
755
+ return;
756
+ }
757
+ hasOpened.current = true;
758
+ activePage.value = page;
759
+ pagerX.value = -page * pageWidth;
760
+ measureCurrent(() => {
761
+ onShow(itemRef.current?.id ?? null);
762
+ settleOpen();
763
+ });
764
+ return;
765
+ }
766
+ // A gesture that closed the viewer has already started the flight home.
767
+ if (phase.value === PHASE_CLOSING) return;
768
+ measureCurrent(() => runOnUI(startClose)(0, 0));
769
+ // eslint-disable-next-line react-hooks/exhaustive-deps
770
+ }, [open]);
771
+
772
+ // A page change from anywhere: the pager, the accessibility actions, or the owner.
773
+ const firstPage = useRef(true);
774
+ useEffect(() => {
775
+ if (firstPage.current) {
776
+ firstPage.current = false;
777
+ return;
778
+ }
779
+ if (!itemRef.current) return;
780
+ if (activePage.value !== page) {
781
+ activePage.value = page;
782
+ scale.value = 1;
783
+ tx.value = 0;
784
+ ty.value = 0;
785
+ pagerX.value = reduced
786
+ ? -page * pageWidth
787
+ : withSpring(-page * pageWidth, SETTLE_SPRING);
788
+ }
789
+ measureCurrent();
790
+ onShow(itemRef.current.id);
791
+ // eslint-disable-next-line react-hooks/exhaustive-deps
792
+ }, [page]);
793
+
794
+ // A rotation re-fits everything, and a zoom that no longer means anything goes.
795
+ const firstSize = useRef(true);
796
+ useEffect(() => {
797
+ if (firstSize.current) {
798
+ firstSize.current = false;
799
+ return;
800
+ }
801
+ cancelAnimation(pagerX);
802
+ pagerX.value = -activePage.value * pageWidth;
803
+ scale.value = 1;
804
+ tx.value = 0;
805
+ ty.value = 0;
806
+ // eslint-disable-next-line react-hooks/exhaustive-deps
807
+ }, [W, H]);
808
+
809
+ const settleZoom = useCallback(() => {
810
+ 'worklet';
811
+ const target = clamp(scale.value, 1, maxScale);
812
+ if (target <= 1.01) {
813
+ scale.value = withSpring(1, SETTLE_SPRING);
814
+ tx.value = withSpring(0, SETTLE_SPRING);
815
+ ty.value = withSpring(0, SETTLE_SPRING);
816
+ chrome.value = withTiming(chromeOn.value ? 1 : 0, { duration: 180 });
817
+ return;
818
+ }
819
+ const nextX =
820
+ target === scale.value
821
+ ? tx.value
822
+ : focalTranslation(lastFocalX.value, focalOffsetX.value, startScale.value, target);
823
+ const nextY =
824
+ target === scale.value
825
+ ? ty.value
826
+ : focalTranslation(lastFocalY.value, focalOffsetY.value, startScale.value, target);
827
+ const boundX = panBound(fit.width, target, W);
828
+ const boundY = panBound(fit.height, target, H);
829
+ scale.value = withSpring(target, SETTLE_SPRING);
830
+ tx.value = withSpring(clamp(nextX, -boundX, boundX), SETTLE_SPRING);
831
+ ty.value = withSpring(clamp(nextY, -boundY, boundY), SETTLE_SPRING);
832
+ }, [
833
+ H,
834
+ W,
835
+ chrome,
836
+ chromeOn,
837
+ fit.height,
838
+ fit.width,
839
+ focalOffsetX,
840
+ focalOffsetY,
841
+ lastFocalX,
842
+ lastFocalY,
843
+ maxScale,
844
+ scale,
845
+ startScale,
846
+ tx,
847
+ ty,
848
+ ]);
849
+
850
+ const gesture = useMemo(() => {
851
+ const pinch = Gesture.Pinch()
852
+ .onStart((event) => {
853
+ 'worklet';
854
+ if (phase.value !== PHASE_OPEN) return;
855
+ pinching.value = true;
856
+ cancelAnimation(scale);
857
+ cancelAnimation(tx);
858
+ cancelAnimation(ty);
859
+ startScale.value = scale.value;
860
+ lastFocalX.value = event.focalX - W / 2;
861
+ lastFocalY.value = event.focalY - H / 2;
862
+ focalOffsetX.value = lastFocalX.value - tx.value;
863
+ focalOffsetY.value = lastFocalY.value - ty.value;
864
+ chrome.value = withTiming(0, { duration: 120 });
865
+ })
866
+ .onUpdate((event) => {
867
+ 'worklet';
868
+ if (!pinching.value) return;
869
+ const next = rubberBandClamp(startScale.value * event.scale, 1, maxScale, maxScale);
870
+ lastFocalX.value = event.focalX - W / 2;
871
+ lastFocalY.value = event.focalY - H / 2;
872
+ scale.value = next;
873
+ tx.value = focalTranslation(lastFocalX.value, focalOffsetX.value, startScale.value, next);
874
+ ty.value = focalTranslation(lastFocalY.value, focalOffsetY.value, startScale.value, next);
875
+ })
876
+ .onEnd(() => {
877
+ 'worklet';
878
+ if (!pinching.value) return;
879
+ pinching.value = false;
880
+ settleZoom();
881
+ });
882
+
883
+ const pan = Gesture.Pan()
884
+ .maxPointers(1)
885
+ .minDistance(8)
886
+ .onStart((event) => {
887
+ 'worklet';
888
+ if (phase.value !== PHASE_OPEN || pinching.value) {
889
+ gestureMode.value = MODE_NONE;
890
+ return;
891
+ }
892
+ if (scale.value > 1.01) {
893
+ gestureMode.value = MODE_PAN;
894
+ cancelAnimation(tx);
895
+ cancelAnimation(ty);
896
+ startX.value = tx.value;
897
+ startY.value = ty.value;
898
+ return;
899
+ }
900
+ if (count > 1 && Math.abs(event.translationX) > Math.abs(event.translationY)) {
901
+ gestureMode.value = MODE_PAGE;
902
+ cancelAnimation(pagerX);
903
+ pagerStart.value = pagerX.value;
904
+ return;
905
+ }
906
+ gestureMode.value = MODE_DISMISS;
907
+ chrome.value = withTiming(0, { duration: 120 });
908
+ if (haptics) runOnJS(selectionTick)();
909
+ })
910
+ .onUpdate((event) => {
911
+ 'worklet';
912
+ if (gestureMode.value === MODE_PAN) {
913
+ const boundX = panBound(fit.width, scale.value, W);
914
+ const boundY = panBound(fit.height, scale.value, H);
915
+ tx.value = rubberBandClamp(startX.value + event.translationX, -boundX, boundX, W);
916
+ ty.value = rubberBandClamp(startY.value + event.translationY, -boundY, boundY, H);
917
+ } else if (gestureMode.value === MODE_PAGE) {
918
+ pagerX.value = rubberBandClamp(
919
+ pagerStart.value + event.translationX,
920
+ -(count - 1) * pageWidth,
921
+ 0,
922
+ W
923
+ );
924
+ } else if (gestureMode.value === MODE_DISMISS) {
925
+ dragX.value = event.translationX;
926
+ dragY.value = event.translationY;
927
+ shrink.value = dragScale(event.translationY, H);
928
+ fade.value = dragFade(event.translationY, H);
929
+ }
930
+ })
931
+ .onEnd((event, success) => {
932
+ 'worklet';
933
+ const current = gestureMode.value;
934
+ gestureMode.value = MODE_NONE;
935
+ if (current === MODE_PAN) {
936
+ const boundX = panBound(fit.width, scale.value, W);
937
+ const boundY = panBound(fit.height, scale.value, H);
938
+ if (boundX === 0 || Math.abs(tx.value) > boundX) {
939
+ tx.value = withSpring(clamp(tx.value, -boundX, boundX), SETTLE_SPRING);
940
+ } else {
941
+ tx.value = withDecay({ velocity: event.velocityX, clamp: [-boundX, boundX] });
942
+ }
943
+ if (boundY === 0 || Math.abs(ty.value) > boundY) {
944
+ ty.value = withSpring(clamp(ty.value, -boundY, boundY), SETTLE_SPRING);
945
+ } else {
946
+ ty.value = withDecay({ velocity: event.velocityY, clamp: [-boundY, boundY] });
947
+ }
948
+ } else if (current === MODE_PAGE) {
949
+ const target = snapPage(pagerX.value, event.velocityX, activePage.value, count, pageWidth);
950
+ pagerX.value = withSpring(-target * pageWidth, {
951
+ ...SETTLE_SPRING,
952
+ velocity: event.velocityX,
953
+ });
954
+ if (target !== activePage.value) {
955
+ activePage.value = target;
956
+ runOnJS(onIndexChange)(target);
957
+ }
958
+ } else if (current === MODE_DISMISS) {
959
+ if (success && shouldDismiss(event.translationY, event.velocityY)) {
960
+ startClose(event.velocityX, event.velocityY);
961
+ runOnJS(onRequestClose)();
962
+ return;
963
+ }
964
+ dragX.value = withSpring(0, SETTLE_SPRING);
965
+ dragY.value = withSpring(0, SETTLE_SPRING);
966
+ shrink.value = withSpring(1, SETTLE_SPRING);
967
+ fade.value = withTiming(1, { duration: 180 });
968
+ chrome.value = withTiming(chromeOn.value ? 1 : 0, { duration: 180 });
969
+ }
970
+ });
971
+
972
+ const doubleTap = Gesture.Tap()
973
+ .numberOfTaps(2)
974
+ .maxDelay(250)
975
+ .maxDistance(24)
976
+ .onEnd((event, success) => {
977
+ 'worklet';
978
+ if (!success || phase.value !== PHASE_OPEN) return;
979
+ if (scale.value > 1.01) {
980
+ scale.value = withSpring(1, SETTLE_SPRING);
981
+ tx.value = withSpring(0, SETTLE_SPRING);
982
+ ty.value = withSpring(0, SETTLE_SPRING);
983
+ chrome.value = withTiming(chromeOn.value ? 1 : 0, { duration: 180 });
984
+ return;
985
+ }
986
+ const target = Math.min(doubleTapScale, maxScale);
987
+ const focalX = event.x - W / 2;
988
+ const focalY = event.y - H / 2;
989
+ const boundX = panBound(fit.width, target, W);
990
+ const boundY = panBound(fit.height, target, H);
991
+ scale.value = withSpring(target, SETTLE_SPRING);
992
+ tx.value = withSpring(
993
+ clamp(focalTranslation(focalX, focalX, 1, target), -boundX, boundX),
994
+ SETTLE_SPRING
995
+ );
996
+ ty.value = withSpring(
997
+ clamp(focalTranslation(focalY, focalY, 1, target), -boundY, boundY),
998
+ SETTLE_SPRING
999
+ );
1000
+ chrome.value = withTiming(0, { duration: 120 });
1001
+ });
1002
+
1003
+ const singleTap = Gesture.Tap()
1004
+ .maxDistance(10)
1005
+ .onEnd((event, success) => {
1006
+ 'worklet';
1007
+ if (!success || phase.value !== PHASE_OPEN) return;
1008
+ const onImage = hitsRect(event.x, event.y, fit, scale.value, tx.value, ty.value);
1009
+ if (!onImage) {
1010
+ startClose(0, 0);
1011
+ runOnJS(onRequestClose)();
1012
+ return;
1013
+ }
1014
+ chromeOn.value = !chromeOn.value;
1015
+ chrome.value = withTiming(chromeOn.value ? 1 : 0, { duration: 180 });
1016
+ });
1017
+
1018
+ return Gesture.Race(
1019
+ Gesture.Simultaneous(pinch, pan),
1020
+ Gesture.Exclusive(doubleTap, singleTap)
1021
+ );
1022
+ }, [
1023
+ H,
1024
+ W,
1025
+ activePage,
1026
+ chrome,
1027
+ chromeOn,
1028
+ count,
1029
+ doubleTapScale,
1030
+ dragX,
1031
+ dragY,
1032
+ fade,
1033
+ fit,
1034
+ focalOffsetX,
1035
+ focalOffsetY,
1036
+ gestureMode,
1037
+ haptics,
1038
+ lastFocalX,
1039
+ lastFocalY,
1040
+ maxScale,
1041
+ onIndexChange,
1042
+ onRequestClose,
1043
+ pageWidth,
1044
+ pagerStart,
1045
+ pagerX,
1046
+ phase,
1047
+ pinching,
1048
+ scale,
1049
+ settleZoom,
1050
+ shrink,
1051
+ startClose,
1052
+ startScale,
1053
+ startX,
1054
+ startY,
1055
+ tx,
1056
+ ty,
1057
+ ]);
1058
+
1059
+ const pagerStyle = useAnimatedStyle(() => ({ transform: [{ translateX: pagerX.value }] }));
1060
+
1061
+ const chromeStyle = useAnimatedStyle(() => ({
1062
+ opacity: chrome.value * clamp(t.value, 0, 1) * fade.value,
1063
+ }));
1064
+ const chromeProps = useAnimatedProps(() => ({
1065
+ pointerEvents: (chrome.value > 0.5 ? 'box-none' : 'none') as 'box-none' | 'none',
1066
+ }));
1067
+
1068
+ const onAccessibilityAction = (event: AccessibilityActionEvent) => {
1069
+ const name = event.nativeEvent.actionName;
1070
+ if (name === 'increment' && page < count - 1) onIndexChange(page + 1);
1071
+ else if (name === 'decrement' && page > 0) onIndexChange(page - 1);
1072
+ else if (name === 'escape' || name === 'magicTap') onRequestClose();
1073
+ };
1074
+
1075
+ if (!item) return null;
1076
+
1077
+ const visiblePages = items
1078
+ .map((entry, at) => ({ entry, at }))
1079
+ .filter(({ at }) => Math.abs(at - page) <= 1);
1080
+
1081
+ const iconColour = typeof foreground === 'string' ? foreground : '#0a0a0a';
1082
+ const caption = item.caption;
1083
+
1084
+ return (
1085
+ <View
1086
+ style={StyleSheet.absoluteFill}
1087
+ accessibilityViewIsModal
1088
+ onAccessibilityEscape={onRequestClose}
1089
+ >
1090
+ <Scrim
1091
+ blur={blur}
1092
+ progress={backdrop}
1093
+ intensity={BLUR_INTENSITY}
1094
+ tint={mode === 'dark' ? 'dark' : 'light'}
1095
+ dimClassName="bg-black/60"
1096
+ pointerEvents="none"
1097
+ />
1098
+
1099
+ <GestureDetector gesture={gesture}>
1100
+ <View
1101
+ style={StyleSheet.absoluteFill}
1102
+ collapsable={false}
1103
+ accessible
1104
+ accessibilityRole={count > 1 ? 'adjustable' : 'image'}
1105
+ accessibilityLabel={item.alt}
1106
+ accessibilityValue={count > 1 ? { text: `${page + 1} of ${count}` } : undefined}
1107
+ accessibilityActions={[
1108
+ ...(count > 1 ? [{ name: 'increment' }, { name: 'decrement' }] : []),
1109
+ { name: 'escape' },
1110
+ { name: 'magicTap' },
1111
+ ]}
1112
+ onAccessibilityAction={onAccessibilityAction}
1113
+ >
1114
+ <Animated.View pointerEvents="none" style={[StyleSheet.absoluteFill, pagerStyle]}>
1115
+ {visiblePages.map(({ entry, at }) => (
1116
+ <ViewerPage
1117
+ key={entry.id}
1118
+ item={entry}
1119
+ at={at}
1120
+ left={at * pageWidth}
1121
+ viewport={{ width: W, height: H }}
1122
+ box={box}
1123
+ cornerRadius={cornerRadius}
1124
+ reduced={reduced}
1125
+ activePage={activePage}
1126
+ t={t}
1127
+ origin={origin}
1128
+ radius={radius}
1129
+ scale={scale}
1130
+ tx={tx}
1131
+ ty={ty}
1132
+ dragX={dragX}
1133
+ dragY={dragY}
1134
+ shrink={shrink}
1135
+ />
1136
+ ))}
1137
+ </Animated.View>
1138
+ </View>
1139
+ </GestureDetector>
1140
+
1141
+ <Animated.View
1142
+ style={[StyleSheet.absoluteFill, chromeStyle]}
1143
+ animatedProps={chromeProps}
1144
+ >
1145
+ <View
1146
+ pointerEvents="box-none"
1147
+ className="absolute inset-x-4 flex-row items-center justify-between"
1148
+ style={{ top: insets.top + 8 }}
1149
+ >
1150
+ {/* Close at the leading edge, where the platform puts the way out of
1151
+ a full-screen view — and clear of the trailing corner, where an
1152
+ app's own floating controls tend to live. */}
1153
+ {showClose ? (
1154
+ <Pressable
1155
+ accessibilityRole="button"
1156
+ accessibilityLabel={closeLabel}
1157
+ hitSlop={8}
1158
+ onPress={onRequestClose}
1159
+ className="h-10 w-10 items-center justify-center rounded-full bg-background/80"
1160
+ >
1161
+ <XIcon size={18} color={iconColour} />
1162
+ </Pressable>
1163
+ ) : (
1164
+ <View className="h-10 w-10" />
1165
+ )}
1166
+ {count > 1 ? (
1167
+ <View
1168
+ accessibilityElementsHidden
1169
+ importantForAccessibility="no-hide-descendants"
1170
+ className="rounded-full bg-background/80 px-3 py-1.5"
1171
+ >
1172
+ <Text size="sm" weight="medium" className="tabular-nums text-foreground">
1173
+ {`${page + 1} of ${count}`}
1174
+ </Text>
1175
+ </View>
1176
+ ) : null}
1177
+ <View className="h-10 w-10" />
1178
+ </View>
1179
+
1180
+ {caption !== undefined && caption !== null ? (
1181
+ <View
1182
+ pointerEvents="box-none"
1183
+ className="absolute inset-x-4 rounded-2xl bg-background/80 px-4 py-3"
1184
+ style={{ bottom: insets.bottom + 16 }}
1185
+ >
1186
+ {textChildren(caption, (text) => (
1187
+ <Text size="sm" className="text-foreground">
1188
+ {text}
1189
+ </Text>
1190
+ ))}
1191
+ </View>
1192
+ ) : null}
1193
+ </Animated.View>
1194
+ </View>
1195
+ );
1196
+ }
1197
+
1198
+ interface ViewerPageProps {
1199
+ item: ViewerItem;
1200
+ at: number;
1201
+ left: number;
1202
+ viewport: Size;
1203
+ box: Rect;
1204
+ cornerRadius: number;
1205
+ reduced: boolean;
1206
+ activePage: SharedValue<number>;
1207
+ t: SharedValue<number>;
1208
+ origin: SharedValue<Rect | null>;
1209
+ radius: SharedValue<number>;
1210
+ scale: SharedValue<number>;
1211
+ tx: SharedValue<number>;
1212
+ ty: SharedValue<number>;
1213
+ dragX: SharedValue<number>;
1214
+ dragY: SharedValue<number>;
1215
+ shrink: SharedValue<number>;
1216
+ }
1217
+
1218
+ /**
1219
+ * One picture in the pager.
1220
+ *
1221
+ * Only the current page flies, zooms and drags; its neighbours sit fitted and
1222
+ * still, waiting to be turned to. That is also why one set of zoom values is
1223
+ * enough for the whole gallery — a page is reset before it stops being current.
1224
+ */
1225
+ function ViewerPage({
1226
+ item,
1227
+ at,
1228
+ left,
1229
+ viewport,
1230
+ box,
1231
+ cornerRadius,
1232
+ reduced,
1233
+ activePage,
1234
+ t,
1235
+ origin,
1236
+ radius,
1237
+ scale,
1238
+ tx,
1239
+ ty,
1240
+ dragX,
1241
+ dragY,
1242
+ shrink,
1243
+ }: ViewerPageProps) {
1244
+ const size = item.size ?? box;
1245
+ const fit = fitWithin(size, box);
1246
+ const [fullLoaded, setFullLoaded] = useState(false);
1247
+
1248
+ const frameStyle = useAnimatedStyle(() => {
1249
+ if (activePage.value !== at) {
1250
+ return {
1251
+ left: fit.x,
1252
+ top: fit.y,
1253
+ width: fit.width,
1254
+ height: fit.height,
1255
+ borderRadius: cornerRadius,
1256
+ opacity: 1,
1257
+ transform: [{ translateX: 0 }, { translateY: 0 }, { scale: 1 }],
1258
+ };
1259
+ }
1260
+ const progress = t.value;
1261
+ const from = origin.value;
1262
+ const flies = from !== null && !reduced;
1263
+ const rect = flies ? lerpRect(from, fit, progress) : fit;
1264
+ const settled = clamp(progress, 0, 1);
1265
+ const visualScale = scale.value * shrink.value * (flies ? 1 : lerp(0.92, 1, settled));
1266
+ // The radius is drawn inside the scale, so it is divided back out: a
1267
+ // picture zoomed to four times keeps corners the size they were, rather
1268
+ // than rounding into a pill.
1269
+ const corner = flies ? lerp(radius.value, cornerRadius, progress) : cornerRadius;
1270
+ return {
1271
+ left: rect.x,
1272
+ top: rect.y,
1273
+ width: Math.max(rect.width, 1),
1274
+ height: Math.max(rect.height, 1),
1275
+ borderRadius: Math.max(0, corner) / Math.max(visualScale, 0.01),
1276
+ opacity: flies ? 1 : settled,
1277
+ transform: [
1278
+ { translateX: tx.value + dragX.value },
1279
+ { translateY: ty.value + dragY.value },
1280
+ { scale: visualScale },
1281
+ ],
1282
+ };
1283
+ });
1284
+
1285
+ const pictureStyle = useAnimatedStyle(() => {
1286
+ const active = activePage.value === at;
1287
+ const from = origin.value;
1288
+ const flies = active && from !== null && !reduced;
1289
+ const rect = flies ? lerpRect(from, fit, t.value) : fit;
1290
+ const cover = coverSize(size, rect);
1291
+ return {
1292
+ left: (rect.width - cover.width) / 2,
1293
+ top: (rect.height - cover.height) / 2,
1294
+ width: cover.width,
1295
+ height: cover.height,
1296
+ };
1297
+ });
1298
+
1299
+ return (
1300
+ <View
1301
+ pointerEvents="none"
1302
+ style={{ position: 'absolute', left, top: 0, width: viewport.width, height: viewport.height }}
1303
+ >
1304
+ <Animated.View style={[{ position: 'absolute', overflow: 'hidden' }, frameStyle]}>
1305
+ <Animated.View style={[{ position: 'absolute' }, pictureStyle]}>
1306
+ <Image
1307
+ source={item.source}
1308
+ resizeMode="cover"
1309
+ accessible={false}
1310
+ style={StyleSheet.absoluteFill}
1311
+ />
1312
+ {item.fullSource ? (
1313
+ <Image
1314
+ source={item.fullSource}
1315
+ resizeMode="cover"
1316
+ accessible={false}
1317
+ onLoad={() => setFullLoaded(true)}
1318
+ style={[StyleSheet.absoluteFill, { opacity: fullLoaded ? 1 : 0 }]}
1319
+ />
1320
+ ) : null}
1321
+ </Animated.View>
1322
+ </Animated.View>
1323
+ </View>
1324
+ );
1325
+ }
1326
+
1327
+ export const ImageViewer = Object.assign(ImageViewerRoot, {
1328
+ Trigger: ImageViewerTrigger,
1329
+ });