From de25f55f1e93aebd226bf9e49d2160f95a3afb55 Mon Sep 17 00:00:00 2001 From: Dennis Falling Date: Mon, 27 Jul 2026 13:43:42 +0100 Subject: [PATCH] Open element photos full screen with pinch-to-zoom MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The detail screen's photo strip was the only place a photo could be seen, and at 200x140 that's too small to make out anything in it. Tapping a thumbnail now opens it full screen where it can be zoomed and panned. Gestures are hand-rolled on PanResponder + Animated. ScrollView's pinch-zoom props are iOS-only so there was no built-in path, and pulling in gesture-handler + reanimated — two native modules — for one screen's zoom is a worse trade than ~150 lines of touch handling. The transform geometry is split into photoZoom.ts so it can be tested without a renderer, leaving the component to only translate touches into it. Co-Authored-By: Claude Opus 5 (1M context) --- __tests__/photoZoom.test.ts | 125 ++++++++++ src/map/ElementDetailScreen.tsx | 40 ++- src/photos/PhotoViewer.tsx | 428 ++++++++++++++++++++++++++++++++ src/photos/photoZoom.ts | 155 ++++++++++++ 4 files changed, 743 insertions(+), 5 deletions(-) create mode 100644 __tests__/photoZoom.test.ts create mode 100644 src/photos/PhotoViewer.tsx create mode 100644 src/photos/photoZoom.ts diff --git a/__tests__/photoZoom.test.ts b/__tests__/photoZoom.test.ts new file mode 100644 index 0000000..da0d621 --- /dev/null +++ b/__tests__/photoZoom.test.ts @@ -0,0 +1,125 @@ +/** + * @format + */ + +import { + clampScale, + clampTranslate, + dismissOpacity, + fitContain, + IDENTITY, + MAX_SCALE, + shouldDismiss, + touchDistance, + touchMidpoint, + zoomAroundFocus, +} from '../src/photos/photoZoom'; + +const CONTAINER = {width: 400, height: 800}; + +test('a landscape photo fits to the container width', () => { + expect(fitContain({width: 1000, height: 500}, CONTAINER)).toEqual({ + width: 400, + height: 200, + }); +}); + +test('a tall photo fits to the container height', () => { + expect(fitContain({width: 500, height: 2000}, CONTAINER)).toEqual({ + width: 200, + height: 800, + }); +}); + +test('unknown sizes fit to nothing rather than dividing by zero', () => { + expect(fitContain({width: 0, height: 0}, CONTAINER)).toEqual({ + width: 0, + height: 0, + }); + expect(fitContain({width: 1000, height: 500}, {width: 0, height: 0})).toEqual( + {width: 0, height: 0}, + ); +}); + +test('scale is clamped to the zoom range', () => { + expect(clampScale(0.2)).toBe(1); + expect(clampScale(2)).toBe(2); + expect(clampScale(99)).toBe(MAX_SCALE); +}); + +test('a photo that fits the container is pinned to the center', () => { + const fitted = {width: 400, height: 200}; + expect(clampTranslate({scale: 1, x: 120, y: -80}, fitted, CONTAINER)).toEqual( + {scale: 1, x: 0, y: 0}, + ); +}); + +test('panning is bounded by the overhang on each axis', () => { + const fitted = {width: 400, height: 200}; + // At 3x the photo is 1200x600: 800px wider than the container, so it pans + // 400px either way, and still shorter than it, so it can't pan vertically. + const clamped = clampTranslate( + {scale: 3, x: 1000, y: 1000}, + fitted, + CONTAINER, + ); + expect(clamped).toEqual({scale: 3, x: 400, y: 0}); + expect(clampTranslate({scale: 3, x: -50, y: 0}, fitted, CONTAINER)).toEqual({ + scale: 3, + x: -50, + y: 0, + }); +}); + +test('zooming keeps the focal point under the fingers', () => { + const fitted = {width: 400, height: 800}; + const focus = {x: 100, y: 200}; + const zoomed = zoomAroundFocus(IDENTITY, 2, focus, CONTAINER); + + // The image point under `focus` before the zoom must still be under it after. + const pointAt = (t: {scale: number; x: number; y: number}) => ({ + x: (focus.x - CONTAINER.width / 2 - t.x) / t.scale, + y: (focus.y - CONTAINER.height / 2 - t.y) / t.scale, + }); + expect(pointAt(zoomed)).toEqual(pointAt(IDENTITY)); + // Zooming around a point left of and above center pushes the photo down-right. + expect(zoomed.x).toBeGreaterThan(0); + expect(zoomed.y).toBeGreaterThan(0); + // ...but not so far that the photo pulls away from the container edge. + expect(clampTranslate(zoomed, fitted, CONTAINER)).toEqual({ + scale: 2, + x: 100, + y: 200, + }); +}); + +test('zooming past the max stops at the max', () => { + expect(zoomAroundFocus(IDENTITY, 20, {x: 200, y: 400}, CONTAINER).scale).toBe( + MAX_SCALE, + ); +}); + +test('pinch distance and midpoint come from the two touches', () => { + const a = {pageX: 100, pageY: 200}; + const b = {pageX: 140, pageY: 230}; + expect(touchDistance(a, b)).toBe(50); + expect(touchMidpoint(a, b)).toEqual({x: 120, y: 215}); +}); + +test('a long drag dismisses; a short slow one does not', () => { + expect(shouldDismiss({dx: 0, dy: 200, vx: 0, vy: 0.1})).toBe(true); + expect(shouldDismiss({dx: -180, dy: 0, vx: -0.1, vy: 0})).toBe(true); + expect(shouldDismiss({dx: 0, dy: 40, vx: 0, vy: 0.1})).toBe(false); +}); + +test('a short flick dismisses in any direction', () => { + expect(shouldDismiss({dx: 30, dy: 30, vx: 1.4, vy: 1.4})).toBe(true); + // Too small to be a swipe at all — a jittery tap must not close the viewer. + expect(shouldDismiss({dx: 2, dy: 2, vx: 3, vy: 3})).toBe(false); +}); + +test('dragging fades the viewer but never all the way out', () => { + expect(dismissOpacity(0, 0)).toBe(1); + expect(dismissOpacity(0, 110)).toBeCloseTo(0.5); + expect(dismissOpacity(0, 5000)).toBe(0.35); +}); diff --git a/src/map/ElementDetailScreen.tsx b/src/map/ElementDetailScreen.tsx index 4fe173b..87e2d06 100644 --- a/src/map/ElementDetailScreen.tsx +++ b/src/map/ElementDetailScreen.tsx @@ -1,5 +1,5 @@ import type {NativeStackScreenProps} from '@react-navigation/native-stack'; -import {useMemo} from 'react'; +import {useMemo, useState} from 'react'; import { ActivityIndicator, Image, @@ -16,6 +16,7 @@ import { useElementDetailQuery, } from '../graphql/__generated__/types'; import type {RootStackParamList} from '../navigation/types'; +import {PhotoViewer} from '../photos/PhotoViewer'; import {photoImageSource} from '../photos/photoImageSource'; import type {Theme} from '../theme/colors'; import {useTheme} from '../theme/useTheme'; @@ -24,6 +25,8 @@ type Props = NativeStackScreenProps; type ElementDetail = ElementDetailQuery['element']; +type ElementPhoto = ElementDetail['photos'][number]; + export function ElementDetailScreen({route, navigation}: Props) { const {elementId} = route.params; const theme = useTheme(); @@ -64,6 +67,7 @@ function ModalContents({ const theme = useTheme(); const styles = useMemo(() => makeStyles(theme), [theme]); const safeAreaInsets = useSafeAreaInsets(); + const [viewedPhoto, setViewedPhoto] = useState(null); return ( @@ -122,11 +126,21 @@ function ModalContents({ showsHorizontalScrollIndicator={false} contentContainerStyle={styles.photoStrip}> {element.photos.map(photo => ( - + accessibilityRole="imagebutton" + accessibilityLabel={ + photo.description + ? `View photo full screen: ${photo.description}` + : 'View photo full screen' + } + onPress={() => setViewedPhoto(photo)} + style={({pressed}) => pressed && styles.photoPressed}> + + ))} ) : null} @@ -184,6 +198,19 @@ function ModalContents({ {loading ? : null} )} + + {/* + Sibling of the ScrollView, not a child: as an overlay it has to cover + the header too, and a PanResponder nested inside a scroll view would + fight it for the pinch. + */} + {viewedPhoto ? ( + setViewedPhoto(null)} + /> + ) : null} ); } @@ -320,6 +347,9 @@ const makeStyles = (theme: Theme) => borderRadius: 10, backgroundColor: theme.surfaceMuted, }, + photoPressed: { + opacity: 0.7, + }, section: { marginTop: 20, }, diff --git a/src/photos/PhotoViewer.tsx b/src/photos/PhotoViewer.tsx new file mode 100644 index 0000000..414b432 --- /dev/null +++ b/src/photos/PhotoViewer.tsx @@ -0,0 +1,428 @@ +import {useEffect, useMemo, useRef, useState} from 'react'; +import { + ActivityIndicator, + Animated, + BackHandler, + type GestureResponderEvent, + Image, + PanResponder, + type PanResponderGestureState, + Pressable, + StyleSheet, + Text, + View, +} from 'react-native'; +import {useSafeAreaInsets} from 'react-native-safe-area-context'; +import {photoImageSource} from './photoImageSource'; +import { + clampScale, + clampTranslate, + DOUBLE_TAP_SCALE, + DRAG_SLOP, + dismissOpacity, + fitContain, + IDENTITY, + MIN_SCALE, + type Size, + shouldDismiss, + type Transform, + touchDistance, + touchMidpoint, + zoomAroundFocus, +} from './photoZoom'; + +type Props = { + /** URL of the image to show; the same one used for the thumbnail is fine. */ + uri: string; + /** The photo's alt text, used as the image's accessibility label. */ + description?: string; + onClose: () => void; +}; + +const ZERO_SIZE: Size = {width: 0, height: 0}; + +/** Max gap between two taps that still counts as a double tap (ms). */ +const DOUBLE_TAP_WINDOW = 280; + +const SPRING = { + useNativeDriver: true, + friction: 8, + tension: 90, +} as const; + +/** What the current one-or-two-finger gesture has been resolved to. */ +type Mode = 'idle' | 'pinch' | 'pan' | 'dismiss'; + +type GestureState = { + mode: Mode; + /** Transform the gesture (or its current leg) started from. */ + from: Transform; + /** Accumulated pan at the start of the current leg, to make it relative. */ + origin: {dx: number; dy: number}; + /** Finger separation when the pinch began. */ + pinchDistance: number; + /** Pinch focal point, in container coordinates. */ + focus: {x: number; y: number}; + lastTapAt: number; +}; + +/** + * Full-screen photo viewer: pinch to zoom, drag to pan while zoomed, double tap + * to toggle zoom, and swipe the photo away (any direction) or tap × to close. + * + * Rendered as an absolute overlay rather than a React Native `Modal`, for the + * same reason as {@link Sheet}: `Modal` gets its own window, which does not + * reliably draw under the system bars on Android 15, so a "full-screen" viewer + * would stop short of them. + * + * Gestures are hand-rolled on `PanResponder` + `Animated` because the app has + * no gesture-handler/reanimated dependency, and one screen's pinch-zoom doesn't + * justify pulling in two native modules. The transform math lives in + * {@link photoZoom}; this component only translates touches into calls on it. + */ +export function PhotoViewer({uri, description, onClose}: Props) { + const safeAreaInsets = useSafeAreaInsets(); + const [containerSize, setContainerSize] = useState(ZERO_SIZE); + const [naturalSize, setNaturalSize] = useState(ZERO_SIZE); + const [loaded, setLoaded] = useState(false); + + // The photo's laid-out size before any zoom. Until the image reports its + // dimensions, fall back to the container so gestures have sane bounds. + const fitted = useMemo(() => { + const contained = fitContain(naturalSize, containerSize); + return contained.width > 0 ? contained : containerSize; + }, [naturalSize, containerSize]); + + const scale = useRef(new Animated.Value(IDENTITY.scale)).current; + const translateX = useRef(new Animated.Value(IDENTITY.x)).current; + const translateY = useRef(new Animated.Value(IDENTITY.y)).current; + const opacity = useRef(new Animated.Value(0)).current; + + // Animated.Value has no public getter, so the committed transform is + // mirrored in a ref for the gesture handlers to read. + const transform = useRef(IDENTITY); + const container = useRef(containerSize); + const frame = useRef(fitted); + const onCloseRef = useRef(onClose); + const closing = useRef(false); + + useEffect(() => { + container.current = containerSize; + frame.current = fitted; + }, [containerSize, fitted]); + + useEffect(() => { + onCloseRef.current = onClose; + }, [onClose]); + + // Fade in on mount; the dismiss animation fades back out. + useEffect(() => { + Animated.timing(opacity, { + toValue: 1, + duration: 160, + useNativeDriver: true, + }).start(); + }, [opacity]); + + // The hardware back button closes the viewer, not the detail screen. + useEffect(() => { + const sub = BackHandler.addEventListener('hardwareBackPress', () => { + onCloseRef.current(); + return true; + }); + return () => sub.remove(); + }, []); + + const gesture = useRef({ + mode: 'idle', + from: IDENTITY, + origin: {dx: 0, dy: 0}, + pinchDistance: 0, + focus: {x: 0, y: 0}, + lastTapAt: 0, + }); + + // Built once: every handler reads mutable refs and Animated values, both of + // which are stable, so the responder never needs to be rebuilt. + const responder = useMemo(() => { + const apply = (next: Transform) => { + transform.current = next; + scale.setValue(next.scale); + translateX.setValue(next.x); + translateY.setValue(next.y); + }; + + const settle = (next: Transform) => { + transform.current = next; + Animated.parallel([ + Animated.spring(scale, {...SPRING, toValue: next.scale}), + Animated.spring(translateX, {...SPRING, toValue: next.x}), + Animated.spring(translateY, {...SPRING, toValue: next.y}), + Animated.spring(opacity, {...SPRING, toValue: 1}), + ]).start(); + }; + + const dismiss = (state: PanResponderGestureState) => { + closing.current = true; + // Keep flinging in the drag's direction while fading out, so the photo + // leaves along the path the finger was taking. + Animated.parallel([ + Animated.timing(opacity, { + toValue: 0, + duration: 170, + useNativeDriver: true, + }), + Animated.timing(translateX, { + toValue: transform.current.x + state.vx * 120, + duration: 170, + useNativeDriver: true, + }), + Animated.timing(translateY, { + toValue: transform.current.y + state.vy * 120, + duration: 170, + useNativeDriver: true, + }), + ]).start(() => onCloseRef.current()); + }; + + const handleTap = (event: GestureResponderEvent) => { + const now = Date.now(); + if (now - gesture.current.lastTapAt > DOUBLE_TAP_WINDOW) { + gesture.current.lastTapAt = now; + return; + } + gesture.current.lastTapAt = 0; + const {pageX, pageY} = event.nativeEvent; + const target = + transform.current.scale > MIN_SCALE + ? IDENTITY + : zoomAroundFocus( + transform.current, + DOUBLE_TAP_SCALE, + {x: pageX, y: pageY}, + container.current, + ); + settle(clampTranslate(target, frame.current, container.current)); + }; + + return PanResponder.create({ + onStartShouldSetPanResponder: () => true, + onMoveShouldSetPanResponder: () => true, + // Nothing above this overlay should be able to take over mid-gesture. + onPanResponderTerminationRequest: () => false, + onShouldBlockNativeResponder: () => true, + + onPanResponderGrant: () => { + // A settle spring still in flight would keep overwriting the values + // this gesture is about to set, so stop it. Grabbing the photo + // mid-spring therefore starts from where the spring was headed. + scale.stopAnimation(); + translateX.stopAnimation(); + translateY.stopAnimation(); + opacity.stopAnimation(); + opacity.setValue(1); + gesture.current.mode = 'idle'; + gesture.current.from = transform.current; + gesture.current.origin = {dx: 0, dy: 0}; + }, + + onPanResponderMove: (event, state) => { + if (closing.current) return; + const touches = event.nativeEvent.touches; + const current = gesture.current; + + if (touches.length >= 2) { + const distance = touchDistance(touches[0], touches[1]); + if (current.mode !== 'pinch') { + // Anchor the pinch: the focal point stays fixed for the whole + // pinch, so zooming doesn't drift as the fingers rotate or slide. + current.mode = 'pinch'; + current.from = transform.current; + current.pinchDistance = distance; + current.focus = touchMidpoint(touches[0], touches[1]); + } + if (current.pinchDistance > 0) { + apply( + zoomAroundFocus( + current.from, + current.from.scale * (distance / current.pinchDistance), + current.focus, + container.current, + ), + ); + } + return; + } + + if (current.mode === 'pinch') { + // A finger lifted mid-pinch. Re-anchor to the transform reached so + // far and let the remaining finger pan from there, rather than + // snapping back to where the whole gesture began. + current.mode = 'pan'; + current.from = transform.current; + current.origin = {dx: state.dx, dy: state.dy}; + } + + const dx = state.dx - current.origin.dx; + const dy = state.dy - current.origin.dy; + + if (current.mode === 'idle') { + if (Math.hypot(dx, dy) < DRAG_SLOP) return; + // Zoomed in, a drag pans the photo; at 1x there's nowhere to pan, so + // it swipes the viewer away instead. + current.mode = + transform.current.scale > MIN_SCALE ? 'pan' : 'dismiss'; + current.from = transform.current; + } + + const dragged: Transform = { + scale: current.from.scale, + x: current.from.x + dx, + y: current.from.y + dy, + }; + + if (current.mode === 'pan') { + apply(clampTranslate(dragged, frame.current, container.current)); + } else { + // Unclamped: the photo tracks the finger off-screen. + apply(dragged); + opacity.setValue(dismissOpacity(dx, dy)); + } + }, + + onPanResponderRelease: (event, state) => { + if (closing.current) return; + const current = gesture.current; + if (current.mode === 'dismiss' && shouldDismiss(state)) { + dismiss(state); + return; + } + if (current.mode === 'idle') { + handleTap(event); + return; + } + current.mode = 'idle'; + settle( + clampTranslate( + {...transform.current, scale: clampScale(transform.current.scale)}, + frame.current, + container.current, + ), + ); + }, + + onPanResponderTerminate: () => { + if (closing.current) return; + gesture.current.mode = 'idle'; + settle( + clampTranslate( + {...transform.current, scale: clampScale(transform.current.scale)}, + frame.current, + container.current, + ), + ); + }, + }); + }, [opacity, scale, translateX, translateY]); + + return ( + { + const {width, height} = event.nativeEvent.layout; + setContainerSize({width, height}); + }}> + {/* + The overlay fills the screen, so a touch's page coordinates are also its + coordinates within this container — what the pinch focal math wants. + */} + + + { + const source = event.nativeEvent.source; + if (source?.width && source?.height) { + setNaturalSize({width: source.width, height: source.height}); + } + setLoaded(true); + }} + onError={() => setLoaded(true)} + /> + + + + {loaded ? null : ( + + + + )} + + [ + styles.closeButton, + {top: safeAreaInsets.top + 8}, + pressed && styles.closeButtonPressed, + ]}> + × + + + ); +} + +// Not themed: a photo viewer is black in both appearances, so the image sets +// the mood and its edges are unambiguous. The chrome is therefore fixed to +// translucent white rather than reading from the theme. +const styles = StyleSheet.create({ + overlay: { + ...StyleSheet.absoluteFill, + backgroundColor: '#000000', + }, + // The photo is centered in the gesture area, which is what makes a zero + // translation mean "centered" for the transform math in photoZoom. + gestureArea: { + ...StyleSheet.absoluteFill, + alignItems: 'center', + justifyContent: 'center', + }, + image: { + width: '100%', + height: '100%', + }, + spinner: { + ...StyleSheet.absoluteFill, + alignItems: 'center', + justifyContent: 'center', + }, + closeButton: { + position: 'absolute', + left: 16, + width: 36, + height: 36, + borderRadius: 18, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: 'rgba(0,0,0,0.45)', + }, + closeButtonPressed: { + backgroundColor: 'rgba(255,255,255,0.25)', + }, + closeIcon: { + fontSize: 22, + lineHeight: 24, + color: '#ffffff', + }, +}); diff --git a/src/photos/photoZoom.ts b/src/photos/photoZoom.ts new file mode 100644 index 0000000..b6a0684 --- /dev/null +++ b/src/photos/photoZoom.ts @@ -0,0 +1,155 @@ +/** + * Geometry for the full-screen photo viewer's pinch/pan gestures. + * + * Kept as pure functions (no Animated, no React) so the transform math is + * testable on its own and {@link PhotoViewer} only has to wire gestures to it. + * + * Coordinate system: the image is laid out centered in the container at its + * `contain`-fitted size, then transformed as `translate(x, y) scale(scale)` — + * i.e. translation is applied in unscaled container pixels, measured from the + * container's center, and is *not* multiplied by the scale. Screen position of + * an image-space point `p` (also measured from the image's center) is therefore + * + * screen = containerCenter + {x, y} + p * scale + */ + +export type Size = {width: number; height: number}; + +/** A photo's on-screen transform: uniform zoom plus a centered offset. */ +export type Transform = {scale: number; x: number; y: number}; + +export const MIN_SCALE = 1; +export const MAX_SCALE = 4; + +/** Zoom applied by a double tap, when not already zoomed in. */ +export const DOUBLE_TAP_SCALE = 2.5; + +export const IDENTITY: Transform = {scale: MIN_SCALE, x: 0, y: 0}; + +/** Movement (px) a one-finger drag must exceed before it counts as a gesture. */ +export const DRAG_SLOP = 6; + +/** Drag distance (px) past which releasing dismisses the viewer. */ +const DISMISS_DISTANCE = 110; + +/** Drag speed (px/ms) past which releasing dismisses, even if short. */ +const DISMISS_VELOCITY = 0.7; + +const clamp = (value: number, min: number, max: number) => + Math.min(Math.max(value, min), max); + +/** The size a `contain`-fitted image renders at inside `container`. */ +export function fitContain(image: Size, container: Size): Size { + if ( + image.width <= 0 || + image.height <= 0 || + container.width <= 0 || + container.height <= 0 + ) { + return {width: 0, height: 0}; + } + const ratio = Math.min( + container.width / image.width, + container.height / image.height, + ); + return {width: image.width * ratio, height: image.height * ratio}; +} + +export function clampScale(scale: number): number { + return clamp(scale, MIN_SCALE, MAX_SCALE); +} + +/** + * Pull a transform back so the zoomed image can't be dragged away from the + * container edges. `fitted` is the image's size at scale 1. When the scaled + * image is smaller than the container on an axis it is pinned to the center, + * which is what snaps a photo back after a pinch out to 1x. + */ +export function clampTranslate( + transform: Transform, + fitted: Size, + container: Size, +): Transform { + const maxX = Math.max( + 0, + (fitted.width * transform.scale - container.width) / 2, + ); + const maxY = Math.max( + 0, + (fitted.height * transform.scale - container.height) / 2, + ); + // `|| 0` only normalizes the -0 that clamping against a zero bound yields. + return { + scale: transform.scale, + x: clamp(transform.x, -maxX, maxX) || 0, + y: clamp(transform.y, -maxY, maxY) || 0, + }; +} + +/** + * Rescale to `nextScale` while keeping whatever image point sits under `focus` + * pinned there, so a pinch zooms around the fingers (and a double tap around + * the tapped point) instead of around the image's center. `focus` is in + * container coordinates, origin at its top-left. + */ +export function zoomAroundFocus( + from: Transform, + nextScale: number, + focus: {x: number; y: number}, + container: Size, +): Transform { + const scale = clampScale(nextScale); + if (from.scale <= 0) return {scale, x: from.x, y: from.y}; + const focusX = focus.x - container.width / 2; + const focusY = focus.y - container.height / 2; + const ratio = scale / from.scale; + return { + scale, + x: focusX - (focusX - from.x) * ratio, + y: focusY - (focusY - from.y) * ratio, + }; +} + +/** Distance between two active touches, for pinch tracking. */ +export function touchDistance( + a: {pageX: number; pageY: number}, + b: {pageX: number; pageY: number}, +): number { + return Math.hypot(b.pageX - a.pageX, b.pageY - a.pageY); +} + +/** Midpoint of two active touches — the focal point of a pinch. */ +export function touchMidpoint( + a: {pageX: number; pageY: number}, + b: {pageX: number; pageY: number}, +): {x: number; y: number} { + return {x: (a.pageX + b.pageX) / 2, y: (a.pageY + b.pageY) / 2}; +} + +/** + * Whether releasing a swipe-away drag should close the viewer: either it moved + * far enough, or it was a deliberate flick in any direction. + */ +export function shouldDismiss(gesture: { + dx: number; + dy: number; + vx: number; + vy: number; +}): boolean { + const distance = Math.hypot(gesture.dx, gesture.dy); + const velocity = Math.hypot(gesture.vx, gesture.vy); + return ( + distance > DISMISS_DISTANCE || + (distance > DRAG_SLOP * 4 && velocity > DISMISS_VELOCITY) + ); +} + +/** + * Viewer opacity part-way through a swipe-away drag, so the photo visibly + * lifts off the screen as it's dragged. Floors above 0 to keep it draggable + * back to fully opaque. + */ +export function dismissOpacity(dx: number, dy: number): number { + const distance = Math.hypot(dx, dy); + return clamp(1 - distance / (DISMISS_DISTANCE * 2), 0.35, 1); +}