From abcde5ec39e9cb517f81e41df6e3932ae6e6950e Mon Sep 17 00:00:00 2001 From: limichange Date: Wed, 4 Nov 2026 19:57:36 +0800 Subject: [PATCH] feat(native-overlay): add contentSize for non-sheet content JS lays non-sheet overlay content out at the window size it last saw, and the Android content host clips the content to that frame. Right after the window rotates, JS still holds the previous size, so content prepared for the rotated window is cut off until JS catches up. contentSize lets a caller lay the content out at the size of the window it is about to rotate into, so the first rotated frame shows all of it. Omitted, the content follows the window size as before. The spec documents the behavior and its platform scope. --- .../react-native-native-overlay/docs/SPEC.md | 25 ++++++++++++++++ .../src/OverlayView.tsx | 11 ++++++- .../src/OverlayViewTypes.ts | 8 +++++ .../src/__tests__/contentSize.test.ts | 29 +++++++++++++++++++ .../src/contentSize.ts | 21 ++++++++++++++ 5 files changed, 93 insertions(+), 1 deletion(-) create mode 100644 native-views/react-native-native-overlay/src/__tests__/contentSize.test.ts create mode 100644 native-views/react-native-native-overlay/src/contentSize.ts diff --git a/native-views/react-native-native-overlay/docs/SPEC.md b/native-views/react-native-native-overlay/docs/SPEC.md index 0443d7421..4759d1880 100644 --- a/native-views/react-native-native-overlay/docs/SPEC.md +++ b/native-views/react-native-native-overlay/docs/SPEC.md @@ -156,6 +156,7 @@ visible)`, `removePage(ownerKey)`. dismissOnBackPress dismissOnPanDown // sheet and toast only anchor? // anchored only: a trigger ref or a window rect + contentSize? // native non-sheet only: content frame instead of the window size onPresented onDismissRequest onDismissed(reason) /> ``` @@ -432,6 +433,27 @@ the correction: - Rects already in screen space (for example from native lists) need no correction. +### Content size for windows about to rotate + +Implemented; Android runtime-verified with OneKey's native chart fullscreen on +an API 36 emulator. iOS takes the same JS layout path but was not +runtime-verified. Non-sheet content is laid out by JS at the window size it +last saw (`useWindowDimensions`), and the Android content host clips the +content to that frame. JS hears about a rotation only after the window has +rotated, so until then the content keeps the previous orientation's frame and +is clipped to it. + +- `contentSize?: { width, height }` (non-sheet presentations) replaces the + window size for the content frame. A caller that prepares content for the + window it is about to rotate into passes the rotated size, so the first + rotated frame shows all of it. Omitted, the content follows the window size + as before. +- The content stays anchored at the host origin. While the window has not + rotated yet, content larger than the window draws past its edges and takes + no touches there; the caller keeps it hidden until the window matches. +- Sheets ignore it: they size their content from the sheet geometry. Web + ignores it: the DOM overlay sizes itself with CSS. + ### Web keyboard focus containment The topmost active, unsuspended blocking Web entry MUST contain sequential @@ -580,6 +602,9 @@ Automated (P0): - page suspend, restore and removal; - back resolution (global first, `block`, `pass`, focused host) and the blocking top. +- `src/__tests__/contentSize.test.ts` covers the non-sheet content frame: + the window size by default, and a caller size before and after the window + size reaches JS. - `src/__tests__/animation.test.ts` covers: - the spring response in all damping regimes, overshoot clamping and settle time; diff --git a/native-views/react-native-native-overlay/src/OverlayView.tsx b/native-views/react-native-native-overlay/src/OverlayView.tsx index 3f0f1465f..e3a34fd28 100644 --- a/native-views/react-native-native-overlay/src/OverlayView.tsx +++ b/native-views/react-native-native-overlay/src/OverlayView.tsx @@ -13,6 +13,7 @@ import { import NativeOverlay from './NativeOverlayNativeComponent'; import { OverlayLevelProvider } from './OverlayLevelContext'; import { isBlockingLevel } from './OverlayLevels'; +import { resolveContentSize } from './contentSize'; import { resolveSheetWidth } from './sheetWidth'; import { useOverlayController } from './useOverlayController'; @@ -72,6 +73,7 @@ export function OverlayView(props: IOverlayViewProps) { testID, windowOffsetRequest, onWindowOffset, + contentSize, } = props; // A plain sibling at the host's spot: its JS-measured origin shares the // modal-relative space of the caller's measurements, while native reports @@ -284,7 +286,14 @@ export function OverlayView(props: IOverlayViewProps) { {children} diff --git a/native-views/react-native-native-overlay/src/OverlayViewTypes.ts b/native-views/react-native-native-overlay/src/OverlayViewTypes.ts index c3c6ffba7..8efa4c2b9 100644 --- a/native-views/react-native-native-overlay/src/OverlayViewTypes.ts +++ b/native-views/react-native-native-overlay/src/OverlayViewTypes.ts @@ -88,6 +88,14 @@ export interface IOverlayViewProps { */ windowOffsetRequest?: number; onWindowOffset?: (offset: { x: number; y: number }) => void; + /** + * Native only, non-sheet presentations. Lays the content out at this size + * instead of the window size JS last saw. The content host clips the + * content to that frame, and JS hears about a rotation only after the + * window has rotated: content prepared for the window it is about to rotate + * into passes the rotated size, so the first rotated frame shows all of it. + */ + contentSize?: { width: number; height: number }; /** The exit animation finished and the content unmounted. */ onClose?: (reason: IOverlayDismissReason) => void; children?: ReactNode; diff --git a/native-views/react-native-native-overlay/src/__tests__/contentSize.test.ts b/native-views/react-native-native-overlay/src/__tests__/contentSize.test.ts new file mode 100644 index 000000000..cd8144e4a --- /dev/null +++ b/native-views/react-native-native-overlay/src/__tests__/contentSize.test.ts @@ -0,0 +1,29 @@ +import { resolveContentSize } from '../contentSize'; + +it('follows the window size by default', () => { + expect(resolveContentSize({ windowWidth: 360, windowHeight: 640 })).toEqual({ + width: 360, + height: 640, + }); +}); + +it('keeps a caller size while the window has not rotated yet', () => { + // Prepared for the landscape window the portrait one is rotating into. + expect( + resolveContentSize({ + windowWidth: 360, + windowHeight: 640, + contentSize: { width: 640, height: 360 }, + }) + ).toEqual({ width: 640, height: 360 }); +}); + +it('keeps a caller size after the window size reaches JS', () => { + expect( + resolveContentSize({ + windowWidth: 640, + windowHeight: 360, + contentSize: { width: 640, height: 360 }, + }) + ).toEqual({ width: 640, height: 360 }); +}); diff --git a/native-views/react-native-native-overlay/src/contentSize.ts b/native-views/react-native-native-overlay/src/contentSize.ts new file mode 100644 index 000000000..78e41b9cf --- /dev/null +++ b/native-views/react-native-native-overlay/src/contentSize.ts @@ -0,0 +1,21 @@ +/** + * Non-sheet content geometry. JS lays the content out at the window size it + * last saw, and the native content host clips the content to that frame. Right + * after the window rotates, JS still holds the previous size, so a caller that + * prepared its content for the window it is about to rotate into passes that + * size instead. + */ +export function resolveContentSize({ + windowWidth, + windowHeight, + contentSize, +}: { + windowWidth: number; + windowHeight: number; + contentSize?: { width: number; height: number }; +}): { width: number; height: number } { + return { + width: contentSize?.width ?? windowWidth, + height: contentSize?.height ?? windowHeight, + }; +}