1+ import { screen } from '@react-native-harness/ui'
12import { createRef } from 'react'
2- import { type LayoutChangeEvent , StyleSheet } from 'react-native'
3+ import {
4+ type LayoutChangeEvent ,
5+ PixelRatio ,
6+ Platform ,
7+ StyleSheet ,
8+ View ,
9+ } from 'react-native'
310import {
411 afterEach ,
512 beforeAll ,
@@ -55,6 +62,8 @@ function expectPreviewGeometry(camera: CameraRef, layout: Layout) {
5562 expect ( meteringPoint . normalizedY ) . toBeLessThanOrEqual ( 1 )
5663}
5764
65+ const SPACER_HEIGHT = 200
66+
5867describe ( 'VisionCamera - Camera View' , ( ) => {
5968 let backDevice : CameraDevice
6069
@@ -72,6 +81,80 @@ describe('VisionCamera - Camera View', () => {
7281 cleanup ( )
7382 } )
7483
84+ it ( 'preserves preview position when laid out below a sibling spacer (issue #3897)' , async ( context ) => {
85+ if ( Platform . OS !== 'android' ) {
86+ return context . skip (
87+ 'Preview spacer-positioned layout: Android-only regression' ,
88+ )
89+ }
90+
91+ const started = deferred ( )
92+ const previewStarted = deferred ( )
93+ let sessionError : Error | undefined
94+ const onError = ( error : Error ) => {
95+ sessionError = error
96+ started . reject ( error )
97+ previewStarted . reject ( error )
98+ }
99+
100+ await render (
101+ < View style = { styles . spacerRoot } >
102+ < View style = { styles . redSpacer } />
103+ < Camera
104+ device = { backDevice }
105+ isActive = { true }
106+ style = { styles . spacerCamera }
107+ onStarted = { started . resolve }
108+ onPreviewStarted = { previewStarted . resolve }
109+ onError = { onError }
110+ />
111+ </ View > ,
112+ )
113+
114+ await withTimeout ( started . promise , 15_000 , 'spacer Camera onStarted' )
115+ await withTimeout (
116+ previewStarted . promise ,
117+ 15_000 ,
118+ 'spacer Camera onPreviewStarted' ,
119+ )
120+ expect ( sessionError ) . toBe ( undefined )
121+
122+ // Yield two frames so the fitter's layout commit reaches the render
123+ // pipeline before we snapshot. Matches the harness's own
124+ // waitForNativeViewHierarchy pattern.
125+ await new Promise < void > ( ( resolve ) =>
126+ requestAnimationFrame ( ( ) => requestAnimationFrame ( ( ) => resolve ( ) ) ) ,
127+ )
128+
129+ const png = await screen . screenshot ( )
130+ if ( png == null ) throw new Error ( 'screen.screenshot returned null' )
131+ // ScreenshotResult.width/height come back as 0, so parse the PNG IHDR
132+ // (width @ byte 16, height @ byte 20, big-endian).
133+ const dv = new DataView (
134+ png . data . buffer ,
135+ png . data . byteOffset ,
136+ png . data . byteLength ,
137+ )
138+ const fileW = dv . getUint32 ( 16 , false )
139+ const fileH = dv . getUint32 ( 20 , false )
140+
141+ // Compare only the spacer's pixel band; the rest is live camera feed
142+ // that legitimately varies between runs.
143+ const spacerPx = SPACER_HEIGHT * PixelRatio . get ( )
144+ await expect ( {
145+ data : png . data ,
146+ width : fileW ,
147+ height : fileH ,
148+ } ) . toMatchImageSnapshot ( {
149+ name : 'spacer-region-stays-red' ,
150+ failureThresholdType : 'percent' ,
151+ failureThreshold : 0.02 ,
152+ ignoreRegions : [
153+ { x : 0 , y : spacerPx , width : fileW , height : fileH - spacerPx } ,
154+ ] ,
155+ } )
156+ } )
157+
75158 it ( 'starts the high-level Camera preview and exposes preview/controller ref methods' , async ( ) => {
76159 const cameraRef = createRef < CameraRef > ( )
77160 const layout = deferred < Layout > ( )
@@ -492,3 +575,17 @@ describe('VisionCamera - Camera View', () => {
492575 expect ( sessionError ) . toBe ( undefined )
493576 } )
494577} )
578+
579+ const styles = StyleSheet . create ( {
580+ spacerRoot : {
581+ flex : 1 ,
582+ backgroundColor : 'black' ,
583+ } ,
584+ redSpacer : {
585+ height : SPACER_HEIGHT ,
586+ backgroundColor : 'red' ,
587+ } ,
588+ spacerCamera : {
589+ flex : 1 ,
590+ } ,
591+ } )
0 commit comments