diff --git a/apps/simple-camera/__tests__/__image_snapshots__/android/spacer-region-stays-red.png b/apps/simple-camera/__tests__/__image_snapshots__/android/spacer-region-stays-red.png new file mode 100644 index 0000000000..932c0b8ebe Binary files /dev/null and b/apps/simple-camera/__tests__/__image_snapshots__/android/spacer-region-stays-red.png differ diff --git a/apps/simple-camera/__tests__/visioncamera.camera-view.harness.tsx b/apps/simple-camera/__tests__/visioncamera.camera-view.harness.tsx index 6c238adfcf..b8d932722a 100644 --- a/apps/simple-camera/__tests__/visioncamera.camera-view.harness.tsx +++ b/apps/simple-camera/__tests__/visioncamera.camera-view.harness.tsx @@ -1,5 +1,12 @@ +import { screen } from '@react-native-harness/ui' import { createRef } from 'react' -import { type LayoutChangeEvent, StyleSheet } from 'react-native' +import { + type LayoutChangeEvent, + PixelRatio, + Platform, + StyleSheet, + View, +} from 'react-native' import { afterEach, beforeAll, @@ -55,6 +62,8 @@ function expectPreviewGeometry(camera: CameraRef, layout: Layout) { expect(meteringPoint.normalizedY).toBeLessThanOrEqual(1) } +const SPACER_HEIGHT = 200 + describe('VisionCamera - Camera View', () => { let backDevice: CameraDevice @@ -72,6 +81,80 @@ describe('VisionCamera - Camera View', () => { cleanup() }) + it('preserves preview position when laid out below a sibling spacer (issue #3897)', async (context) => { + if (Platform.OS !== 'android') { + return context.skip( + 'Preview spacer-positioned layout: Android-only regression', + ) + } + + const started = deferred() + const previewStarted = deferred() + let sessionError: Error | undefined + const onError = (error: Error) => { + sessionError = error + started.reject(error) + previewStarted.reject(error) + } + + await render( + + + + , + ) + + await withTimeout(started.promise, 15_000, 'spacer Camera onStarted') + await withTimeout( + previewStarted.promise, + 15_000, + 'spacer Camera onPreviewStarted', + ) + expect(sessionError).toBe(undefined) + + // Yield two frames so the fitter's layout commit reaches the render + // pipeline before we snapshot. Matches the harness's own + // waitForNativeViewHierarchy pattern. + await new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(() => resolve())), + ) + + const png = await screen.screenshot() + if (png == null) throw new Error('screen.screenshot returned null') + // ScreenshotResult.width/height come back as 0, so parse the PNG IHDR + // (width @ byte 16, height @ byte 20, big-endian). + const dv = new DataView( + png.data.buffer, + png.data.byteOffset, + png.data.byteLength, + ) + const fileW = dv.getUint32(16, false) + const fileH = dv.getUint32(20, false) + + // Compare only the spacer's pixel band; the rest is live camera feed + // that legitimately varies between runs. + const spacerPx = SPACER_HEIGHT * PixelRatio.get() + await expect({ + data: png.data, + width: fileW, + height: fileH, + }).toMatchImageSnapshot({ + name: 'spacer-region-stays-red', + failureThresholdType: 'percent', + failureThreshold: 0.02, + ignoreRegions: [ + { x: 0, y: spacerPx, width: fileW, height: fileH - spacerPx }, + ], + }) + }) + it('starts the high-level Camera preview and exposes preview/controller ref methods', async () => { const cameraRef = createRef() const layout = deferred() @@ -492,3 +575,17 @@ describe('VisionCamera - Camera View', () => { expect(sessionError).toBe(undefined) }) }) + +const styles = StyleSheet.create({ + spacerRoot: { + flex: 1, + backgroundColor: 'black', + }, + redSpacer: { + height: SPACER_HEIGHT, + backgroundColor: 'red', + }, + spacerCamera: { + flex: 1, + }, +}) diff --git a/apps/simple-camera/ios/Podfile.lock b/apps/simple-camera/ios/Podfile.lock index 727225291f..6a7b6c6638 100644 --- a/apps/simple-camera/ios/Podfile.lock +++ b/apps/simple-camera/ios/Podfile.lock @@ -23,6 +23,28 @@ PODS: - GoogleUtilities/Logger - GoogleUtilities/Privacy - GTMSessionFetcher/Core (3.5.0) + - HarnessUI (1.3.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga - hermes-engine (250829098.0.10): - hermes-engine/Pre-built (= 250829098.0.10) - hermes-engine/Pre-built (250829098.0.10) @@ -2454,6 +2476,7 @@ PODS: DEPENDENCIES: - FBLazyVector (from `../../../node_modules/react-native/Libraries/FBLazyVector`) + - "HarnessUI (from `../../../node_modules/@react-native-harness/ui`)" - hermes-engine (from `../../../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec`) - NitroImage (from `../../../node_modules/react-native-nitro-image`) - NitroModules (from `../../../node_modules/react-native-nitro-modules`) @@ -2564,6 +2587,8 @@ SPEC REPOS: EXTERNAL SOURCES: FBLazyVector: :path: "../../../node_modules/react-native/Libraries/FBLazyVector" + HarnessUI: + :path: "../../../node_modules/@react-native-harness/ui" hermes-engine: :podspec: "../../../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec" :tag: hermes-v250829098.0.10 @@ -2755,7 +2780,8 @@ SPEC CHECKSUMS: GoogleToolboxForMac: d1a2cbf009c453f4d6ded37c105e2f67a32206d8 GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1 GTMSessionFetcher: 5aea5ba6bd522a239e236100971f10cb71b96ab6 - hermes-engine: 691752261227b9de03faf08561f47f2e2b5b52e9 + HarnessUI: f439596aec93ff76765451f834c93f966d54e53e + hermes-engine: e0e7de0611a16bc91b9cc6f8ac5a1c84600c06c5 MLImage: 0de5c6c2bf9e93b80ef752e2797f0836f03b58c0 MLKitBarcodeScanning: 39de223e7b1b8a8fbf10816a536dd292d8a39343 MLKitCommon: 47d47b50a031d00db62f1b0efe5a1d8b09a3b2e6 @@ -2772,7 +2798,7 @@ SPEC CHECKSUMS: React: e2dc35338068bbd299c66f043ae0d7f25de8499e React-callinvoker: 28b25d21b124c26cebaea713ba7d801b9351dc48 React-Core: 02ed7d2ffb70437bdf2aba074a13078a7b0b9ff0 - React-Core-prebuilt: 0e292a9b21c7ff7a2d50d7db3141213b15222a2a + React-Core-prebuilt: b96a5ee807b097c569912aa525f09e673e2a6f6f React-CoreModules: b3a5a42dadcde3b5d47b325bd912eb2ced89e146 React-cxxreact: fe8f88dda044e5905e99a00f41b7a874c3908716 React-debug: 92944dc4d89f56d640e75498266cbde557a48189 @@ -2840,7 +2866,7 @@ SPEC CHECKSUMS: ReactAppDependencyProvider: 25c9c516839be2c5e3d3344f95dc7da5f7e63fc2 ReactCodegen: dfe41c3f92bdf782bcf9b2c9d7c12cb9cfd82cb7 ReactCommon: 7dfc3250793bf36cf221096ff59e1179e13eef7f - ReactNativeDependencies: 2eb4b828d36504e50dff4d7a9c3808068bbd4713 + ReactNativeDependencies: 98fd3ddae59aabdb73ee38b20b6babf126044243 RNGestureHandler: ed28fea435eee1ea629ed8372c2e98138a73a472 RNReanimated: 0f1a1b11eddb9a66ebf1c1a70d0cdd230f4bb10f RNScreens: 991cc417cd396602a6cf59a42139e5a9d91462a9 diff --git a/apps/simple-camera/package.json b/apps/simple-camera/package.json index 34ef1f08ec..a27e5091ef 100644 --- a/apps/simple-camera/package.json +++ b/apps/simple-camera/package.json @@ -49,6 +49,7 @@ "@react-native-community/cli-platform-ios": "20.1.3", "@react-native-harness/platform-android": "1.4.0-rc.1", "@react-native-harness/platform-apple": "1.4.0-rc.1", + "@react-native-harness/ui": "1.4.0-rc.1", "@react-native/babel-preset": "0.85.3", "@react-native/metro-config": "0.85.3", "@react-native/typescript-config": "0.85.3", diff --git a/bun.lock b/bun.lock index a51e5348f0..1dce53a58c 100644 --- a/bun.lock +++ b/bun.lock @@ -56,6 +56,7 @@ "@react-native-community/cli-platform-ios": "20.1.3", "@react-native-harness/platform-android": "1.4.0-rc.1", "@react-native-harness/platform-apple": "1.4.0-rc.1", + "@react-native-harness/ui": "1.4.0-rc.1", "@react-native/babel-preset": "0.85.3", "@react-native/metro-config": "0.85.3", "@react-native/typescript-config": "0.85.3", @@ -938,6 +939,8 @@ "@react-native-harness/tools": ["@react-native-harness/tools@1.4.0-rc.1", "", { "dependencies": { "@clack/prompts": "1.0.0-alpha.9", "nano-spawn": "^1.0.2", "picocolors": "^1.1.1", "tslib": "^2.3.0" }, "peerDependencies": { "react-native": "*" } }, "sha512-A/Zj865TCX2XDIiLwdkxNFEwsjbnvVjJ5vjShu0Y2wpFAlFy6d1epACfQ9EDqhJi+1v3mUavZ6vhkqWEUuoUUA=="], + "@react-native-harness/ui": ["@react-native-harness/ui@1.4.0-rc.1", "", { "dependencies": { "@react-native-harness/runtime": "1.4.0-rc.1", "tslib": "^2.3.0" }, "peerDependencies": { "react-native": "*" } }, "sha512-BoggdQuPeyg6zoc3kYq8fRtsvoNw4/wpKIRSlhCdFwzKslGOr9KFCZlxZu13JdmeByUIAseGb3aAJfPlOxEAjw=="], + "@react-native-menu/menu": ["@react-native-menu/menu@2.0.0", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-hb8Mirw6aKPGONhgo52IiNpwHtISVrgCT3rMdFX1qS7eOFNzOcQh8d2UDnaH5zVpxN+QuvWtaaiRMGFpIjzdtA=="], "@react-native-vector-icons/common": ["@react-native-vector-icons/common@13.0.1", "", { "dependencies": { "find-up": "^8.0.0", "picocolors": "^1.1.1", "plist": "^3.1.0" }, "peerDependencies": { "@react-native-vector-icons/get-image": "^13.0.0", "@react-native/assets-registry": "*", "expo-font": "*", "react": "*", "react-native": "*" }, "optionalPeers": ["@react-native-vector-icons/get-image", "@react-native/assets-registry", "expo-font"], "bin": { "rnvi-update-plist": "lib/commonjs/scripts/updatePlist.js" } }, "sha512-UPC6L3tW5rXCjBn4kgw9RPURUILIg8tFpEY2uaYwU8aCjEHkywNCMcAO8+PvMCDkR6aICPeHYA0OXvMgrjsF4g=="],