diff --git a/.gitignore b/.gitignore index e599a9d..1d72dad 100644 --- a/.gitignore +++ b/.gitignore @@ -40,6 +40,9 @@ android.iml # example/ios/Pods +# Expo Go is the deploy path; example/ios/ is regenerable prebuild output. +example/ios/ + # node.js # node_modules/ diff --git a/SPECS.md b/SPECS.md index b4cb04f..cdca26f 100644 --- a/SPECS.md +++ b/SPECS.md @@ -38,8 +38,8 @@ Exported from `src/index.tsx`: - `ReactNativeZoomableViewProps` — prop type - `ReactNativeZoomableViewRef` — imperative handle - `ZoomableViewEvent` — `{ zoomLevel, offsetX, offsetY, originalWidth, originalHeight }` -- `useZoomableViewContext()` — hook returning `{ zoom, inverseZoom, inverseZoomStyle, offsetX, offsetY }` for descendants -- `FixedSize` — wrapper that keeps absolutely-positioned children at constant visual size regardless of zoom +- `useZoomableViewContext()` — hook returning `{ zoom, offsetX, offsetY }` for descendants +- `NonScalingOverlay` — translate-only overlay component; markers position via `left: 'X%' / top: 'Y%'` and render at 1:1 screen size at every zoom level. Typically used via the `renderOverlay` prop on `ReactNativeZoomableView`; can also be mounted manually with explicit `contentWidth`/`contentHeight`/`wrapperWidth`/`wrapperHeight` numeric props plus SharedValue `zoom`/`offsetX`/`offsetY` (and optional `rotation`). - `applyContainResizeMode`, `getImageOriginOnTransformSubject`, `viewportPositionToImagePosition` — coordinate helpers --- @@ -317,7 +317,7 @@ This major replaces the class-component PanResponder/Animated implementation wit ### New public exports - `useZoomableViewContext()` -- `FixedSize` +- `NonScalingOverlay` (with `renderOverlay` prop on `ReactNativeZoomableView` as the primary entry point) - `ReactNativeZoomableViewRef` typed imperative handle - `applyContainResizeMode`, `getImageOriginOnTransformSubject`, `viewportPositionToImagePosition` diff --git a/example/App.tsx b/example/App.tsx index 39e8f0f..68969b9 100644 --- a/example/App.tsx +++ b/example/App.tsx @@ -1,5 +1,4 @@ import { - FixedSize, ReactNativeZoomableView, ReactNativeZoomableViewRef, } from '@openspacelabs/react-native-zoomable-view'; @@ -27,12 +26,16 @@ import Animated, { } from 'react-native-reanimated'; import { ReText } from 'react-native-redash'; -import { applyContainResizeMode } from '../src/helper/coordinateConversion'; import { styles } from './style'; const kittenSize = 800; -const uri = `https://placekitten.com/${kittenSize}/${kittenSize}`; -const imageSize = { width: kittenSize, height: kittenSize }; +// `placekitten.com` has been offline for an extended period. Lorem Picsum +// is the reliable drop-in (CDN-backed via Cloudflare, accepts the same +// `//` URL shape). Loses the cat theme — placecats.com is the +// cat-themed alternative — but Picsum's reliability matters more for an +// example app that needs to actually render the image on every fresh +// install. +const uri = `https://picsum.photos/${kittenSize}/${kittenSize}`; const stringifyPoint = (point?: { x: number; y: number }) => point ? `${Math.round(point.x)}, ${Math.round(point.y)}` : 'Off map'; @@ -209,7 +212,22 @@ export default function App() { }); const staticPinPosition = { x: size.width / 2, y: size.height / 2 }; - const { size: contentSize } = applyContainResizeMode(imageSize, size); + + // Capture the source dims via `onLoad` so the contents View can be + // sized to match the image's aspect ratio. With matching aspect, RN's + // resizeMode 'contain' produces zero letterbox — the rendered-pixel + // frame equals the element frame — and the contents View's onLayout + // gives `NonScalingOverlay` the exact `contentWidth`/`contentHeight` + // it needs (no separate `applyContainResizeMode` step). + const [imageSourceSize, setImageSourceSize] = useState<{ + width: number; + height: number; + }>({ width: kittenSize, height: kittenSize }); + const sourceAspect = imageSourceSize.width / imageSourceSize.height; + const [contentSize, setContentSize] = useState<{ + width: number; + height: number; + }>({ width: 0, height: 0 }); const Wrapper = modal ? PageSheetModal : View; @@ -250,23 +268,102 @@ export default function App() { // Give these to the zoomable view so it can apply the boundaries around the actual content. // Need to make sure the content is actually centered and the width and height are // measured when it's rendered naturally. Not the intrinsic sizes. - contentWidth={contentSize?.width ?? 0} - contentHeight={contentSize?.height ?? 0} + contentWidth={contentSize.width} + contentHeight={contentSize.height} + renderOverlay={ + showMarkers + ? () => { + // Wrapper ≈ box minus its 5pt border each side. The + // lib doesn't expose its `wrapperSize` directly, but + // `box.width/height - 10` reproduces it on this + // example. Used only for the debug HUD. + const wrapperApproxW = Math.max(0, size.width - 10); + const wrapperApproxH = Math.max(0, size.height - 10); + return ( + <> + {/* DEBUG: visualize the overlay's bounding box. + Sized 100% × 100% of the overlay so it tracks + contentSize × zoom and reveals where the + translate-only overlay is actually painting on + screen. Example-only — remove for production. */} + + {/* DEBUG HUD pinned to overlay's top-left so it + tracks the overlay's transform and provides + the live numbers used by the translate math + (translateX/Y at z=1, ox=oy=0). */} + + NSOL wW≈{Math.round(wrapperApproxW)} wH≈ + {Math.round(wrapperApproxH)} cW= + {Math.round(contentSize.width)} cH= + {Math.round(contentSize.height)} tXjs= + {Math.round( + wrapperApproxW / 2 - contentSize.width / 2 + )}{' '} + tYjs= + {Math.round( + wrapperApproxH / 2 - contentSize.height / 2 + )} + + {[20, 40, 60, 80].map((left) => + [20, 40, 60, 80].map((top) => ( + + )) + )} + + ); + } + : undefined + } > - - - - {showMarkers && - [20, 40, 60, 80].map((left) => - [20, 40, 60, 80].map((top) => ( - - - - )) - )} + { + const { width, height } = e.nativeEvent.layout; + setContentSize((prev) => + prev.width === width && prev.height === height + ? prev + : { width, height } + ); + }} + > + { + const src = e.nativeEvent.source; + setImageSourceSize((prev) => + prev.width === src.width && prev.height === src.height + ? prev + : { width: src.width, height: src.height } + ); + }} + /> + + DBG contentSize: {Math.round(contentSize.width)}× + {Math.round(contentSize.height)} box: {Math.round(size.width)}× + {Math.round(size.height)} + onStaticPinPositionChange: {stringifyPoint(pin)}