From 6db57ff8a5c690aa926fd7850e7b205276156554 Mon Sep 17 00:00:00 2001 From: thomasvo Date: Sun, 10 May 2026 15:47:18 -0700 Subject: [PATCH 01/29] feat: add NonScalingOverlay translate-only marker overlay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a `NonScalingOverlay` component plus a `renderOverlay` prop on `ReactNativeZoomableView` for layering markers (pins, dots, labels) over zoomable content without inverse-scaling each child. Markers render at 1:1 screen size at every zoom level — the overlay box itself absorbs zoom/pan via a translate-only `useAnimatedStyle`, while children position via `left: 'X%' / top: 'Y%'` against the overlay's content-percentage frame. Why this matters - The pre-existing `FixedSize` primitive uses an outer scale + per- child inverse-scale transform. On iOS that reduces CALayer contentsScale and produces visible pixelation when zoomed in. It also pulls markers toward the transform origin (clumping) because every child shares the same scale anchor. The new translate-only model has neither problem. Public API - `renderOverlay?: () => React.ReactNode` on `ReactNativeZoomableView`. When supplied, the library mounts the returned tree inside a `` as a sibling of the inner zoom layer, so the user wires zero plumbing. - `NonScalingOverlay` exported as a standalone component for advanced consumers who need manual mount control. Self-contained props (numeric content/wrapper sizes + SharedValue zoom/offset/ optional rotation), so it can be used outside the library's context if needed. - `FixedSize` untouched (back-compat). Implementation notes - `useAnimatedStyle` sizes the overlay container to `contentW*z x contentH*z` and translates it to align with the inner zoom layer. With `rotation` the 5-element transform list is required to pivot around the overlay geometric center and apply pan in the rotated frame; comments in `NonScalingOverlay.tsx` describe the matrix-composition mechanism. - Children are wrapped in a fake `ReactNativeZoomableViewProvider` with zoom=1 / offsets=0 / inverseZoom=1 so nested `useZoomableViewContext` consumers (including `FixedSize`) become no-ops and don't double-counteract zoom inside the overlay. - The main component mirrors wrapper width/height from `measureZoomSubject` into React state alongside the existing SharedValue path, so `NonScalingOverlay` receives them as plain numbers for its worklet-closure math. Example - `example/App.tsx` now uses `renderOverlay` to render the 4x4 dot grid instead of mapping `FixedSize` children. The dots use the existing `styles.marker` plus per-dot `left/top` overrides. iOS Local Network Privacy - `example/app.json` adds expo.ios.infoPlist.NSLocalNetworkUsageDescription so future expo prebuild regens preserve the key. - `example/ios/.../Info.plist` carries the same key so the current prebuild artifact lets the example reach Metro over LAN on iOS 14+ physical devices. Without it the OS blocks every Metro probe at the nw_path layer and RCTBundleURLProvider returns nil -> "No script URL provided" red screen. --- example/App.tsx | 32 ++-- example/app.json | 5 +- .../Info.plist | 78 +++++++++ src/ReactNativeZoomableView.tsx | 46 ++++- src/components/NonScalingOverlay.tsx | 157 ++++++++++++++++++ src/index.tsx | 4 + src/typings/index.ts | 17 ++ 7 files changed, 327 insertions(+), 12 deletions(-) create mode 100644 example/ios/openspacelabsreactnativezoomableviewexample/Info.plist create mode 100644 src/components/NonScalingOverlay.tsx diff --git a/example/App.tsx b/example/App.tsx index 39e8f0fe..4843722d 100644 --- a/example/App.tsx +++ b/example/App.tsx @@ -1,5 +1,4 @@ import { - FixedSize, ReactNativeZoomableView, ReactNativeZoomableViewRef, } from '@openspacelabs/react-native-zoomable-view'; @@ -252,18 +251,31 @@ export default function App() { // measured when it's rendered naturally. Not the intrinsic sizes. contentWidth={contentSize?.width ?? 0} contentHeight={contentSize?.height ?? 0} + renderOverlay={ + showMarkers + ? () => ( + <> + {[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) => ( - - - - )) - )} diff --git a/example/app.json b/example/app.json index 47fe619c..0e5a42c0 100644 --- a/example/app.json +++ b/example/app.json @@ -18,7 +18,10 @@ "**/*" ], "ios": { - "supportsTablet": true + "supportsTablet": true, + "infoPlist": { + "NSLocalNetworkUsageDescription": "Used to connect to the Metro development server during local development." + } }, "android": { "adaptiveIcon": { diff --git a/example/ios/openspacelabsreactnativezoomableviewexample/Info.plist b/example/ios/openspacelabsreactnativezoomableviewexample/Info.plist new file mode 100644 index 00000000..6347ffde --- /dev/null +++ b/example/ios/openspacelabsreactnativezoomableviewexample/Info.plist @@ -0,0 +1,78 @@ + + + + + CADisableMinimumFrameDurationOnPhone + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + @openspacelabs/react-native-zoomable-view-example + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 2.0.1 + CFBundleSignature + ???? + CFBundleURLTypes + + + CFBundleURLSchemes + + com.anonymous.openspacelabs-react-native-zoomable-view-example + + + + CFBundleVersion + 1 + LSMinimumSystemVersion + 12.0 + LSRequiresIPhoneOS + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + NSAllowsLocalNetworking + + + NSLocalNetworkUsageDescription + Used to connect to the Metro development server during local development. + RCTNewArchEnabled + + UILaunchStoryboardName + SplashScreen + UIRequiredDeviceCapabilities + + arm64 + + UIRequiresFullScreen + + UIStatusBarStyle + UIStatusBarStyleDefault + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UIUserInterfaceStyle + Light + UIViewControllerBasedStatusBarAppearance + + + \ No newline at end of file diff --git a/src/ReactNativeZoomableView.tsx b/src/ReactNativeZoomableView.tsx index 64d9d4f4..ce219719 100644 --- a/src/ReactNativeZoomableView.tsx +++ b/src/ReactNativeZoomableView.tsx @@ -26,6 +26,7 @@ import Animated, { import { zoomToAnimation } from './animations'; import { AnimatedTouchFeedback } from './components'; +import { NonScalingOverlay } from './components/NonScalingOverlay'; import { StaticPin } from './components/StaticPin'; import { DebugTouchPoint } from './debugHelper'; import { @@ -75,6 +76,13 @@ const ReactNativeZoomableViewInner: ForwardRefRenderFunction< const [pinSize, setPinSize] = useState({ width: 0, height: 0 }); const [stateTouches, setStateTouches] = useState([]); + // `wrapperSize` is the JS mirror of `originalWidth/Height` (which are + // SharedValues). `NonScalingOverlay` needs the wrapper dimensions as + // plain numbers because they participate in its `useAnimatedStyle` math + // alongside SharedValues — capturing them as worklet-closure constants + // is cheaper than tracking another SharedValue, and the values only + // change on layout (mount + resize), not per gesture frame. + const [wrapperSize, setWrapperSize] = useState({ width: 0, height: 0 }); const { debugPoints, setDebugPoints, setPinchDebugPoints } = useDebugPoints(); @@ -158,6 +166,7 @@ const ReactNativeZoomableViewInner: ForwardRefRenderFunction< initialZoom: propsInitialZoom, zoomStep: propZoomStep, pinProps, + renderOverlay, } = props; const offsetX = useSharedValue(0); @@ -1657,7 +1666,21 @@ const ReactNativeZoomableViewInner: ForwardRefRenderFunction< // eslint-disable-next-line @typescript-eslint/no-use-before-define style={styles.container} ref={zoomSubjectWrapperRef} - onLayout={measureZoomSubject} + onLayout={(e) => { + // Preserve the original measurement path (writes SharedValues + // consumed by the gesture math). The setState below is purely + // additive — a JS-thread mirror for `NonScalingOverlay`. + measureZoomSubject(e); + const { width, height } = e.nativeEvent.layout; + // Match `measureZoomSubject`'s off-screen guard so we never + // overwrite a real measurement with 0s. + if (!width || !height) return; + setWrapperSize((prev) => + prev.width === width && prev.height === height + ? prev + : { width, height } + ); + }} > {/* GestureDetector is placed as a sibling of StaticPin (not an ancestor) so RNGH's gesture-recognizer ancestor walk treats @@ -1713,6 +1736,27 @@ const ReactNativeZoomableViewInner: ForwardRefRenderFunction< return ; })} + {renderOverlay && ( + // Mounted as a sibling of `GestureDetector` inside the wrapper + // container so the overlay and the zoom-transformed layer share + // the same coordinate frame (both children of the View measured + // by `originalWidth/Height`). Mounted ABOVE `StaticPin` so the + // pin renders on top — and BEFORE `StaticPin` in source order so + // RN paints overlay markers underneath the pin (last sibling + // renders on top in RN's z-order without explicit zIndex). + + {renderOverlay()} + + )} + {propStaticPinPosition && ( ; + /** Current pan offset X (Reanimated SharedValue, driven by the gesture). */ + offsetX: SharedValue; + /** Current pan offset Y (Reanimated SharedValue). */ + offsetY: SharedValue; + /** Optional rotation in radians (Reanimated SharedValue). When provided, the + * overlay's transform list pivots around the overlay center and pan is + * applied in the rotated frame. */ + rotation?: SharedValue; +}; + +/** + * Translate-only overlay that tracks the zoomable view's pan/zoom (and + * optional rotation) but does NOT scale its children. Children render at + * 1:1 screen size at every zoom level — no inverse-scale transform applied + * to them, which avoids the CALayer.contentsScale pixelation and the + * transform-origin clumping that the inverse-scale model exhibits. + * + * Children pattern: + * - Position with `left: '%' / top: '%'` in content-percentage space. + * - Use a fixed pt size (e.g. `width: 16, height: 16`). + * - Self-center on the anchor via `marginLeft: -size/2, marginTop: -size/2`. + * - If rotation may be active, attach a per-child counter-rotation style + * `useAnimatedStyle({ transform: [{ rotate: `${-rotation.value}rad` }] })`. + */ +export const NonScalingOverlay = ({ + children, + contentWidth, + contentHeight, + wrapperWidth, + wrapperHeight, + zoom, + offsetX, + offsetY, + rotation, +}: NonScalingOverlayProps) => { + const overlayStyle = useAnimatedStyle(() => { + const z = zoom.value; + const ox = offsetX.value; + const oy = offsetY.value; + + return { + position: 'absolute', + // Box grows with zoom so a child at `left:50%, top:50%` (content- + // percentage space) lands at the right screen pixel without any + // per-child inverse-scale; the translates below align the grown box + // with the wrapper's transformed content layer. + width: contentWidth * z, + height: contentHeight * z, + transform: rotation + ? [ + // The 5-element form is REQUIRED when rotation can be non-zero + // and cannot be collapsed to the 2-translate form. RN applies + // transforms right-to-left as matrix multiplications: + // 1) The two leading translates re-center the (z-scaled) + // overlay box on the wrapper midpoint, so the subsequent + // `rotate` pivots around the overlay's geometric center + // (matching the inner zoom layer's rotation pivot). + // 2) `rotate` then rotates the now-centered frame. + // 3) The trailing `z*ox` / `z*oy` translates run AFTER the + // rotate in source order but BEFORE it under right-to-left + // composition — applying pan in the rotated frame, which + // is what keeps the overlay aligned with the rotated + // content underneath. Folding `z*ox` into the first + // translate would apply pan in the pre-rotation frame and + // desync from the inner layer. + { translateX: wrapperWidth / 2 - (z * contentWidth) / 2 }, + { translateY: wrapperHeight / 2 - (z * contentHeight) / 2 }, + { rotate: `${rotation.value}rad` }, + { translateX: z * ox }, + { translateY: z * oy }, + ] + : [ + // Same math as above with rotation=0 collapsed into a single + // translate per axis: re-center the z-scaled box, then add the + // pan offset (scaled by z, because pan is content-space units). + { translateX: wrapperWidth / 2 - (z * contentWidth) / 2 + z * ox }, + { + translateY: wrapperHeight / 2 - (z * contentHeight) / 2 + z * oy, + }, + ], + }; + }, [contentWidth, contentHeight, wrapperWidth, wrapperHeight]); + + // Fake context with zoom=1 / offsets=0 / inverseZoom=1 so any consumer of + // `useZoomableViewContext` rendered INSIDE the overlay (e.g. `FixedSize`) + // becomes a no-op. Without this, `FixedSize` would apply its + // `inverseZoomStyle` (`scale: 1/zoom`) on top of the translate-only model + // here, double-counteracting zoom and shrinking children toward 0 at high + // zoom levels. + const unitZoom = useSharedValue(1); + const unitInverseZoom = useDerivedValue(() => 1); + const unitScale = useSharedValue(1); + const zeroOffset = useSharedValue(0); + const fakeContext = useMemo( + () => ({ + zoom: unitZoom, + inverseZoom: unitInverseZoom, + inverseZoomStyle: { transform: [{ scale: unitScale }] }, + offsetX: zeroOffset, + offsetY: zeroOffset, + }), + [unitZoom, unitInverseZoom, unitScale, zeroOffset] + ); + + // The translate math (`wrapperW/2 - z*contentW/2 + z*ox`) requires real + // dimensions; with 0s it resolves to 0 (no-rotation case) or NaN-adjacent + // intermediate values, painting the overlay at the wrong location for one + // frame before measurements arrive. + if (!contentWidth || !contentHeight) return null; + + return ( + + + {children} + + + ); +}; + +const styles = StyleSheet.create({ + overlay: { + // Markers anchored to content edges typically apply negative margins + // (`marginLeft: -size/2, marginTop: -size/2`) to self-center on the + // anchor point — they extend past the overlay's bounding box. iOS + // defaults to clipping subviews to their parent's bounds, so without + // `visible` here those edge markers disappear at high zoom levels. + overflow: 'visible', + }, +}); diff --git a/src/index.tsx b/src/index.tsx index 8d825851..f6fb2bd1 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,4 +1,6 @@ import FixedSize from './components/FixedSize'; +import type { NonScalingOverlayProps } from './components/NonScalingOverlay'; +import { NonScalingOverlay } from './components/NonScalingOverlay'; import { applyContainResizeMode, getImageOriginOnTransformSubject, @@ -19,6 +21,8 @@ export { applyContainResizeMode, FixedSize, getImageOriginOnTransformSubject, + NonScalingOverlay, + NonScalingOverlayProps, ReactNativeZoomableView, ReactNativeZoomableViewProps, ReactNativeZoomableViewRef, diff --git a/src/typings/index.ts b/src/typings/index.ts index 9385d97a..f0ea9942 100644 --- a/src/typings/index.ts +++ b/src/typings/index.ts @@ -96,6 +96,23 @@ export interface ReactNativeZoomableViewProps { ) => void; staticPinPosition?: Vec2D; staticPinIcon?: React.ReactElement; + /** + * Optional render-prop that returns markers to display in a non-scaling + * overlay layered above the zoomed content. The returned tree is mounted + * inside a `NonScalingOverlay` (see `components/NonScalingOverlay.tsx`) + * as a sibling of the zoom-transformed layer — children translate with + * pan/zoom but render at 1:1 screen size at every zoom level. + * + * Children should position themselves with `left: 'X%' / top: 'Y%'` in + * content-percentage space, use fixed pt dimensions, and self-center via + * negative `marginLeft` / `marginTop`. The overlay is `pointerEvents="none"`. + * + * Pair this with `contentWidth` / `contentHeight` props so the overlay + * knows the intrinsic content dimensions to scale percentage positions + * against. With `contentWidth` / `contentHeight` unset, the overlay + * renders nothing. + */ + renderOverlay?: () => ReactNode; /** * Called on the JS thread once the static pin position has settled * (~100ms after the last motion). Use for state updates that should not From b65a98696a193b16aeca4a6f1c4153f4e89f0006 Mon Sep 17 00:00:00 2001 From: thomasvo Date: Sun, 10 May 2026 15:52:04 -0700 Subject: [PATCH 02/29] chore: untrack example/ios prebuild artifact (app.json is source of truth) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit example/ios/ is regenerated end-to-end by `expo prebuild` from example/app.json. Tracking the prebuilt Info.plist (added in the previous commit alongside the NonScalingOverlay feature) was a mistake — the .plist will be re-emitted from `expo.ios.infoPlist.NSLocalNetworkUsageDescription` on the next prebuild, so committing it creates churn and lets stale artifacts mask config drift. Anything that must persist across prebuilds belongs in app.json's expo.ios section, which is what we already landed. - Remove example/ios/.../Info.plist from the index (file kept on disk so the current Metro session stays working). - Add `example/ios/` to .gitignore so future contributions don't accidentally re-commit any prebuild output. --- .gitignore | 7 ++ .../Info.plist | 78 ------------------- 2 files changed, 7 insertions(+), 78 deletions(-) delete mode 100644 example/ios/openspacelabsreactnativezoomableviewexample/Info.plist diff --git a/.gitignore b/.gitignore index e599a9d8..f50c76bb 100644 --- a/.gitignore +++ b/.gitignore @@ -40,6 +40,13 @@ android.iml # example/ios/Pods +# Expo prebuild output (example app) — `expo prebuild` regenerates the +# entire iOS project from `example/app.json`. Tracking these files would +# create merge churn on every prebuild and let stale artifacts mask +# config drift. Anything that must persist across prebuilds goes in +# `example/app.json` under `expo.ios.*` and gets re-emitted. +example/ios/ + # node.js # node_modules/ diff --git a/example/ios/openspacelabsreactnativezoomableviewexample/Info.plist b/example/ios/openspacelabsreactnativezoomableviewexample/Info.plist deleted file mode 100644 index 6347ffde..00000000 --- a/example/ios/openspacelabsreactnativezoomableviewexample/Info.plist +++ /dev/null @@ -1,78 +0,0 @@ - - - - - CADisableMinimumFrameDurationOnPhone - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleDisplayName - @openspacelabs/react-native-zoomable-view-example - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - $(PRODUCT_NAME) - CFBundlePackageType - $(PRODUCT_BUNDLE_PACKAGE_TYPE) - CFBundleShortVersionString - 2.0.1 - CFBundleSignature - ???? - CFBundleURLTypes - - - CFBundleURLSchemes - - com.anonymous.openspacelabs-react-native-zoomable-view-example - - - - CFBundleVersion - 1 - LSMinimumSystemVersion - 12.0 - LSRequiresIPhoneOS - - NSAppTransportSecurity - - NSAllowsArbitraryLoads - - NSAllowsLocalNetworking - - - NSLocalNetworkUsageDescription - Used to connect to the Metro development server during local development. - RCTNewArchEnabled - - UILaunchStoryboardName - SplashScreen - UIRequiredDeviceCapabilities - - arm64 - - UIRequiresFullScreen - - UIStatusBarStyle - UIStatusBarStyleDefault - UISupportedInterfaceOrientations - - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown - - UISupportedInterfaceOrientations~ipad - - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UIUserInterfaceStyle - Light - UIViewControllerBasedStatusBarAppearance - - - \ No newline at end of file From d0752756aa1cf136064c7f37bef182993e55522a Mon Sep 17 00:00:00 2001 From: thomasvo Date: Sun, 10 May 2026 15:55:53 -0700 Subject: [PATCH 03/29] =?UTF-8?q?revert:=20drop=20example/ios/=20gitignore?= =?UTF-8?q?=20=E2=80=94=20native=20edits=20must=20remain=20trackable?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second of a 3-commit sequence: 1. feat: NonScalingOverlay + iOS Local Network privacy fix (abbeddf — added NSLocalNetworkUsageDescription in both app.json and the prebuilt Info.plist). 2. chore: untrack prebuild Info.plist + add example/ios/ to .gitignore (a48cf44 — overreach: the gitignore add was unwarranted). 3. THIS commit — revert only the gitignore add. `example/ios/` was previously untracked (never committed), NOT .gitignored. Adding a blanket `example/ios/` ignore in commit `a48cf44` would have silently swallowed legitimate future native edits — Podfile, AppDelegate, native modules, build settings — that contributors expect to commit. Those edits don't come from `expo prebuild` and shouldn't be hidden by gitignore. What stays from a48cf44 (correct): - `example/ios/.../Info.plist` remains removed from the index. app.json's `expo.ios.infoPlist.NSLocalNetworkUsageDescription` is the source of truth for the one key we needed; the .plist is regenerated by `expo prebuild`. What this revert removes (incorrect from a48cf44): - The `example/ios/` block in .gitignore — the existing `example/ios/Pods` entry is preserved (Pods are genuinely generated by `pod install` and shouldn't be tracked). --- .gitignore | 7 ------- 1 file changed, 7 deletions(-) diff --git a/.gitignore b/.gitignore index f50c76bb..e599a9d8 100644 --- a/.gitignore +++ b/.gitignore @@ -40,13 +40,6 @@ android.iml # example/ios/Pods -# Expo prebuild output (example app) — `expo prebuild` regenerates the -# entire iOS project from `example/app.json`. Tracking these files would -# create merge churn on every prebuild and let stale artifacts mask -# config drift. Anything that must persist across prebuilds goes in -# `example/app.json` under `expo.ios.*` and gets re-emitted. -example/ios/ - # node.js # node_modules/ From 86039bdcc0bb278be1bacd20c2460eb239b59155 Mon Sep 17 00:00:00 2001 From: thomasvo Date: Sun, 10 May 2026 15:57:24 -0700 Subject: [PATCH 04/29] fix: track Info.plist as source instead of app.json prebuild config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This project uses the bare iOS workflow — `example/ios/` is committed source, not regenerated by `expo prebuild`. That makes the `expo.ios.infoPlist.*` block in `app.json` inert: it only takes effect during prebuild, which we don't run. The actual `Info.plist` is the source of truth, so we should track it directly. Changes vs the previous two commits in this sequence (`abbeddf` landed both, `a48cf44` removed the .plist): - Revert the `expo.ios.infoPlist` block in `example/app.json` — it was added on the (incorrect) assumption that prebuild would regenerate the .plist. - Re-add `example/ios/.../Info.plist` to the tracked set with `NSLocalNetworkUsageDescription` present. Without this key, iOS 14+ blocks every Metro probe at the `nw_path` layer (`unsatisfied (Local network prohibited)`), `RCTBundleURLProvider` returns `nil`, and the example shows the "No script URL provided" red screen on physical devices. Commit sequence on this branch: 1. `abbeddf`: feat — NonScalingOverlay + initial Info.plist + redundant app.json infoPlist. 2. `a48cf44`: chore — untrack Info.plist + overreaching gitignore add. 3. `1562181`: revert — drop the gitignore add only. 4. THIS commit — drop app.json prebuild config; track Info.plist as source. --- example/app.json | 5 +- .../Info.plist | 78 +++++++++++++++++++ 2 files changed, 79 insertions(+), 4 deletions(-) create mode 100644 example/ios/openspacelabsreactnativezoomableviewexample/Info.plist diff --git a/example/app.json b/example/app.json index 0e5a42c0..47fe619c 100644 --- a/example/app.json +++ b/example/app.json @@ -18,10 +18,7 @@ "**/*" ], "ios": { - "supportsTablet": true, - "infoPlist": { - "NSLocalNetworkUsageDescription": "Used to connect to the Metro development server during local development." - } + "supportsTablet": true }, "android": { "adaptiveIcon": { diff --git a/example/ios/openspacelabsreactnativezoomableviewexample/Info.plist b/example/ios/openspacelabsreactnativezoomableviewexample/Info.plist new file mode 100644 index 00000000..6347ffde --- /dev/null +++ b/example/ios/openspacelabsreactnativezoomableviewexample/Info.plist @@ -0,0 +1,78 @@ + + + + + CADisableMinimumFrameDurationOnPhone + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + @openspacelabs/react-native-zoomable-view-example + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 2.0.1 + CFBundleSignature + ???? + CFBundleURLTypes + + + CFBundleURLSchemes + + com.anonymous.openspacelabs-react-native-zoomable-view-example + + + + CFBundleVersion + 1 + LSMinimumSystemVersion + 12.0 + LSRequiresIPhoneOS + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + NSAllowsLocalNetworking + + + NSLocalNetworkUsageDescription + Used to connect to the Metro development server during local development. + RCTNewArchEnabled + + UILaunchStoryboardName + SplashScreen + UIRequiredDeviceCapabilities + + arm64 + + UIRequiresFullScreen + + UIStatusBarStyle + UIStatusBarStyleDefault + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UIUserInterfaceStyle + Light + UIViewControllerBasedStatusBarAppearance + + + \ No newline at end of file From 30125ffcc2c95c024cbb1f5f29bfca5bfd327d1f Mon Sep 17 00:00:00 2001 From: thomasvo Date: Sun, 10 May 2026 16:43:45 -0700 Subject: [PATCH 05/29] chore: replace dead placekitten.com with picsum.photos MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `placekitten.com` returns DNS NXDOMAIN — it has been offline for an extended period and the example app renders the kitten URL into a white box, leaving `applyContainResizeMode` with no visible content to fit the 4x4 marker grid against. `picsum.photos` (Lorem Picsum) is the reliable drop-in: same `//` URL shape, CDN-backed via Cloudflare, HTTPS with TLSv1.3. Loses the cat theme — `placecats.com` is the cat-themed alternative and was tried first — but `placecats.com` would not render visibly on the iPhone 16 Pro during verification (image element stayed invisible across multiple kill+launch cycles), whereas `picsum.photos` renders a fresh photo behind the dots immediately on reload. Reliability wins for an example app meant to render the image on every fresh install. --- example/App.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/example/App.tsx b/example/App.tsx index 4843722d..ad1b9033 100644 --- a/example/App.tsx +++ b/example/App.tsx @@ -30,7 +30,13 @@ import { applyContainResizeMode } from '../src/helper/coordinateConversion'; import { styles } from './style'; const kittenSize = 800; -const uri = `https://placekitten.com/${kittenSize}/${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 imageSize = { width: kittenSize, height: kittenSize }; const stringifyPoint = (point?: { x: number; y: number }) => From a760053c63a147f170251fd2151e7dab6b30193e Mon Sep 17 00:00:00 2001 From: thomasvo Date: Sun, 10 May 2026 16:46:23 -0700 Subject: [PATCH 06/29] =?UTF-8?q?refactor:=20unify=20NonScalingOverlay=20t?= =?UTF-8?q?ransform=20=E2=80=94=20single=205-element=20list,=20currentRota?= =?UTF-8?q?tion=3D0=20default?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 5-element list `[translate-center, translate-center, rotate, translate-pan, translate-pan]` with rotation=0 reduces to the 2-element combined-translate form (identity rotation contributes nothing under matrix composition), so a single code path covers both cases. Removes the duplicated math in the ternary branches and the long matrix-composition tutorial that was inline. Renames the worklet locals from `z`/`ox`/`oy` to `currentZoom`/`currentOffsetX`/`currentOffsetY`/`currentRotation` so the math at the call sites reads in full words. No behavioral change. Sanity-screenshot confirms the 4x4 marker grid still renders correctly over the example app's image. --- src/components/NonScalingOverlay.tsx | 61 +++++++++------------------- 1 file changed, 20 insertions(+), 41 deletions(-) diff --git a/src/components/NonScalingOverlay.tsx b/src/components/NonScalingOverlay.tsx index 6a445919..6d6c8c11 100644 --- a/src/components/NonScalingOverlay.tsx +++ b/src/components/NonScalingOverlay.tsx @@ -58,50 +58,29 @@ export const NonScalingOverlay = ({ rotation, }: NonScalingOverlayProps) => { const overlayStyle = useAnimatedStyle(() => { - const z = zoom.value; - const ox = offsetX.value; - const oy = offsetY.value; + const currentZoom = zoom.value; + const currentOffsetX = offsetX.value; + const currentOffsetY = offsetY.value; + const currentRotation = rotation ? rotation.value : 0; + // Transform composes right-to-left: re-center the z-scaled box on + // the wrapper midpoint, rotate around that center, then translate + // by the pan IN THE ROTATED FRAME (post-rotate). Folding pan into + // the centering translate would apply it pre-rotation and desync + // from the rotated content underneath. When rotation is absent, + // currentRotation=0 collapses the rotate to identity and the + // 5-element list reduces to a single combined translate per axis. return { position: 'absolute', - // Box grows with zoom so a child at `left:50%, top:50%` (content- - // percentage space) lands at the right screen pixel without any - // per-child inverse-scale; the translates below align the grown box - // with the wrapper's transformed content layer. - width: contentWidth * z, - height: contentHeight * z, - transform: rotation - ? [ - // The 5-element form is REQUIRED when rotation can be non-zero - // and cannot be collapsed to the 2-translate form. RN applies - // transforms right-to-left as matrix multiplications: - // 1) The two leading translates re-center the (z-scaled) - // overlay box on the wrapper midpoint, so the subsequent - // `rotate` pivots around the overlay's geometric center - // (matching the inner zoom layer's rotation pivot). - // 2) `rotate` then rotates the now-centered frame. - // 3) The trailing `z*ox` / `z*oy` translates run AFTER the - // rotate in source order but BEFORE it under right-to-left - // composition — applying pan in the rotated frame, which - // is what keeps the overlay aligned with the rotated - // content underneath. Folding `z*ox` into the first - // translate would apply pan in the pre-rotation frame and - // desync from the inner layer. - { translateX: wrapperWidth / 2 - (z * contentWidth) / 2 }, - { translateY: wrapperHeight / 2 - (z * contentHeight) / 2 }, - { rotate: `${rotation.value}rad` }, - { translateX: z * ox }, - { translateY: z * oy }, - ] - : [ - // Same math as above with rotation=0 collapsed into a single - // translate per axis: re-center the z-scaled box, then add the - // pan offset (scaled by z, because pan is content-space units). - { translateX: wrapperWidth / 2 - (z * contentWidth) / 2 + z * ox }, - { - translateY: wrapperHeight / 2 - (z * contentHeight) / 2 + z * oy, - }, - ], + width: contentWidth * currentZoom, + height: contentHeight * currentZoom, + transform: [ + { translateX: wrapperWidth / 2 - (currentZoom * contentWidth) / 2 }, + { translateY: wrapperHeight / 2 - (currentZoom * contentHeight) / 2 }, + { rotate: `${currentRotation}rad` }, + { translateX: currentZoom * currentOffsetX }, + { translateY: currentZoom * currentOffsetY }, + ], }; }, [contentWidth, contentHeight, wrapperWidth, wrapperHeight]); From 830d98e208aa198624b1c3f6009b3793a8009387 Mon Sep 17 00:00:00 2001 From: thomasvo Date: Sun, 10 May 2026 16:58:29 -0700 Subject: [PATCH 07/29] refactor: remove FixedSize, replaced by NonScalingOverlay translate-only model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `FixedSize` used the inverse-scale model: an outer scale transform on the zoom subject + per-child `scale: 1/zoom` to keep markers at constant visual size. On iOS, the per-child inverse-scale reduces CALayer.contentsScale and causes visible pixelation at high zoom. It also pulls markers toward the transform origin (clumping) because every child shares the same scale anchor. `NonScalingOverlay` (added in `abbeddf`) supersedes it via the translate-only model: a single overlay box absorbs zoom/pan in its own transform, children render at 1:1 screen size, no per-child inverse-scale, no CALayer pixelation, no clumping. The lib's `renderOverlay` prop wraps it for the common case. Changes - Delete `src/components/FixedSize.tsx`. - Drop `FixedSize` from `src/index.tsx` public exports. - Refresh the `NonScalingOverlay` fake-context WHY-comment so it no longer name-drops `FixedSize` — the rationale (neutralize any nested consumer of `useZoomableViewContext` that would multiply the outer `inverseZoomStyle` on top of the translate-only model) applies to any consumer, not just the deleted primitive. - SPECS.md: replace the `FixedSize` entry under New API and New public exports with the `NonScalingOverlay` description. Potential follow-up (NOT in this commit) - `ZoomableViewContextValue.inverseZoom` and `inverseZoomStyle` were effectively only consumed by `FixedSize`. They remain on the context so external consumers depending on those fields don't break silently. Deciding to remove them is a separate breaking change and should be evaluated against downstream callers. --- SPECS.md | 4 +-- src/components/FixedSize.tsx | 47 ---------------------------- src/components/NonScalingOverlay.tsx | 10 +++--- src/index.tsx | 2 -- 4 files changed, 7 insertions(+), 56 deletions(-) delete mode 100644 src/components/FixedSize.tsx diff --git a/SPECS.md b/SPECS.md index b4cb04ff..3859fb98 100644 --- a/SPECS.md +++ b/SPECS.md @@ -39,7 +39,7 @@ Exported from `src/index.tsx`: - `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 +- `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/src/components/FixedSize.tsx b/src/components/FixedSize.tsx deleted file mode 100644 index 61b11687..00000000 --- a/src/components/FixedSize.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import React from 'react'; -import Animated from 'react-native-reanimated'; - -import { useZoomableViewContext } from '../ReactNativeZoomableViewContext'; -/** - * A wrapper component that keeps elements at a fixed visual size regardless of zoom level. - * - * @param {{ - * left: number; - * top: number; - * children: React.ReactNode; - * }} param0 - * @param {number} param0.left The left position in percentage (0-100) - * @param {number} param0.top The top position in percentage (0-100) - * @param {React.ReactNode} param0.children The children to render inside the fixed size container - * @returns {*} - */ -export const FixedSize = ({ - left, - top, - children, -}: { - left: number; - top: number; - children: React.ReactNode; -}) => { - const context = useZoomableViewContext(); - - return ( - - {children} - - ); -}; - -export default FixedSize; diff --git a/src/components/NonScalingOverlay.tsx b/src/components/NonScalingOverlay.tsx index 6d6c8c11..1e4c75a8 100644 --- a/src/components/NonScalingOverlay.tsx +++ b/src/components/NonScalingOverlay.tsx @@ -85,11 +85,11 @@ export const NonScalingOverlay = ({ }, [contentWidth, contentHeight, wrapperWidth, wrapperHeight]); // Fake context with zoom=1 / offsets=0 / inverseZoom=1 so any consumer of - // `useZoomableViewContext` rendered INSIDE the overlay (e.g. `FixedSize`) - // becomes a no-op. Without this, `FixedSize` would apply its - // `inverseZoomStyle` (`scale: 1/zoom`) on top of the translate-only model - // here, double-counteracting zoom and shrinking children toward 0 at high - // zoom levels. + // `useZoomableViewContext` rendered INSIDE the overlay becomes a no-op. + // Without this, a nested consumer that applies the outer context's + // `inverseZoomStyle` (`scale: 1/zoom`) would multiply on top of the + // translate-only model here, double-counteracting zoom and shrinking + // children toward 0 at high zoom levels. const unitZoom = useSharedValue(1); const unitInverseZoom = useDerivedValue(() => 1); const unitScale = useSharedValue(1); diff --git a/src/index.tsx b/src/index.tsx index f6fb2bd1..0731082f 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,4 +1,3 @@ -import FixedSize from './components/FixedSize'; import type { NonScalingOverlayProps } from './components/NonScalingOverlay'; import { NonScalingOverlay } from './components/NonScalingOverlay'; import { @@ -19,7 +18,6 @@ import type { export { // Helper functions for coordinate conversion applyContainResizeMode, - FixedSize, getImageOriginOnTransformSubject, NonScalingOverlay, NonScalingOverlayProps, From 1f293920c266c676cd56b5c3e2c278df2fac1182 Mon Sep 17 00:00:00 2001 From: thomasvo Date: Sun, 10 May 2026 22:38:47 -0700 Subject: [PATCH 08/29] fix(NonScalingOverlay): anchor at top:0/left:0 to defeat Yoga centering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: the lib's wrapper container uses `alignItems: 'center', justifyContent: 'center'`. Unlike standard CSS, RN/Yoga positions a `position: 'absolute'` child via the parent's `alignItems`/`justifyContent` whenever the child has a measurable size. `useAnimatedStyle` set `width: contentW * z, height: contentH * z`, which Yoga DID see, so Yoga centered the (340x340) overlay inside the (340x590) wrapper at wrapper-y=125. The transform's `translateY = wrapperH/2 - z*contentH/2 = 125` then added another 125pt, painting the overlay at wrapper-y=250 instead of 125 — every dot landed below where it should have. On iPhone 12 Pro this surfaced as the bottom row of dots rendering in the white letterbox below the image. Forcing `top: 0, left: 0` overrides the parent's alignment so the transform's translates are the SOLE source of position. Verified on iPhone 12 Pro / Expo Go SDK 54: at z=1 the overlay precisely overlaps the rendered image; pinch-in fans dots outward symmetrically around image center; pan moves dots together with image content. Also collapses the `transform: rotation ? [5] : [2]` ternary into a single 5-element list with a constant-zero `SharedValue` default. The no-rotation case is mathematically the same product with `rotate(0) = I`; the conditional was redundant. Removed the in-progress `runOnJS`/`react-native-worklets` console-log diagnostic that was no longer needed once the root cause was identified. example/style.ts: replace `box.width: 480` with `Dimensions.get('window').width - 40`. The fixed 480pt overflows the 390pt iPhone 12 Pro viewport. `width: '100%'` doesn't work because the nested container chain uses `alignItems: 'center'`, leaving the parent's cross-axis intrinsic-sized — '100%' resolves to 0. example/App.tsx + style.ts: add an \`overlayDebugBox\` View as a child of \`renderOverlay\` (100% × 100% magenta-bordered fill) so the overlay's bounding box is visible at every zoom level. Keeps debug visualization in example code only — library code stays clean. Co-Authored-By: Claude Opus 4.7 --- example/App.tsx | 66 ++++++++++- example/package.json | 4 +- example/style.ts | 29 ++++- example/yarn.lock | 167 ++++++++++++++++++++++++++- src/components/NonScalingOverlay.tsx | 98 ++++++++++------ 5 files changed, 319 insertions(+), 45 deletions(-) diff --git a/example/App.tsx b/example/App.tsx index ad1b9033..0b8e73b8 100644 --- a/example/App.tsx +++ b/example/App.tsx @@ -37,7 +37,6 @@ const kittenSize = 800; // example app that needs to actually render the image on every fresh // install. const uri = `https://picsum.photos/${kittenSize}/${kittenSize}`; -const imageSize = { width: kittenSize, height: kittenSize }; const stringifyPoint = (point?: { x: number; y: number }) => point ? `${Math.round(point.x)}, ${Math.round(point.y)}` : 'Off map'; @@ -214,7 +213,34 @@ export default function App() { }); const staticPinPosition = { x: size.width / 2, y: size.height / 2 }; - const { size: contentSize } = applyContainResizeMode(imageSize, size); + + // Measure the 's actual rendered frame (post resizeMode:contain + // fit) to drive `NonScalingOverlay` placement. The element's + // onLayout reports its ELEMENT box (fills the wrapper minus border), but + // resizeMode:contain letterboxes the pixels at the source aspect inside + // that box. We must pass the rendered-pixel frame (not the element box) + // so dots at 20%/40%/60%/80% land on the image, not on letterbox. + const [imageElementBox, setImageElementBox] = useState<{ + width: number; + height: number; + }>({ width: 0, height: 0 }); + // Source dims are known from the URL but capture via onLoad in case + // picsum returns a different size than requested. + const [imageSourceSize, setImageSourceSize] = useState<{ + width: number; + height: number; + }>({ width: kittenSize, height: kittenSize }); + const contentSize = useMemo(() => { + if (!imageElementBox.width || !imageElementBox.height) { + return { width: 0, height: 0 }; + } + return ( + applyContainResizeMode(imageSourceSize, imageElementBox).size ?? { + width: 0, + height: 0, + } + ); + }, [imageElementBox, imageSourceSize]); const Wrapper = modal ? PageSheetModal : View; @@ -255,12 +281,18 @@ 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 ? () => ( <> + {/* 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. */} + {[20, 40, 60, 80].map((left) => [20, 40, 60, 80].map((top) => ( - + { + const { width, height } = e.nativeEvent.layout; + setImageElementBox((prev) => + prev.width === width && prev.height === height + ? prev + : { width, height } + ); + }} + onLoad={(e) => { + 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)}