Library picker: window the grid on the target date instead of walking to it

Dragging the date bar fired a window query every 120ms while one query takes
over a second on a 12S Ultra, so ~20 queries queued behind each other, the
grid froze for the length of the drag and the stale one landed last - the
grid settled on a date the finger had long left.

The window now opens on both sides: a page above the target, so a list parked
mid-library still has content above it and a downward drag does not die on
offset 0, plus the target page and everything older. jumpTo coalesces the
drag into one query and parks the grid on the row the target date sits on;
scrolling back up to the top pages newer photos in and corrects the offset by
hand, because maintainVisibleContentPosition keeps the offset glued to index 0
and walked the grid forward in time as it paged.
This commit is contained in:
2026-09-13 22:14:36 +07:00
parent 1f8b72ec8c
commit 99405514c6
+764
View File
@@ -0,0 +1,764 @@
import React, { useEffect, useRef, useState } from 'react';
import {
View,
Text,
Modal,
TouchableOpacity,
FlatList,
Image,
Animated,
PanResponder,
ActivityIndicator,
useWindowDimensions,
} from 'react-native';
import { X, Check } from 'lucide-react-native';
import * as MediaLibrary from 'expo-media-library/legacy';
import ZoomableImage from './ZoomableImage';
import { PickedPhotoAsset } from '../utils/photoGps';
// In-app library picker. Tapping a thumbnail opens it full screen: pinch and
// double-tap zoom, swipe left/right through the library, then USE or CANCEL.
// It replaces the system picker on Android 13, whose grid hands the photo back
// on the first tap — no preview, so nothing to look at before choosing.
//
// ponytail: newest PAGE photos, no album row / search / multi-select; add the
// album buckets here when a large library makes the first page too shallow.
const PAGE = 60;
const COLS = 3;
// Date scrubber on the right edge of the grid: drag it down to walk back in
// time. Pages are pulled in on demand, so a jump can load a few pages first.
const SCRUB_W = 34;
const THUMB_H = 44;
const TRACK_PAD = 8;
interface LibraryPickerModalProps {
visible: boolean;
onCancel: () => void;
onSelect: (asset: PickedPhotoAsset) => void;
}
export default function LibraryPickerModal({
visible,
onCancel,
onSelect,
}: LibraryPickerModalProps) {
const { width } = useWindowDimensions();
const [assets, setAssets] = useState<MediaLibrary.Asset[]>([]);
const [cursor, setCursor] = useState<string | undefined>(undefined);
const [hasMore, setHasMore] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// null = grid, otherwise the index of the photo shown full screen.
const [preview, setPreview] = useState<number | null>(null);
const [busy, setBusy] = useState(false);
// Creation time of the oldest photo on the device: the far end of the
// scrubber. Fetched once, so the track does not shift while pages load.
const [oldest, setOldest] = useState<number | null>(null);
// The newest photo on the device, and the width of one preview page. A jump
// replaces the list with a window around the target date, so the bar has to
// map its travel onto the whole library rather than onto whatever the grid
// happens to hold, and the preview pages are sized by the strip's own layout,
// never by the window: a mismatch between the two parked the preview between
// two photos.
const [newest, setNewest] = useState<number | null>(null);
const [pageW, setPageW] = useState(width);
// ponytail: the thumb is owned by the drag (time based), never by the grid
// scroll - syncing it to the list fought with the pages a jump pulls in and
// sent the thumb back to the top. Wire onScroll back in if the grid itself
// ever needs to move the thumb.
const [frac, setFrac] = useState(0);
const [trackH, setTrackH] = useState(0);
const [scrubbing, setScrubbing] = useState(false);
// The preview pager lays its three slots out around `anchor`, which only moves
// once a settle animation has finished: rebasing them on the new photo mid
// flight is what would flash the photo the strip just left.
const [anchor, setAnchor] = useState(0);
const gridRef = useRef<FlatList<MediaLibrary.Asset>>(null);
const alive = useRef(true);
// Mirrors of the list state so the pan handlers (created once) always read
// the current page without being rebuilt.
const assetsRef = useRef<MediaLibrary.Asset[]>([]);
const cursorRef = useRef<string | undefined>(undefined);
const hasMoreRef = useRef(false);
// Whether newer photos exist above the loaded window (true only after a jump,
// which starts the list at the target date instead of at the newest photo).
const hasNewerRef = useRef(false);
const newestRef = useRef<number | null>(null);
// Latest date the bar asked for, and whether its query is running. The loop in
// jumpTo reads these, so a drag coalesces into one last query.
const jumpTarget = useRef<number | null>(null);
const jumpBusy = useRef(false);
const loadingRef = useRef(false);
// Live scroll offset: the prepend reads it to tell a real pull past the top
// from the echo of its own scrollToOffset.
const offsetRef = useRef(0);
const trackHRef = useRef(0);
const stripRef = useRef<View>(null);
// Top of the bar in window coordinates, so a drag can be read from pageY.
const stripTop = useRef(0);
const pageWRef = useRef(width);
const txAn = useRef(new Animated.Value(0)).current;
const tyAn = useRef(new Animated.Value(0)).current;
// The preview shrinks as it is dragged down, so the drag reads as "put it back
// in the grid" before it lands there.
const scaleAn = useRef(new Animated.Value(1)).current;
const anchorRef = useRef(0);
const axis = useRef<'x' | 'y' | null>(null);
const settling = useRef(false);
const load = async (after?: string) => {
if (loadingRef.current) return null;
loadingRef.current = true;
setLoading(true);
try {
const page = await MediaLibrary.getAssetsAsync({
first: PAGE,
after,
mediaType: 'photo',
// @ts-ignore legacy SortByValue tuple shape
sortBy: [['creationTime', false]],
});
if (!alive.current) return null;
assetsRef.current = after ? [...assetsRef.current, ...page.assets] : page.assets;
cursorRef.current = page.endCursor;
hasMoreRef.current = page.hasNextPage;
setAssets(assetsRef.current);
setCursor(page.endCursor);
setHasMore(page.hasNextPage);
setError(page.assets.length === 0 && !after ? 'NO PHOTOS ON THIS DEVICE' : null);
return page;
} catch (e) {
console.warn('Library page load failed:', e);
if (alive.current) setError('CANNOT READ PHOTOS');
return null;
} finally {
loadingRef.current = false;
if (alive.current) setLoading(false);
}
};
// One query lands on any date, however deep the library is. Walking page by
// page to a date a couple of years back meant a dozen round trips and the
// thumbnails only caught up seconds after the drag; the grid is a window that
// starts at the target instead, and paging down from there stays on the same
// cursor.
// The window carries the page above the target too: a list whose first row is
// the target has nothing above it, so a downward drag dies on the spot (the
// scroll view is already at offset 0, no scroll event, nothing to page in).
// Returns the row the target date sits on, to park on.
const loadWindow = async (before: number) => {
// Wait for whatever is already loading instead of skipping: the last drag
// event usually lands while the previous jump (or a scroll-triggered page)
// is still resolving, and bailing out left the grid a drag behind the thumb.
for (let i = 0; i < 50 && loadingRef.current; i++) {
await new Promise((r) => setTimeout(r, 20));
}
loadingRef.current = true;
setLoading(true);
try {
const [page, above0] = await Promise.all([
MediaLibrary.getAssetsAsync({
first: PAGE * 2,
mediaType: 'photo',
createdBefore: before,
// @ts-ignore legacy SortByValue tuple shape
sortBy: [['creationTime', false]],
}),
MediaLibrary.getAssetsAsync({
first: PAGE,
mediaType: 'photo',
createdAfter: before,
// @ts-ignore legacy SortByValue tuple shape
sortBy: [['creationTime', true]],
}),
]);
if (!alive.current) return null;
// createdAfter sorts oldest first, so the page is the block sitting right
// above the target; reversed it is newest first like the rest of the list.
const above = [...above0.assets].reverse();
assetsRef.current = [...above, ...page.assets];
cursorRef.current = page.endCursor;
hasMoreRef.current = page.hasNextPage;
hasNewerRef.current = above.length === PAGE &&
above[0].creationTime < (newestRef.current ?? 0);
setAssets(assetsRef.current);
setCursor(page.endCursor);
setHasMore(page.hasNextPage);
return Math.ceil(above.length / COLS);
} catch (e) {
console.warn('Library window load failed:', e);
return null;
} finally {
loadingRef.current = false;
if (alive.current) setLoading(false);
}
};
// Scrolling up to the top of the window pulls the page above it and stacks it
// on. Native maintainVisibleContentPosition keeps the offset glued to index 0
// instead of to the row the user is looking at, which walked the grid forward
// in time as it paged in - so the offset is corrected here.
const prependNewer = async () => {
const top = assetsRef.current[0];
if (!top || !hasNewerRef.current) return;
for (let i = 0; i < 50 && loadingRef.current; i++) {
await new Promise((r) => setTimeout(r, 20));
}
// A drag emits one scroll event per frame, so this is called repeatedly
// while it sits at the top; the ones that queued up behind the first page
// find the list already moved down and drop out.
if (loadingRef.current || offsetRef.current >= cell * 2) return;
loadingRef.current = true;
try {
const page = await MediaLibrary.getAssetsAsync({
first: PAGE,
mediaType: 'photo',
createdAfter: top.creationTime,
// @ts-ignore legacy SortByValue tuple shape
sortBy: [['creationTime', true]],
});
if (!alive.current) return;
const fetched = page.assets;
hasNewerRef.current = fetched.length === PAGE &&
fetched[fetched.length - 1].creationTime < (newestRef.current ?? 0);
// Whole rows only: a block that is not a multiple of COLS would shift
// every cell a column to the side. The few dropped at the newest end are
// asked for again by the next call.
let above = [...fetched].reverse();
above = above.slice(above.length % COLS);
if (!above.length) return;
assetsRef.current = [...above, ...assetsRef.current];
// The preview pager holds its photo by index, so the index has to move
// with the block that just landed in front of it.
anchorRef.current += above.length;
setAnchor(anchorRef.current);
setAssets(assetsRef.current);
offsetRef.current += (above.length / COLS) * cell;
gridRef.current?.scrollToOffset({ offset: offsetRef.current, animated: false });
} catch (e) {
console.warn('Library prepend failed:', e);
} finally {
loadingRef.current = false;
}
};
// Photo read permission first: without it the query returns an empty page and
// the picker would look broken rather than locked.
useEffect(() => {
if (!visible) {
setPreview(null);
return;
}
alive.current = true;
setPreview(null);
setFrac(0);
(async () => {
try {
const perm = await MediaLibrary.requestPermissionsAsync(false, ['photo']);
if (!alive.current) return;
if (!perm.granted) {
setError('PHOTO ACCESS DENIED - ENABLE IT IN SETTINGS');
return;
}
// Both ends of the timeline, in one round trip each. The oldest one
// needs createdAfter (it maps to DATE_TAKEN > 1): a photo the scanner
// could not date (DATE_TAKEN 0 - screenshots, downloads, anything it
// only saw with a 1970 mtime) sorts as the oldest, and an oldest of 0
// reads as "no range", so the bar moved but printed no date and never
// scrolled the grid.
const [newestPage, oldestPage] = await Promise.all([
MediaLibrary.getAssetsAsync({
first: 1,
mediaType: 'photo',
// @ts-ignore legacy SortByValue tuple shape
sortBy: [['creationTime', false]],
}),
MediaLibrary.getAssetsAsync({
first: 1,
mediaType: 'photo',
createdAfter: 1,
// @ts-ignore legacy SortByValue tuple shape
sortBy: [['creationTime', true]],
}),
]);
if (!alive.current) return;
setNewest(newestPage.assets[0]?.creationTime ?? null);
newestRef.current = newestPage.assets[0]?.creationTime ?? null;
setOldest(oldestPage.assets[0]?.creationTime ?? null);
await load();
} catch (e) {
console.warn('Photo permission request failed:', e);
if (alive.current) setError('CANNOT READ PHOTOS');
}
})();
return () => {
alive.current = false;
};
}, [visible]);
// USE: localUri is the real file (grid uri is the same on Android, the lookup
// is what also brings the EXIF/MediaStore id the GPS resolver wants).
const handleUse = async () => {
if (preview === null || busy) return;
const asset = assets[preview];
if (!asset) return;
setBusy(true);
try {
const info = await MediaLibrary.getAssetInfoAsync(asset.id);
onSelect({
uri: info.localUri || asset.uri,
assetId: asset.id,
exif: (info.exif as never) ?? null,
});
} catch (e) {
console.warn('Asset info lookup failed, using grid uri:', e);
onSelect({ uri: asset.uri, assetId: asset.id, exif: null });
} finally {
setBusy(false);
}
};
const cell = (width - SCRUB_W - 8) / COLS;
const timeAtFrac = (f: number) => {
if (!newest || !oldest || oldest >= newest) return null;
return newest - f * (newest - oldest);
};
// Jump: one query re-windows the grid on the target date, so the thumbnails
// are there as soon as the drag stops instead of after a dozen page loads.
// Dragging fires these faster than the query resolves, so only the newest
// drag position parks the grid (an older jump that finishes late is dropped).
const parkRow = (row: number) => {
gridRef.current?.scrollToOffset({ offset: row * cell, animated: false });
// The window can land after the native list has measured its content, and
// an offset past the measured content is clamped - park again once layout
// has caught up.
setTimeout(() => {
gridRef.current?.scrollToOffset({ offset: row * cell, animated: false });
}, 300);
};
const jumpTo = (f: number) => {
const target = timeAtFrac(f);
if (target == null) return;
// One query at a time, only ever for the newest position asked for. A drag
// fires these every 120ms and a window query on the 12S Ultra takes over a
// second: queueing them all left the grid frozen for the length of the drag
// and then let an early (stale) jump land last, so the grid settled on a
// date the finger had long left.
jumpTarget.current = target + 1;
if (jumpBusy.current) return;
jumpBusy.current = true;
(async () => {
while (alive.current && jumpTarget.current != null) {
const before = jumpTarget.current;
jumpTarget.current = null;
const row = await loadWindow(before);
// Park on the row the target date sits on, not the top row: the window
// opens a page above the target so the grid can still be dragged down.
if (row != null && jumpTarget.current == null && alive.current) parkRow(row);
}
jumpBusy.current = false;
})();
};
const scrub = (y: number) => {
const travel = Math.max(1, trackHRef.current - 2 * TRACK_PAD - THUMB_H);
const f = Math.min(1, Math.max(0, (y - TRACK_PAD - THUMB_H / 2) / travel));
setScrubbing(true);
setFrac(f);
// No throttle: jumpTo only records the position, and the loop inside it runs
// one query at a time, so the window that lands is always the one the bar
// ended on. Skipping a move meant the grid could settle a date behind the
// bar when the finger stopped inside the throttle window.
jumpTo(f);
};
const scrubRef = useRef(scrub);
scrubRef.current = scrub;
// Read the drag from pageY, never from locationY: locationY is relative to
// whichever node the OS picked as the touch target, and on a Xiaomi 14 Ultra
// that switched to the thumb as it slid under the finger - every other move
// event came back as a small number, which clamped the thumb back to the top
// and made it flash between the finger and the top, with no date to read.
// pageY is absolute, so the bar's own origin is measured instead.
const pan = useRef(
PanResponder.create({
onStartShouldSetPanResponder: () => true,
onMoveShouldSetPanResponder: () => true,
onPanResponderGrant: (e) => {
const { pageY, locationY } = e.nativeEvent;
const top = stripTop.current || pageY - locationY;
stripTop.current = top;
scrubRef.current(pageY - top);
},
onPanResponderMove: (e) => {
if (!stripTop.current) return;
scrubRef.current(e.nativeEvent.pageY - stripTop.current);
},
onPanResponderRelease: () => setScrubbing(false),
onPanResponderTerminate: () => setScrubbing(false),
})
).current;
// Preview pager. This used to be a paging ScrollView, which only handed the
// gesture over where its own native interceptor beat the photo's PanResponder:
// on a Xiaomi 14 Ultra that left most of the picture unable to swipe, and only
// the middle band paged. Here the pager takes the drag itself as soon as the
// finger travels, so the whole photo swipes to the previous/next one; a
// downward drag closes the preview and drops back to the thumbnails. The photo
// keeps taps and pinches - it holds the responder from the start and refuses
// to hand it over while zoomed (ZoomableImage.onPanResponderTerminationRequest).
const closePreview = () => {
settling.current = true;
Animated.parallel([
Animated.timing(tyAn, { toValue: 1200, duration: 180, useNativeDriver: false }),
Animated.timing(scaleAn, { toValue: 0.7, duration: 180, useNativeDriver: false }),
]).start(() => {
tyAn.setValue(0);
scaleAn.setValue(1);
settling.current = false;
setPreview(null);
});
};
const pager = useRef(
PanResponder.create({
// capture: the photo is the one holding the responder
onMoveShouldSetPanResponderCapture: (e, g) => {
// Two fingers is a pinch, whatever the photo has decided yet.
if (e.nativeEvent.touches.length >= 2) return false;
if (settling.current) return false;
if (Math.abs(g.dx) < 10 && g.dy < 10) return false;
axis.current = Math.abs(g.dy) > Math.abs(g.dx) ? 'y' : 'x';
return true;
},
onPanResponderMove: (_e, g) => {
if (axis.current === 'y') {
const dy = Math.max(0, g.dy);
tyAn.setValue(dy);
scaleAn.setValue(Math.max(0.7, 1 - dy / 2000));
return;
}
const i = anchorRef.current;
const edge = (i === 0 && g.dx > 0) || (i >= assetsRef.current.length - 1 && g.dx < 0);
txAn.setValue(edge ? g.dx * 0.3 : g.dx);
},
onPanResponderRelease: (_e, g) => {
const a = axis.current;
axis.current = null;
if (a === 'y') {
if (g.dy > 120 || g.vy > 0.7) {
closePreview();
return;
}
Animated.parallel([
Animated.spring(tyAn, { toValue: 0, friction: 8, tension: 80, useNativeDriver: false }),
Animated.spring(scaleAn, { toValue: 1, friction: 8, tension: 80, useNativeDriver: false }),
]).start();
return;
}
const i = anchorRef.current;
const last = assetsRef.current.length - 1;
const target = g.dx < 0 ? Math.min(i + 1, last) : Math.max(i - 1, 0);
const far = Math.abs(g.dx) > pageWRef.current * 0.2 || Math.abs(g.vx) > 0.4;
if (target === i || !far) {
Animated.spring(txAn, { toValue: 0, friction: 8, tension: 80, useNativeDriver: false }).start();
return;
}
settling.current = true;
const slide = g.dx < 0 ? -pageWRef.current : pageWRef.current;
setPreview(target);
Animated.timing(txAn, { toValue: slide, duration: 160, useNativeDriver: false }).start(() => {
// Same pixels before and after the swap, so the slots can be rebased
// on the new photo without a flicker.
txAn.setValue(0);
anchorRef.current = target;
setAnchor(target);
settling.current = false;
});
if (hasMoreRef.current && target >= assetsRef.current.length - 2) {
load(cursorRef.current);
}
},
onPanResponderTerminate: () => {
axis.current = null;
txAn.setValue(0);
tyAn.setValue(0);
scaleAn.setValue(1);
},
})
).current;
const openPreview = (index: number) => {
axis.current = null;
settling.current = false;
txAn.setValue(0);
tyAn.setValue(0);
scaleAn.setValue(1);
anchorRef.current = index;
setAnchor(index);
setPreview(index);
};
// No usable range (nothing datable, or every photo on one date): leave the
// bar out instead of showing a control that cannot move the grid.
const rangeOk = !!newest && !!oldest && oldest < newest;
const time = timeAtFrac(frac);
// creationTime is already in milliseconds, so it goes straight into Date.
const stamp = time
? (() => {
const d = new Date(time);
const p = (n: number) => String(n).padStart(2, '0');
return `${p(d.getDate())}/${p(d.getMonth() + 1)}/${d.getFullYear()}`;
})()
: null;
return (
<Modal visible={visible} animationType="slide" onRequestClose={onCancel}>
<View className="flex-1 bg-black">
{preview === null ? (
<>
{/* Header */}
<View className="pt-12 pb-3 px-4 flex-row justify-between items-center">
<View>
<Text className="text-white font-mono text-sm font-bold tracking-widest">
SELECT PHOTO
</Text>
<Text className="text-zinc-600 font-mono text-[10px] mt-0.5">
TAP TO PREVIEW · DRAG THE BAR TO CHANGE DATE
</Text>
</View>
<TouchableOpacity
onPress={onCancel}
className="w-9 h-9 rounded-full bg-zinc-900 flex items-center justify-center"
activeOpacity={0.7}
>
<X size={18} color="#ffffff" />
</TouchableOpacity>
</View>
{error ? (
<View className="flex-1 items-center justify-center px-8">
<Text className="text-zinc-400 font-mono text-xs text-center">{error}</Text>
</View>
) : (
<View className="flex-1">
<FlatList
ref={gridRef}
data={assets}
numColumns={COLS}
keyExtractor={(a) => a.id}
style={{ flex: 1, paddingRight: SCRUB_W }}
columnWrapperStyle={{ paddingHorizontal: 1 }}
// FlatList hands getItemLayout the ROW index (its item count
// is rows, not photos) - so one cell per index, no /COLS.
getItemLayout={(_, i) => ({
length: cell,
offset: cell * i,
index: i,
})}
onEndReachedThreshold={0.7}
onEndReached={() => {
if (hasMore && !loading) load(cursor);
}}
// Paging newer photos in cannot go through onStartReached: it
// stays quiet while the first rendered cell is index 0, which
// is exactly where a downward drag starts. The offset is read
// instead, and a drag that has nothing left above it pulls the
// page in.
scrollEventThrottle={16}
onScroll={(e) => {
offsetRef.current = e.nativeEvent.contentOffset.y;
if (offsetRef.current < cell * 2) prependNewer();
}}
renderItem={({ item, index }) => (
<TouchableOpacity
onPress={() => openPreview(index)}
activeOpacity={0.8}
style={{ width: cell, height: cell, padding: 1 }}
>
<Image
source={{ uri: item.uri }}
resizeMode="cover"
resizeMethod="resize"
style={{ flex: 1, backgroundColor: '#18181b' }}
/>
</TouchableOpacity>
)}
ListFooterComponent={
loading ? (
<View className="py-6 items-center">
<ActivityIndicator color="#f59e0b" />
</View>
) : null
}
/>
{assets.length > 0 && rangeOk && (
<View
ref={stripRef}
onLayout={(e) => {
trackHRef.current = e.nativeEvent.layout.height;
setTrackH(e.nativeEvent.layout.height);
stripRef.current?.measureInWindow((_x, y) => {
stripTop.current = y;
});
}}
style={{
position: 'absolute',
right: 0,
top: 0,
bottom: 0,
width: SCRUB_W,
}}
{...pan.panHandlers}
>
<View
pointerEvents="none"
className="bg-zinc-800 rounded-full"
style={{
position: 'absolute',
right: SCRUB_W / 2 - 1,
top: TRACK_PAD,
bottom: TRACK_PAD,
width: 2,
}}
/>
<View
pointerEvents="none"
className="bg-amber-500 rounded-full"
style={{
position: 'absolute',
right: 6,
width: SCRUB_W - 12,
height: THUMB_H,
top:
TRACK_PAD +
frac * Math.max(0, trackH - 2 * TRACK_PAD - THUMB_H),
}}
/>
</View>
)}
{scrubbing && stamp && (
<View
className="bg-zinc-900 border border-zinc-800 px-3 py-1.5 rounded-md"
style={{
position: 'absolute',
right: SCRUB_W + 8,
top:
TRACK_PAD +
frac * Math.max(0, trackH - 2 * TRACK_PAD - THUMB_H) +
THUMB_H / 2 -
14,
}}
>
<Text className="text-amber-400 font-mono text-[10px] font-bold">
{stamp}
</Text>
</View>
)}
</View>
)}
</>
) : (
<>
<View className="pt-12 pb-2 px-4 flex-row justify-between items-center">
<View>
<Text className="text-white font-mono text-xs font-bold tracking-widest">
{assets[preview]?.filename?.toUpperCase() || ''}
</Text>
<Text className="text-zinc-600 font-mono text-[10px] mt-0.5">
{preview + 1} / {assets.length}
{hasMore ? '+' : ''} · PINCH OR DOUBLE TAP TO ZOOM
</Text>
</View>
<TouchableOpacity
onPress={() => setPreview(null)}
className="w-9 h-9 rounded-full bg-zinc-900 flex items-center justify-center"
activeOpacity={0.7}
>
<X size={18} color="#ffffff" />
</TouchableOpacity>
</View>
<Animated.View
style={{
flex: 1,
overflow: 'hidden',
backgroundColor: '#000000',
transform: [{ translateY: tyAn }, { scale: scaleAn }],
}}
// Own width, never the window's: a strip a few pixels narrower than
// the window parked the preview between two photos.
onLayout={(e) => {
const w = e.nativeEvent.layout.width;
if (w > 0 && w !== pageWRef.current) {
pageWRef.current = w;
setPageW(w);
}
}}
{...pager.panHandlers}
>
<Animated.View
style={{
position: 'absolute',
left: 0,
right: 0,
top: 0,
bottom: 0,
transform: [{ translateX: txAn }],
}}
>
{[-1, 0, 1].map((d) => {
const i = anchor + d;
const a = assets[i];
if (!a) return null;
return (
<View
key={a.id}
style={{
position: 'absolute',
top: 0,
bottom: 0,
left: d * pageW,
width: pageW,
}}
className="justify-center items-center"
>
<ZoomableImage uri={a.uri} width={pageW} />
</View>
);
})}
</Animated.View>
</Animated.View>
<View className="pb-10 pt-3 px-4 flex-row justify-around items-center">
<TouchableOpacity
onPress={onCancel}
className="flex-row items-center bg-zinc-900 border border-zinc-800 px-6 py-3 rounded-full"
activeOpacity={0.7}
>
<X size={16} color="#ffffff" />
<Text className="text-white font-mono text-xs font-bold ml-2">CANCEL</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={handleUse}
disabled={busy}
className="flex-row items-center bg-amber-500 px-6 py-3 rounded-full"
activeOpacity={0.7}
>
<Check size={16} color="#000000" />
<Text className="text-black font-mono text-xs font-bold ml-2">
{busy ? 'LOADING...' : 'USE PHOTO'}
</Text>
</TouchableOpacity>
</View>
</>
)}
</View>
</Modal>
);
}