From eaa00c7e12d7e8aa29c4a22bfd6b42a27346f977 Mon Sep 17 00:00:00 2001 From: Szymon Gaczol Date: Mon, 8 Jun 2026 12:28:24 +0200 Subject: [PATCH 01/25] ContainedModal and ContainedModalProvider codegen setup --- package.json | 6 ++++++ src/fabric/ContainedModalNativeComponent.ts | 8 ++++++++ src/fabric/ContainedModalProviderNativeComponent.ts | 9 +++++++++ 3 files changed, 23 insertions(+) create mode 100644 src/fabric/ContainedModalNativeComponent.ts create mode 100644 src/fabric/ContainedModalProviderNativeComponent.ts diff --git a/package.json b/package.json index d0267a5ec4..cfe8aeeec7 100644 --- a/package.json +++ b/package.json @@ -235,6 +235,12 @@ }, "RNSFormSheetContentWrapper": { "className": "RNSFormSheetContentWrapperComponentView" + }, + "RNSContainedModal": { + "className": "RNSContainedModalComponentView" + }, + "RNSContainedModalProvider": { + "className": "RNSContainedModalProviderComponentView" } } } diff --git a/src/fabric/ContainedModalNativeComponent.ts b/src/fabric/ContainedModalNativeComponent.ts new file mode 100644 index 0000000000..e25b4ebecb --- /dev/null +++ b/src/fabric/ContainedModalNativeComponent.ts @@ -0,0 +1,8 @@ +import { codegenNativeComponent } from 'react-native'; +import type { ViewProps } from 'react-native'; + +export interface NativeProps extends ViewProps {} + +export default codegenNativeComponent('RNSContainedModal', { + interfaceOnly: true, +}); diff --git a/src/fabric/ContainedModalProviderNativeComponent.ts b/src/fabric/ContainedModalProviderNativeComponent.ts new file mode 100644 index 0000000000..5ad9709111 --- /dev/null +++ b/src/fabric/ContainedModalProviderNativeComponent.ts @@ -0,0 +1,9 @@ +import { codegenNativeComponent } from 'react-native'; +import type { ViewProps } from 'react-native'; + +export interface NativeProps extends ViewProps {} + +export default codegenNativeComponent( + 'RNSContainedModalProvider', + {}, +); From 07914eb5d6113e7e253eeec6c3ce88e607338e27 Mon Sep 17 00:00:00 2001 From: Szymon Gaczol Date: Mon, 8 Jun 2026 13:07:10 +0200 Subject: [PATCH 02/25] remove interfaceOnly: true from ContainedModal codegen file such approach is temporary - implementing a custom shadow node will be handled in a separate pr --- src/fabric/ContainedModalNativeComponent.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/fabric/ContainedModalNativeComponent.ts b/src/fabric/ContainedModalNativeComponent.ts index e25b4ebecb..e6fe6f8702 100644 --- a/src/fabric/ContainedModalNativeComponent.ts +++ b/src/fabric/ContainedModalNativeComponent.ts @@ -3,6 +3,4 @@ import type { ViewProps } from 'react-native'; export interface NativeProps extends ViewProps {} -export default codegenNativeComponent('RNSContainedModal', { - interfaceOnly: true, -}); +export default codegenNativeComponent('RNSContainedModal', {}); From c10bebb97467af9ac5210e457dc5b6bcc7ee400f Mon Sep 17 00:00:00 2001 From: Szymon Gaczol Date: Mon, 8 Jun 2026 14:12:10 +0200 Subject: [PATCH 03/25] update codegen files --- src/fabric/ContainedModalNativeComponent.ts | 12 +++++++++--- src/fabric/ContainedModalProviderNativeComponent.ts | 6 +++++- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/src/fabric/ContainedModalNativeComponent.ts b/src/fabric/ContainedModalNativeComponent.ts index e6fe6f8702..1be8302e3f 100644 --- a/src/fabric/ContainedModalNativeComponent.ts +++ b/src/fabric/ContainedModalNativeComponent.ts @@ -1,6 +1,12 @@ +'use client'; + import { codegenNativeComponent } from 'react-native'; -import type { ViewProps } from 'react-native'; +import type { CodegenTypes as CT, ViewProps } from 'react-native'; -export interface NativeProps extends ViewProps {} +export interface NativeProps extends ViewProps { + isOpen?: CT.WithDefault; +} -export default codegenNativeComponent('RNSContainedModal', {}); +export default codegenNativeComponent('RNSContainedModal', { + excludedPlatforms: ['android'], +}); diff --git a/src/fabric/ContainedModalProviderNativeComponent.ts b/src/fabric/ContainedModalProviderNativeComponent.ts index 5ad9709111..adb7fcb497 100644 --- a/src/fabric/ContainedModalProviderNativeComponent.ts +++ b/src/fabric/ContainedModalProviderNativeComponent.ts @@ -1,3 +1,5 @@ +'use client'; + import { codegenNativeComponent } from 'react-native'; import type { ViewProps } from 'react-native'; @@ -5,5 +7,7 @@ export interface NativeProps extends ViewProps {} export default codegenNativeComponent( 'RNSContainedModalProvider', - {}, + { + excludedPlatforms: ['android'], + }, ); From 2b83e6e512f4b2539e8c1e35b1b3fe481ffd6635 Mon Sep 17 00:00:00 2001 From: Szymon Gaczol Date: Mon, 8 Jun 2026 14:32:06 +0200 Subject: [PATCH 04/25] added files on js side: - ContainedModal.tsx - ContainedModal.types.ts - ContainedModalProvider.tsx - ContainedModalProvider.types.ts - index.tsx --- .../modals/contained-modal/ContainedModal.tsx | 22 +++++++++++++++++++ .../contained-modal/ContainedModal.types.ts | 5 +++++ .../ContainedModalProvider.tsx | 7 ++++++ .../ContainedModalProvider.types.ts | 3 +++ .../gamma/modals/contained-modal/index.ts | 5 +++++ .../ContainedModalNativeComponent.ts | 0 .../ContainedModalProviderNativeComponent.ts | 0 7 files changed, 42 insertions(+) create mode 100644 src/components/gamma/modals/contained-modal/ContainedModal.tsx create mode 100644 src/components/gamma/modals/contained-modal/ContainedModal.types.ts create mode 100644 src/components/gamma/modals/contained-modal/ContainedModalProvider.tsx create mode 100644 src/components/gamma/modals/contained-modal/ContainedModalProvider.types.ts create mode 100644 src/components/gamma/modals/contained-modal/index.ts rename src/fabric/{ => gamma/modals/contained-modal}/ContainedModalNativeComponent.ts (100%) rename src/fabric/{ => gamma/modals/contained-modal}/ContainedModalProviderNativeComponent.ts (100%) diff --git a/src/components/gamma/modals/contained-modal/ContainedModal.tsx b/src/components/gamma/modals/contained-modal/ContainedModal.tsx new file mode 100644 index 0000000000..89b30eefdd --- /dev/null +++ b/src/components/gamma/modals/contained-modal/ContainedModal.tsx @@ -0,0 +1,22 @@ +import React from 'react'; +import type { ContainedModalProps } from './ContainedModal.types'; +import ContainedModalNativeComponent from '../../../../fabric/gamma/modals/contained-modal/ContainedModalProviderNativeComponent'; +import { StyleSheet } from 'react-native'; + +export function ContainedModal(props: ContainedModalProps) { + const { style, ...rest } = props; + + return ( + + ); +} + +const styles = StyleSheet.create({ + host: { + position: 'absolute', + top: 0, + left: 0, + width: 0, + height: 0, + }, +}); diff --git a/src/components/gamma/modals/contained-modal/ContainedModal.types.ts b/src/components/gamma/modals/contained-modal/ContainedModal.types.ts new file mode 100644 index 0000000000..c92011fc14 --- /dev/null +++ b/src/components/gamma/modals/contained-modal/ContainedModal.types.ts @@ -0,0 +1,5 @@ +import type { ViewProps } from 'react-native'; + +export interface ContainedModalProps extends ViewProps { + isOpen: boolean; +} diff --git a/src/components/gamma/modals/contained-modal/ContainedModalProvider.tsx b/src/components/gamma/modals/contained-modal/ContainedModalProvider.tsx new file mode 100644 index 0000000000..f4173a0d03 --- /dev/null +++ b/src/components/gamma/modals/contained-modal/ContainedModalProvider.tsx @@ -0,0 +1,7 @@ +import React from 'react'; +import ContainedModalProviderNativeComponent from '../../../../fabric/gamma/modals/contained-modal/ContainedModalProviderNativeComponent'; +import { ContainedModalProviderProps } from './ContainedModalProvider.types'; + +export function ContainedModalProvider(props: ContainedModalProviderProps) { + return ; +} diff --git a/src/components/gamma/modals/contained-modal/ContainedModalProvider.types.ts b/src/components/gamma/modals/contained-modal/ContainedModalProvider.types.ts new file mode 100644 index 0000000000..d0dba7543f --- /dev/null +++ b/src/components/gamma/modals/contained-modal/ContainedModalProvider.types.ts @@ -0,0 +1,3 @@ +import type { ViewProps } from 'react-native'; + +export interface ContainedModalProviderProps extends ViewProps {} diff --git a/src/components/gamma/modals/contained-modal/index.ts b/src/components/gamma/modals/contained-modal/index.ts new file mode 100644 index 0000000000..f1481ea45b --- /dev/null +++ b/src/components/gamma/modals/contained-modal/index.ts @@ -0,0 +1,5 @@ +export type { ContainedModalProps } from './ContainedModal.types'; +export type { ContainedModalProviderProps } from './ContainedModalProvider.types'; + +export { ContainedModal } from './ContainedModal'; +export { ContainedModalProvider } from './ContainedModalProvider'; diff --git a/src/fabric/ContainedModalNativeComponent.ts b/src/fabric/gamma/modals/contained-modal/ContainedModalNativeComponent.ts similarity index 100% rename from src/fabric/ContainedModalNativeComponent.ts rename to src/fabric/gamma/modals/contained-modal/ContainedModalNativeComponent.ts diff --git a/src/fabric/ContainedModalProviderNativeComponent.ts b/src/fabric/gamma/modals/contained-modal/ContainedModalProviderNativeComponent.ts similarity index 100% rename from src/fabric/ContainedModalProviderNativeComponent.ts rename to src/fabric/gamma/modals/contained-modal/ContainedModalProviderNativeComponent.ts From f4469ac0b3201120c2528bb8924b4066c54c3be6 Mon Sep 17 00:00:00 2001 From: Szymon Gaczol Date: Mon, 15 Jun 2026 13:26:45 +0200 Subject: [PATCH 05/25] import fix --- src/components/gamma/modals/contained-modal/ContainedModal.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/gamma/modals/contained-modal/ContainedModal.tsx b/src/components/gamma/modals/contained-modal/ContainedModal.tsx index 89b30eefdd..d16e886b11 100644 --- a/src/components/gamma/modals/contained-modal/ContainedModal.tsx +++ b/src/components/gamma/modals/contained-modal/ContainedModal.tsx @@ -1,6 +1,6 @@ import React from 'react'; import type { ContainedModalProps } from './ContainedModal.types'; -import ContainedModalNativeComponent from '../../../../fabric/gamma/modals/contained-modal/ContainedModalProviderNativeComponent'; +import ContainedModalNativeComponent from '../../../../fabric/gamma/modals/contained-modal/ContainedModalNativeComponent'; import { StyleSheet } from 'react-native'; export function ContainedModal(props: ContainedModalProps) { From e54aab037672cd22820a007d9af145b3a7144e8c Mon Sep 17 00:00:00 2001 From: Szymon Gaczol Date: Tue, 16 Jun 2026 11:21:43 +0200 Subject: [PATCH 06/25] naming convention --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index cfe8aeeec7..cc2bd43020 100644 --- a/package.json +++ b/package.json @@ -237,10 +237,10 @@ "className": "RNSFormSheetContentWrapperComponentView" }, "RNSContainedModal": { - "className": "RNSContainedModalComponentView" + "className": "RNSContainedModalHostComponentView" }, "RNSContainedModalProvider": { - "className": "RNSContainedModalProviderComponentView" + "className": "RNSContainedModalProviderHostComponentView" } } } From 12a3a276263f72493da6a331b04bb66eabd94571 Mon Sep 17 00:00:00 2001 From: Szymon Gaczol Date: Tue, 16 Jun 2026 11:24:54 +0200 Subject: [PATCH 07/25] add ContainedModal to experimental --- src/experimental/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/experimental/index.ts b/src/experimental/index.ts index 8e508a2a7b..9245c06b7f 100644 --- a/src/experimental/index.ts +++ b/src/experimental/index.ts @@ -10,3 +10,4 @@ export * from '../components/gamma/split'; export * from '../components/safe-area'; export * from '../components/gamma/scroll-view-marker'; export * from '../components/gamma/modals/form-sheet'; +export * from '../components/gamma/modals/contained-modal'; From 5beb11ebbe30b6dc9510aac68b3fc494d07c3b7e Mon Sep 17 00:00:00 2001 From: Szymon Gaczol Date: Tue, 16 Jun 2026 11:25:23 +0200 Subject: [PATCH 08/25] import type --- .../gamma/modals/contained-modal/ContainedModalProvider.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/gamma/modals/contained-modal/ContainedModalProvider.tsx b/src/components/gamma/modals/contained-modal/ContainedModalProvider.tsx index f4173a0d03..656092fcff 100644 --- a/src/components/gamma/modals/contained-modal/ContainedModalProvider.tsx +++ b/src/components/gamma/modals/contained-modal/ContainedModalProvider.tsx @@ -1,6 +1,6 @@ import React from 'react'; import ContainedModalProviderNativeComponent from '../../../../fabric/gamma/modals/contained-modal/ContainedModalProviderNativeComponent'; -import { ContainedModalProviderProps } from './ContainedModalProvider.types'; +import type { ContainedModalProviderProps } from './ContainedModalProvider.types'; export function ContainedModalProvider(props: ContainedModalProviderProps) { return ; From 77afa9a6c21082081264307a3674bbc94664f7b1 Mon Sep 17 00:00:00 2001 From: Szymon Gaczol Date: Tue, 16 Jun 2026 11:34:46 +0200 Subject: [PATCH 09/25] add interfaceOnly: true --- .../modals/contained-modal/ContainedModalNativeComponent.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/fabric/gamma/modals/contained-modal/ContainedModalNativeComponent.ts b/src/fabric/gamma/modals/contained-modal/ContainedModalNativeComponent.ts index 1be8302e3f..57159193e8 100644 --- a/src/fabric/gamma/modals/contained-modal/ContainedModalNativeComponent.ts +++ b/src/fabric/gamma/modals/contained-modal/ContainedModalNativeComponent.ts @@ -8,5 +8,6 @@ export interface NativeProps extends ViewProps { } export default codegenNativeComponent('RNSContainedModal', { + interfaceOnly: true, excludedPlatforms: ['android'], }); From fc9e7e8fef5248a19992fa30f49531c61add4b38 Mon Sep 17 00:00:00 2001 From: Szymon Gaczol Date: Tue, 16 Jun 2026 11:36:59 +0200 Subject: [PATCH 10/25] rename codegen files --- package.json | 4 ++-- .../gamma/modals/contained-modal/ContainedModal.tsx | 4 ++-- .../gamma/modals/contained-modal/ContainedModalProvider.tsx | 4 ++-- ...ativeComponent.ts => ContainedModalHostNativeComponent.ts} | 0 ...ponent.ts => ContainedModalProviderHostNativeComponent.ts} | 0 5 files changed, 6 insertions(+), 6 deletions(-) rename src/fabric/gamma/modals/contained-modal/{ContainedModalNativeComponent.ts => ContainedModalHostNativeComponent.ts} (100%) rename src/fabric/gamma/modals/contained-modal/{ContainedModalProviderNativeComponent.ts => ContainedModalProviderHostNativeComponent.ts} (100%) diff --git a/package.json b/package.json index cc2bd43020..a85329a27c 100644 --- a/package.json +++ b/package.json @@ -236,10 +236,10 @@ "RNSFormSheetContentWrapper": { "className": "RNSFormSheetContentWrapperComponentView" }, - "RNSContainedModal": { + "RNSContainedModalHost": { "className": "RNSContainedModalHostComponentView" }, - "RNSContainedModalProvider": { + "RNSContainedModalHostProvider": { "className": "RNSContainedModalProviderHostComponentView" } } diff --git a/src/components/gamma/modals/contained-modal/ContainedModal.tsx b/src/components/gamma/modals/contained-modal/ContainedModal.tsx index d16e886b11..abe694a121 100644 --- a/src/components/gamma/modals/contained-modal/ContainedModal.tsx +++ b/src/components/gamma/modals/contained-modal/ContainedModal.tsx @@ -1,13 +1,13 @@ import React from 'react'; import type { ContainedModalProps } from './ContainedModal.types'; -import ContainedModalNativeComponent from '../../../../fabric/gamma/modals/contained-modal/ContainedModalNativeComponent'; +import ContainedModalHostNativeComponent from '../../../../fabric/gamma/modals/contained-modal/ContainedModalHostNativeComponent'; import { StyleSheet } from 'react-native'; export function ContainedModal(props: ContainedModalProps) { const { style, ...rest } = props; return ( - + ); } diff --git a/src/components/gamma/modals/contained-modal/ContainedModalProvider.tsx b/src/components/gamma/modals/contained-modal/ContainedModalProvider.tsx index 656092fcff..d5597a22b8 100644 --- a/src/components/gamma/modals/contained-modal/ContainedModalProvider.tsx +++ b/src/components/gamma/modals/contained-modal/ContainedModalProvider.tsx @@ -1,7 +1,7 @@ import React from 'react'; -import ContainedModalProviderNativeComponent from '../../../../fabric/gamma/modals/contained-modal/ContainedModalProviderNativeComponent'; +import ContainedModalProviderHostNativeComponent from '../../../../fabric/gamma/modals/contained-modal/ContainedModalProviderHostNativeComponent'; import type { ContainedModalProviderProps } from './ContainedModalProvider.types'; export function ContainedModalProvider(props: ContainedModalProviderProps) { - return ; + return ; } diff --git a/src/fabric/gamma/modals/contained-modal/ContainedModalNativeComponent.ts b/src/fabric/gamma/modals/contained-modal/ContainedModalHostNativeComponent.ts similarity index 100% rename from src/fabric/gamma/modals/contained-modal/ContainedModalNativeComponent.ts rename to src/fabric/gamma/modals/contained-modal/ContainedModalHostNativeComponent.ts diff --git a/src/fabric/gamma/modals/contained-modal/ContainedModalProviderNativeComponent.ts b/src/fabric/gamma/modals/contained-modal/ContainedModalProviderHostNativeComponent.ts similarity index 100% rename from src/fabric/gamma/modals/contained-modal/ContainedModalProviderNativeComponent.ts rename to src/fabric/gamma/modals/contained-modal/ContainedModalProviderHostNativeComponent.ts From 138f7da4e840b30623fcdc7ab0eb501e58844172 Mon Sep 17 00:00:00 2001 From: Szymon Gaczol Date: Tue, 16 Jun 2026 13:21:44 +0200 Subject: [PATCH 11/25] codegen name fix --- .../modals/contained-modal/ContainedModalHostNativeComponent.ts | 2 +- .../ContainedModalProviderHostNativeComponent.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/fabric/gamma/modals/contained-modal/ContainedModalHostNativeComponent.ts b/src/fabric/gamma/modals/contained-modal/ContainedModalHostNativeComponent.ts index 57159193e8..085ba875ce 100644 --- a/src/fabric/gamma/modals/contained-modal/ContainedModalHostNativeComponent.ts +++ b/src/fabric/gamma/modals/contained-modal/ContainedModalHostNativeComponent.ts @@ -7,7 +7,7 @@ export interface NativeProps extends ViewProps { isOpen?: CT.WithDefault; } -export default codegenNativeComponent('RNSContainedModal', { +export default codegenNativeComponent('RNSContainedModalHost', { interfaceOnly: true, excludedPlatforms: ['android'], }); diff --git a/src/fabric/gamma/modals/contained-modal/ContainedModalProviderHostNativeComponent.ts b/src/fabric/gamma/modals/contained-modal/ContainedModalProviderHostNativeComponent.ts index adb7fcb497..e3fa312cd2 100644 --- a/src/fabric/gamma/modals/contained-modal/ContainedModalProviderHostNativeComponent.ts +++ b/src/fabric/gamma/modals/contained-modal/ContainedModalProviderHostNativeComponent.ts @@ -6,7 +6,7 @@ import type { ViewProps } from 'react-native'; export interface NativeProps extends ViewProps {} export default codegenNativeComponent( - 'RNSContainedModalProvider', + 'RNSContainedModalProviderHost', { excludedPlatforms: ['android'], }, From 6b70b124c43e6c0962475b16f77671d8babb9c3f Mon Sep 17 00:00:00 2001 From: Szymon Gaczol Date: Tue, 16 Jun 2026 14:10:37 +0200 Subject: [PATCH 12/25] package.json fix --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index a85329a27c..217d942642 100644 --- a/package.json +++ b/package.json @@ -239,7 +239,7 @@ "RNSContainedModalHost": { "className": "RNSContainedModalHostComponentView" }, - "RNSContainedModalHostProvider": { + "RNSContainedModalProviderHost": { "className": "RNSContainedModalProviderHostComponentView" } } From 9475b3d81ccda70bf8c4b37d813f80f14ade7d4a Mon Sep 17 00:00:00 2001 From: Szymon Gaczol Date: Wed, 17 Jun 2026 12:46:06 +0200 Subject: [PATCH 13/25] update --- .../modals/contained-modal/ContainedModal.tsx | 12 +++++++-- .../contained-modal/ContainedModal.types.ts | 22 +++++++++++++++- .../ContainedModalProvider.tsx | 8 +++++- .../ContainedModalProvider.types.ts | 26 +++++++++++++++++-- .../ContainedModalHostNativeComponent.ts | 1 + ...ntainedModalProviderHostNativeComponent.ts | 6 +++-- 6 files changed, 67 insertions(+), 8 deletions(-) diff --git a/src/components/gamma/modals/contained-modal/ContainedModal.tsx b/src/components/gamma/modals/contained-modal/ContainedModal.tsx index abe694a121..f024d51a99 100644 --- a/src/components/gamma/modals/contained-modal/ContainedModal.tsx +++ b/src/components/gamma/modals/contained-modal/ContainedModal.tsx @@ -4,14 +4,22 @@ import ContainedModalHostNativeComponent from '../../../../fabric/gamma/modals/c import { StyleSheet } from 'react-native'; export function ContainedModal(props: ContainedModalProps) { - const { style, ...rest } = props; + const { children, ...rest } = props; return ( - + + {children} + ); } const styles = StyleSheet.create({ + // We use absolute positioning so the Host view doesn't affect the layout of its siblings. + // Setting `top: 0` and `left: 0` explicitly anchors the view to a predictable origin, + // preventing it from floating at an arbitrary offset based on its position in the Element tree. + // + // IMPORTANT: "Absolute" positioning is still relative to the nearest positioned containing + // box. This anchors it to that specific container's (0,0), not the global window (0,0). host: { position: 'absolute', top: 0, diff --git a/src/components/gamma/modals/contained-modal/ContainedModal.types.ts b/src/components/gamma/modals/contained-modal/ContainedModal.types.ts index c92011fc14..9adae7ef73 100644 --- a/src/components/gamma/modals/contained-modal/ContainedModal.types.ts +++ b/src/components/gamma/modals/contained-modal/ContainedModal.types.ts @@ -1,5 +1,25 @@ import type { ViewProps } from 'react-native'; -export interface ContainedModalProps extends ViewProps { +export interface ContainedModalProps { + children?: ViewProps['children'] | undefined; + /** + * @summary Determines whether the contained modal is currently visible. + * + * Presentation is driven by state transitions: updating this property + * from `false` to `true` triggers the sheet to present, while changing + * it from `true` to `false` triggers a programmatic dismissal. + * + * @platform ios + */ isOpen: boolean; + /** + * @summary Selects which `ContainedModalProvider` this modal is presented in. + * + * The modal is matched to a provider by comparing this `providerKey` + * against the provider's `providerKey`. The modal is presented within the + * bounds of the provider whose `providerKey` equals this value. + * + * @platform ios + */ + providerKey: string; } diff --git a/src/components/gamma/modals/contained-modal/ContainedModalProvider.tsx b/src/components/gamma/modals/contained-modal/ContainedModalProvider.tsx index d5597a22b8..ccda0995ef 100644 --- a/src/components/gamma/modals/contained-modal/ContainedModalProvider.tsx +++ b/src/components/gamma/modals/contained-modal/ContainedModalProvider.tsx @@ -3,5 +3,11 @@ import ContainedModalProviderHostNativeComponent from '../../../../fabric/gamma/ import type { ContainedModalProviderProps } from './ContainedModalProvider.types'; export function ContainedModalProvider(props: ContainedModalProviderProps) { - return ; + const { children, style, ...rest } = props; + + return ( + + {children} + + ); } diff --git a/src/components/gamma/modals/contained-modal/ContainedModalProvider.types.ts b/src/components/gamma/modals/contained-modal/ContainedModalProvider.types.ts index d0dba7543f..881961931c 100644 --- a/src/components/gamma/modals/contained-modal/ContainedModalProvider.types.ts +++ b/src/components/gamma/modals/contained-modal/ContainedModalProvider.types.ts @@ -1,3 +1,25 @@ -import type { ViewProps } from 'react-native'; +import type { StyleProp, ViewProps, ViewStyle } from 'react-native'; -export interface ContainedModalProviderProps extends ViewProps {} +export interface ContainedModalProviderProps { + children?: ViewProps['children'] | undefined; + /** + * @summary Style applied to the provider's host view. + * + * The provider is a regular, space-filling container whose bounds define the + * area within which a matched `ContainedModal` is presented, so its size is + * configurable - unlike `ContainedModal`, whose host is a zero-sized logical anchor. + * + * @platform ios + */ + style?: StyleProp | undefined; + /** + * @summary Identifies this provider so a `ContainedModal` can target it. + * + * A `ContainedModal` is matched to its provider by comparing this + * `providerKey` against the modal's `providerKey`. The modal is presented + * within the bounds of the provider whose `providerKey` equals the modal's. + * + * @platform ios + */ + providerKey: string; +} diff --git a/src/fabric/gamma/modals/contained-modal/ContainedModalHostNativeComponent.ts b/src/fabric/gamma/modals/contained-modal/ContainedModalHostNativeComponent.ts index 085ba875ce..9a2b2d3014 100644 --- a/src/fabric/gamma/modals/contained-modal/ContainedModalHostNativeComponent.ts +++ b/src/fabric/gamma/modals/contained-modal/ContainedModalHostNativeComponent.ts @@ -5,6 +5,7 @@ import type { CodegenTypes as CT, ViewProps } from 'react-native'; export interface NativeProps extends ViewProps { isOpen?: CT.WithDefault; + providerKey?: CT.WithDefault; } export default codegenNativeComponent('RNSContainedModalHost', { diff --git a/src/fabric/gamma/modals/contained-modal/ContainedModalProviderHostNativeComponent.ts b/src/fabric/gamma/modals/contained-modal/ContainedModalProviderHostNativeComponent.ts index e3fa312cd2..ebf2ee9942 100644 --- a/src/fabric/gamma/modals/contained-modal/ContainedModalProviderHostNativeComponent.ts +++ b/src/fabric/gamma/modals/contained-modal/ContainedModalProviderHostNativeComponent.ts @@ -1,9 +1,11 @@ 'use client'; import { codegenNativeComponent } from 'react-native'; -import type { ViewProps } from 'react-native'; +import type { CodegenTypes as CT, ViewProps } from 'react-native'; -export interface NativeProps extends ViewProps {} +export interface NativeProps extends ViewProps { + providerKey?: CT.WithDefault; +} export default codegenNativeComponent( 'RNSContainedModalProviderHost', From 93288c6ee939d03c6a79579ec75949657be637c3 Mon Sep 17 00:00:00 2001 From: Szymon Gaczol Date: Wed, 17 Jun 2026 12:51:24 +0200 Subject: [PATCH 14/25] provider name change --- package.json | 4 ++-- .../gamma/modals/contained-modal/ContainedModalProvider.tsx | 6 +++--- ...omponent.ts => ContainedModalProviderNativeComponent.ts} | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) rename src/fabric/gamma/modals/contained-modal/{ContainedModalProviderHostNativeComponent.ts => ContainedModalProviderNativeComponent.ts} (90%) diff --git a/package.json b/package.json index 217d942642..3fa51be96e 100644 --- a/package.json +++ b/package.json @@ -239,8 +239,8 @@ "RNSContainedModalHost": { "className": "RNSContainedModalHostComponentView" }, - "RNSContainedModalProviderHost": { - "className": "RNSContainedModalProviderHostComponentView" + "RNSContainedModalProvider": { + "className": "RNSContainedModalProviderComponentView" } } } diff --git a/src/components/gamma/modals/contained-modal/ContainedModalProvider.tsx b/src/components/gamma/modals/contained-modal/ContainedModalProvider.tsx index ccda0995ef..cb04602292 100644 --- a/src/components/gamma/modals/contained-modal/ContainedModalProvider.tsx +++ b/src/components/gamma/modals/contained-modal/ContainedModalProvider.tsx @@ -1,13 +1,13 @@ import React from 'react'; -import ContainedModalProviderHostNativeComponent from '../../../../fabric/gamma/modals/contained-modal/ContainedModalProviderHostNativeComponent'; +import ContainedModalProviderNativeComponent from '../../../../fabric/gamma/modals/contained-modal/ContainedModalProviderNativeComponent'; import type { ContainedModalProviderProps } from './ContainedModalProvider.types'; export function ContainedModalProvider(props: ContainedModalProviderProps) { const { children, style, ...rest } = props; return ( - + {children} - + ); } diff --git a/src/fabric/gamma/modals/contained-modal/ContainedModalProviderHostNativeComponent.ts b/src/fabric/gamma/modals/contained-modal/ContainedModalProviderNativeComponent.ts similarity index 90% rename from src/fabric/gamma/modals/contained-modal/ContainedModalProviderHostNativeComponent.ts rename to src/fabric/gamma/modals/contained-modal/ContainedModalProviderNativeComponent.ts index ebf2ee9942..7deb4a6a25 100644 --- a/src/fabric/gamma/modals/contained-modal/ContainedModalProviderHostNativeComponent.ts +++ b/src/fabric/gamma/modals/contained-modal/ContainedModalProviderNativeComponent.ts @@ -8,7 +8,7 @@ export interface NativeProps extends ViewProps { } export default codegenNativeComponent( - 'RNSContainedModalProviderHost', + 'RNSContainedModalProvider', { excludedPlatforms: ['android'], }, From 4c1725365736cb82285556c95452de67395a969b Mon Sep 17 00:00:00 2001 From: Szymon Gaczol Date: Wed, 17 Jun 2026 13:23:55 +0200 Subject: [PATCH 15/25] stubs added --- .../modals/contained-modal/ContainedModal.android.tsx | 9 +++++++++ .../gamma/modals/contained-modal/ContainedModal.web.tsx | 9 +++++++++ .../contained-modal/ContainedModalProvider.android.tsx | 9 +++++++++ .../contained-modal/ContainedModalProvider.web.tsx | 9 +++++++++ 4 files changed, 36 insertions(+) create mode 100644 src/components/gamma/modals/contained-modal/ContainedModal.android.tsx create mode 100644 src/components/gamma/modals/contained-modal/ContainedModal.web.tsx create mode 100644 src/components/gamma/modals/contained-modal/ContainedModalProvider.android.tsx create mode 100644 src/components/gamma/modals/contained-modal/ContainedModalProvider.web.tsx diff --git a/src/components/gamma/modals/contained-modal/ContainedModal.android.tsx b/src/components/gamma/modals/contained-modal/ContainedModal.android.tsx new file mode 100644 index 0000000000..a6e9456971 --- /dev/null +++ b/src/components/gamma/modals/contained-modal/ContainedModal.android.tsx @@ -0,0 +1,9 @@ +import warnOnce from 'warn-once'; + +export const ContainedModal = () => { + warnOnce( + true, + '[RNScreens] As of now, ContainedModal component is supported only for iOS.', + ); + return null; +}; diff --git a/src/components/gamma/modals/contained-modal/ContainedModal.web.tsx b/src/components/gamma/modals/contained-modal/ContainedModal.web.tsx new file mode 100644 index 0000000000..a6e9456971 --- /dev/null +++ b/src/components/gamma/modals/contained-modal/ContainedModal.web.tsx @@ -0,0 +1,9 @@ +import warnOnce from 'warn-once'; + +export const ContainedModal = () => { + warnOnce( + true, + '[RNScreens] As of now, ContainedModal component is supported only for iOS.', + ); + return null; +}; diff --git a/src/components/gamma/modals/contained-modal/ContainedModalProvider.android.tsx b/src/components/gamma/modals/contained-modal/ContainedModalProvider.android.tsx new file mode 100644 index 0000000000..a866676e3b --- /dev/null +++ b/src/components/gamma/modals/contained-modal/ContainedModalProvider.android.tsx @@ -0,0 +1,9 @@ +import warnOnce from 'warn-once'; + +export const ContainedModalProvider = () => { + warnOnce( + true, + '[RNScreens] As of now, ContainedModalProvider component is supported only for iOS.', + ); + return null; +}; diff --git a/src/components/gamma/modals/contained-modal/ContainedModalProvider.web.tsx b/src/components/gamma/modals/contained-modal/ContainedModalProvider.web.tsx new file mode 100644 index 0000000000..a866676e3b --- /dev/null +++ b/src/components/gamma/modals/contained-modal/ContainedModalProvider.web.tsx @@ -0,0 +1,9 @@ +import warnOnce from 'warn-once'; + +export const ContainedModalProvider = () => { + warnOnce( + true, + '[RNScreens] As of now, ContainedModalProvider component is supported only for iOS.', + ); + return null; +}; From 3628223f79931b5cfef010771256390e1186b8bb Mon Sep 17 00:00:00 2001 From: Szymon Gaczol Date: Wed, 17 Jun 2026 15:20:08 +0200 Subject: [PATCH 16/25] providerKey renamed into containerId for the provider and targetContainerId for the modal --- .../gamma/modals/contained-modal/ContainedModal.types.ts | 8 ++++---- .../contained-modal/ContainedModalProvider.types.ts | 7 ++++--- .../contained-modal/ContainedModalHostNativeComponent.ts | 2 +- .../ContainedModalProviderNativeComponent.ts | 2 +- 4 files changed, 10 insertions(+), 9 deletions(-) diff --git a/src/components/gamma/modals/contained-modal/ContainedModal.types.ts b/src/components/gamma/modals/contained-modal/ContainedModal.types.ts index 9adae7ef73..34a8bbf829 100644 --- a/src/components/gamma/modals/contained-modal/ContainedModal.types.ts +++ b/src/components/gamma/modals/contained-modal/ContainedModal.types.ts @@ -15,11 +15,11 @@ export interface ContainedModalProps { /** * @summary Selects which `ContainedModalProvider` this modal is presented in. * - * The modal is matched to a provider by comparing this `providerKey` - * against the provider's `providerKey`. The modal is presented within the - * bounds of the provider whose `providerKey` equals this value. + * The modal is matched to a provider by comparing this `targetContainerId` + * against the provider's `containerId`. The modal is presented within the + * bounds of the provider whose `containerId` equals this value. * * @platform ios */ - providerKey: string; + targetContainerId: string; } diff --git a/src/components/gamma/modals/contained-modal/ContainedModalProvider.types.ts b/src/components/gamma/modals/contained-modal/ContainedModalProvider.types.ts index 881961931c..3aace6d13c 100644 --- a/src/components/gamma/modals/contained-modal/ContainedModalProvider.types.ts +++ b/src/components/gamma/modals/contained-modal/ContainedModalProvider.types.ts @@ -16,10 +16,11 @@ export interface ContainedModalProviderProps { * @summary Identifies this provider so a `ContainedModal` can target it. * * A `ContainedModal` is matched to its provider by comparing this - * `providerKey` against the modal's `providerKey`. The modal is presented - * within the bounds of the provider whose `providerKey` equals the modal's. + * `containerId` against the modal's `targetContainerId`. The modal is + * presented within the bounds of the provider whose `containerId` equals + * the modal's `targetContainerId`. * * @platform ios */ - providerKey: string; + containerId: string; } diff --git a/src/fabric/gamma/modals/contained-modal/ContainedModalHostNativeComponent.ts b/src/fabric/gamma/modals/contained-modal/ContainedModalHostNativeComponent.ts index 9a2b2d3014..4d76e45ece 100644 --- a/src/fabric/gamma/modals/contained-modal/ContainedModalHostNativeComponent.ts +++ b/src/fabric/gamma/modals/contained-modal/ContainedModalHostNativeComponent.ts @@ -5,7 +5,7 @@ import type { CodegenTypes as CT, ViewProps } from 'react-native'; export interface NativeProps extends ViewProps { isOpen?: CT.WithDefault; - providerKey?: CT.WithDefault; + targetContainerId?: CT.WithDefault; } export default codegenNativeComponent('RNSContainedModalHost', { diff --git a/src/fabric/gamma/modals/contained-modal/ContainedModalProviderNativeComponent.ts b/src/fabric/gamma/modals/contained-modal/ContainedModalProviderNativeComponent.ts index 7deb4a6a25..5d1e0ab2cb 100644 --- a/src/fabric/gamma/modals/contained-modal/ContainedModalProviderNativeComponent.ts +++ b/src/fabric/gamma/modals/contained-modal/ContainedModalProviderNativeComponent.ts @@ -4,7 +4,7 @@ import { codegenNativeComponent } from 'react-native'; import type { CodegenTypes as CT, ViewProps } from 'react-native'; export interface NativeProps extends ViewProps { - providerKey?: CT.WithDefault; + containerId?: CT.WithDefault; } export default codegenNativeComponent( From 3421d9c9e498aeb5b34b3eeb36b1cabaa5d23510 Mon Sep 17 00:00:00 2001 From: Szymon Gaczol Date: Thu, 18 Jun 2026 12:08:35 +0200 Subject: [PATCH 17/25] comment update --- .../gamma/modals/contained-modal/ContainedModal.types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/gamma/modals/contained-modal/ContainedModal.types.ts b/src/components/gamma/modals/contained-modal/ContainedModal.types.ts index 34a8bbf829..0abcad7e8e 100644 --- a/src/components/gamma/modals/contained-modal/ContainedModal.types.ts +++ b/src/components/gamma/modals/contained-modal/ContainedModal.types.ts @@ -6,7 +6,7 @@ export interface ContainedModalProps { * @summary Determines whether the contained modal is currently visible. * * Presentation is driven by state transitions: updating this property - * from `false` to `true` triggers the sheet to present, while changing + * from `false` to `true` triggers the modal to present, while changing * it from `true` to `false` triggers a programmatic dismissal. * * @platform ios From 17425120fd1a82597515930b0760b18246923fe3 Mon Sep 17 00:00:00 2001 From: Szymon Gaczol Date: Thu, 18 Jun 2026 13:19:30 +0200 Subject: [PATCH 18/25] add RNSContainedModal on native side --- ...RNSContainedModalHostComponentDescriptor.h | 44 ++++ .../RNSContainedModalHostShadowNode.cpp | 17 ++ .../RNSContainedModalHostShadowNode.h | 29 +++ .../rnscreens/RNSContainedModalHostState.h | 21 ++ ...RNSContainedModalConfigurationApplicator.h | 15 ++ ...NSContainedModalConfigurationApplicator.mm | 5 + .../RNSContainedModalContentController.h | 36 +++ .../RNSContainedModalContentController.mm | 106 ++++++++ .../RNSContainedModalContentView.h | 14 + .../RNSContainedModalContentView.mm | 30 +++ .../RNSContainedModalHostComponentView.h | 20 ++ .../RNSContainedModalHostComponentView.mm | 244 ++++++++++++++++++ .../RNSContainedModalHostShadowStateProxy.h | 22 ++ .../RNSContainedModalHostShadowStateProxy.mm | 44 ++++ .../RNSContainedModalPresentationManager.h | 19 ++ .../RNSContainedModalPresentationManager.mm | 131 ++++++++++ .../RNSContainedModalPresentationState.h | 10 + .../RNSContainedModalProviderComponentView.h | 19 ++ .../RNSContainedModalProviderComponentView.mm | 117 +++++++++ .../RNSContainedModalProviderController.h | 11 + .../RNSContainedModalProviderController.mm | 20 ++ .../RNSContainedModalProviders.h | 19 ++ .../RNSContainedModalUpdateCoordinator.h | 21 ++ .../RNSContainedModalUpdateCoordinator.mm | 52 ++++ .../RNSContainedModalUpdateFlags.h | 13 + ios/stubs/RNSGammaStubs.h | 6 + ios/stubs/RNSGammaStubs.mm | 6 + 27 files changed, 1091 insertions(+) create mode 100644 common/cpp/react/renderer/components/rnscreens/RNSContainedModalHostComponentDescriptor.h create mode 100644 common/cpp/react/renderer/components/rnscreens/RNSContainedModalHostShadowNode.cpp create mode 100644 common/cpp/react/renderer/components/rnscreens/RNSContainedModalHostShadowNode.h create mode 100644 common/cpp/react/renderer/components/rnscreens/RNSContainedModalHostState.h create mode 100644 ios/gamma/modals/contained-modal/RNSContainedModalConfigurationApplicator.h create mode 100644 ios/gamma/modals/contained-modal/RNSContainedModalConfigurationApplicator.mm create mode 100644 ios/gamma/modals/contained-modal/RNSContainedModalContentController.h create mode 100644 ios/gamma/modals/contained-modal/RNSContainedModalContentController.mm create mode 100644 ios/gamma/modals/contained-modal/RNSContainedModalContentView.h create mode 100644 ios/gamma/modals/contained-modal/RNSContainedModalContentView.mm create mode 100644 ios/gamma/modals/contained-modal/RNSContainedModalHostComponentView.h create mode 100644 ios/gamma/modals/contained-modal/RNSContainedModalHostComponentView.mm create mode 100644 ios/gamma/modals/contained-modal/RNSContainedModalHostShadowStateProxy.h create mode 100644 ios/gamma/modals/contained-modal/RNSContainedModalHostShadowStateProxy.mm create mode 100644 ios/gamma/modals/contained-modal/RNSContainedModalPresentationManager.h create mode 100644 ios/gamma/modals/contained-modal/RNSContainedModalPresentationManager.mm create mode 100644 ios/gamma/modals/contained-modal/RNSContainedModalPresentationState.h create mode 100644 ios/gamma/modals/contained-modal/RNSContainedModalProviderComponentView.h create mode 100644 ios/gamma/modals/contained-modal/RNSContainedModalProviderComponentView.mm create mode 100644 ios/gamma/modals/contained-modal/RNSContainedModalProviderController.h create mode 100644 ios/gamma/modals/contained-modal/RNSContainedModalProviderController.mm create mode 100644 ios/gamma/modals/contained-modal/RNSContainedModalProviders.h create mode 100644 ios/gamma/modals/contained-modal/RNSContainedModalUpdateCoordinator.h create mode 100644 ios/gamma/modals/contained-modal/RNSContainedModalUpdateCoordinator.mm create mode 100644 ios/gamma/modals/contained-modal/RNSContainedModalUpdateFlags.h diff --git a/common/cpp/react/renderer/components/rnscreens/RNSContainedModalHostComponentDescriptor.h b/common/cpp/react/renderer/components/rnscreens/RNSContainedModalHostComponentDescriptor.h new file mode 100644 index 0000000000..c79c8c79ba --- /dev/null +++ b/common/cpp/react/renderer/components/rnscreens/RNSContainedModalHostComponentDescriptor.h @@ -0,0 +1,44 @@ +#pragma once + +#if !defined(ANDROID) + +#include +#include +#include "RNSContainedModalHostShadowNode.h" + +namespace facebook::react { + +class RNSContainedModalHostComponentDescriptor final + : public ConcreteComponentDescriptor { + public: + using ConcreteComponentDescriptor::ConcreteComponentDescriptor; + + void adopt(ShadowNode &shadowNode) const override { + react_native_assert( + dynamic_cast(&shadowNode)); + auto &concreteShadowNode = + static_cast(shadowNode); + + react_native_assert( + dynamic_cast(&concreteShadowNode)); + auto &layoutableShadowNode = + static_cast(concreteShadowNode); + + auto state = std::static_pointer_cast< + const RNSContainedModalHostShadowNode::ConcreteState>( + shadowNode.getState()); + + auto stateData = state->getData(); + + if (stateData.frameSize.width > 0 && stateData.frameSize.height > 0) { + layoutableShadowNode.setSize( + Size{stateData.frameSize.width, stateData.frameSize.height}); + } + + ConcreteComponentDescriptor::adopt(shadowNode); + } +}; + +} // namespace facebook::react + +#endif // !defined(ANDROID) diff --git a/common/cpp/react/renderer/components/rnscreens/RNSContainedModalHostShadowNode.cpp b/common/cpp/react/renderer/components/rnscreens/RNSContainedModalHostShadowNode.cpp new file mode 100644 index 0000000000..b0ff395e32 --- /dev/null +++ b/common/cpp/react/renderer/components/rnscreens/RNSContainedModalHostShadowNode.cpp @@ -0,0 +1,17 @@ +#include "RNSContainedModalHostShadowNode.h" + +#if !defined(ANDROID) + +namespace facebook::react { + +extern const char RNSContainedModalHostComponentName[] = + "RNSContainedModalHost"; + +Point RNSContainedModalHostShadowNode::getContentOriginOffset( + bool /*includeTransform*/) const { + return getStateData().contentOffset; +} + +} // namespace facebook::react + +#endif // !defined(ANDROID) diff --git a/common/cpp/react/renderer/components/rnscreens/RNSContainedModalHostShadowNode.h b/common/cpp/react/renderer/components/rnscreens/RNSContainedModalHostShadowNode.h new file mode 100644 index 0000000000..141c95600f --- /dev/null +++ b/common/cpp/react/renderer/components/rnscreens/RNSContainedModalHostShadowNode.h @@ -0,0 +1,29 @@ +#pragma once + +#if !defined(ANDROID) + +#include +#include +#include +#include +#include "RNSContainedModalHostState.h" + +namespace facebook::react { + +JSI_EXPORT extern const char RNSContainedModalHostComponentName[]; + +class JSI_EXPORT RNSContainedModalHostShadowNode final + : public ConcreteViewShadowNode< + RNSContainedModalHostComponentName, + RNSContainedModalHostProps, + RNSContainedModalHostEventEmitter, + RNSContainedModalHostState> { + public: + using ConcreteViewShadowNode::ConcreteViewShadowNode; + + Point getContentOriginOffset(bool includeTransform) const override; +}; + +} // namespace facebook::react + +#endif // !defined(ANDROID) diff --git a/common/cpp/react/renderer/components/rnscreens/RNSContainedModalHostState.h b/common/cpp/react/renderer/components/rnscreens/RNSContainedModalHostState.h new file mode 100644 index 0000000000..acd37e5ebe --- /dev/null +++ b/common/cpp/react/renderer/components/rnscreens/RNSContainedModalHostState.h @@ -0,0 +1,21 @@ +#pragma once + +#if !defined(ANDROID) + +#include + +namespace facebook::react { + +class JSI_EXPORT RNSContainedModalHostState final { + public: + RNSContainedModalHostState() = default; + RNSContainedModalHostState(Size frameSize, Point contentOffset) + : frameSize(frameSize), contentOffset(contentOffset) {} + + Size frameSize{}; + Point contentOffset{}; +}; + +} // namespace facebook::react + +#endif // !defined(ANDROID) diff --git a/ios/gamma/modals/contained-modal/RNSContainedModalConfigurationApplicator.h b/ios/gamma/modals/contained-modal/RNSContainedModalConfigurationApplicator.h new file mode 100644 index 0000000000..15f2f26927 --- /dev/null +++ b/ios/gamma/modals/contained-modal/RNSContainedModalConfigurationApplicator.h @@ -0,0 +1,15 @@ +#pragma once + +#import +#import "RNSContainedModalProviders.h" + +@class RNSContainedModalUpdateCoordinator; +@class RNSContainedModalContentController; + +NS_ASSUME_NONNULL_BEGIN + +@interface RNSContainedModalConfigurationApplicator : NSObject + +@end + +NS_ASSUME_NONNULL_END diff --git a/ios/gamma/modals/contained-modal/RNSContainedModalConfigurationApplicator.mm b/ios/gamma/modals/contained-modal/RNSContainedModalConfigurationApplicator.mm new file mode 100644 index 0000000000..b21a99905c --- /dev/null +++ b/ios/gamma/modals/contained-modal/RNSContainedModalConfigurationApplicator.mm @@ -0,0 +1,5 @@ +#import "RNSContainedModalConfigurationApplicator.h" + +@implementation RNSContainedModalConfigurationApplicator + +@end diff --git a/ios/gamma/modals/contained-modal/RNSContainedModalContentController.h b/ios/gamma/modals/contained-modal/RNSContainedModalContentController.h new file mode 100644 index 0000000000..506a8d592a --- /dev/null +++ b/ios/gamma/modals/contained-modal/RNSContainedModalContentController.h @@ -0,0 +1,36 @@ +#pragma once + +#import +#import "RNSContainedModalProviders.h" + +NS_ASSUME_NONNULL_BEGIN + +@class RNSContainedModalContentView; +@class RNSContainedModalContentController; + +@protocol RNSContainedModalContentControllerDelegate +- (void)modalControllerViewDidLayoutSubviews:(RNSContainedModalContentController *)controller; +- (void)modalControllerViewDidDisappear:(RNSContainedModalContentController *)controller; +@end + +@interface RNSContainedModalContentController : UIViewController + +@property (nonatomic, weak, nullable) id delegate; + +@property (nonatomic, weak, nullable) id presentationProvider; + +@property (nonatomic, readonly, nonnull) RNSContainedModalContentView *contentView; + +#pragma mark - Signals + +- (void)setNeedsPresentationUpdate; +- (void)setNeedsBehaviorUpdate; +- (void)setNeedsAppearanceUpdate; + +#pragma mark - Updates + +- (void)flushPendingUpdates; + +@end + +NS_ASSUME_NONNULL_END diff --git a/ios/gamma/modals/contained-modal/RNSContainedModalContentController.mm b/ios/gamma/modals/contained-modal/RNSContainedModalContentController.mm new file mode 100644 index 0000000000..8961738bf8 --- /dev/null +++ b/ios/gamma/modals/contained-modal/RNSContainedModalContentController.mm @@ -0,0 +1,106 @@ +#import "RNSContainedModalContentController.h" +#import "RNSContainedModalConfigurationApplicator.h" +#import "RNSContainedModalContentView.h" +#import "RNSContainedModalPresentationManager.h" +#import "RNSContainedModalUpdateCoordinator.h" +#import "RNSContainedModalUpdateFlags.h" + +#import + +@implementation RNSContainedModalContentController { + RNSContainedModalUpdateCoordinator *_Nonnull _updateCoordinator; + RNSContainedModalConfigurationApplicator *_Nonnull _configurationApplicator; + RNSContainedModalPresentationManager *_Nonnull _presentationManager; +} + +- (instancetype)init +{ + if (self = [super init]) { + // TODO: expose these as props - the transition animation and the presentation style + // (UIModalPresentationOverCurrentContext vs UIModalPresentationCurrentContext should be configurable from JS). + self.modalPresentationStyle = UIModalPresentationOverCurrentContext; + self.modalTransitionStyle = UIModalTransitionStyleCrossDissolve; + + _updateCoordinator = [RNSContainedModalUpdateCoordinator new]; + _configurationApplicator = [RNSContainedModalConfigurationApplicator new]; + _presentationManager = [RNSContainedModalPresentationManager new]; + } + return self; +} + +- (RNSContainedModalContentView *)contentView +{ + RCTAssert([self.view isKindOfClass:[RNSContainedModalContentView class]], + @"[RNScreens] ContentView must be of type RNSContainedModalContentView"); + return static_cast(self.view); +} + +#pragma mark - UIKit callbacks + +- (void)loadView +{ + self.view = [RNSContainedModalContentView new]; +} + +- (void)viewDidLayoutSubviews +{ + [super viewDidLayoutSubviews]; + + [self.delegate modalControllerViewDidLayoutSubviews:self]; +} + +- (void)viewDidDisappear:(BOOL)animated +{ + [super viewDidDisappear:animated]; + + [self.delegate modalControllerViewDidDisappear:self]; +} + +#pragma mark - Presentation Setup + +- (void)updatePresentationIfNeeded +{ + [_updateCoordinator updateIfNeeded:RNSContainedModalUpdateFlagsPresentation + performOperations:^{ + [self updatePresentationState]; + }]; +} + +- (void)updatePresentationState +{ + id presentationProvider = self.presentationProvider; + + RCTAssert(presentationProvider != nil, + @"[RNScreens] Presentation provider must be set before updating presentation state."); + + if (presentationProvider == nil) { + return; + } + + [_presentationManager updatePresentationIfNeededWithProvider:presentationProvider controller:self]; +} + +#pragma mark - Signals + +- (void)setNeedsPresentationUpdate +{ + [_updateCoordinator setNeeds:RNSContainedModalUpdateFlagsPresentation]; +} + +- (void)setNeedsAppearanceUpdate +{ + [_updateCoordinator setNeeds:RNSContainedModalUpdateFlagsAppearance]; +} + +- (void)setNeedsBehaviorUpdate +{ + [_updateCoordinator setNeeds:RNSContainedModalUpdateFlagsBehavior]; +} + +#pragma mark - Updates + +- (void)flushPendingUpdates +{ + [self updatePresentationIfNeeded]; +} +@end diff --git a/ios/gamma/modals/contained-modal/RNSContainedModalContentView.h b/ios/gamma/modals/contained-modal/RNSContainedModalContentView.h new file mode 100644 index 0000000000..9fa17aca17 --- /dev/null +++ b/ios/gamma/modals/contained-modal/RNSContainedModalContentView.h @@ -0,0 +1,14 @@ +#pragma once + +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface RNSContainedModalContentView : UIView + +- (void)insertReactSubview:(UIView *)subview atIndex:(NSInteger)index; +- (void)removeReactSubview:(UIView *)subview; + +@end + +NS_ASSUME_NONNULL_END diff --git a/ios/gamma/modals/contained-modal/RNSContainedModalContentView.mm b/ios/gamma/modals/contained-modal/RNSContainedModalContentView.mm new file mode 100644 index 0000000000..b606c3adb4 --- /dev/null +++ b/ios/gamma/modals/contained-modal/RNSContainedModalContentView.mm @@ -0,0 +1,30 @@ +#import "RNSContainedModalContentView.h" + +@implementation RNSContainedModalContentView + +- (instancetype)initWithFrame:(CGRect)frame +{ + if (self = [super initWithFrame:frame]) { + // Explicitly set to clearColor since this UIView is manually added + // into the view hierarchy. This ensures it doesn't interfere with + // any background colors defined by child React subviews. + self.backgroundColor = [UIColor clearColor]; + // Ensure the view stretches to fill the presentation context + self.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; + } + return self; +} + +#pragma mark - RN Subviews Management + +- (void)insertReactSubview:(UIView *)subview atIndex:(NSInteger)index +{ + [self insertSubview:subview atIndex:index]; +} + +- (void)removeReactSubview:(UIView *)subview +{ + [subview removeFromSuperview]; +} + +@end diff --git a/ios/gamma/modals/contained-modal/RNSContainedModalHostComponentView.h b/ios/gamma/modals/contained-modal/RNSContainedModalHostComponentView.h new file mode 100644 index 0000000000..95edf39c54 --- /dev/null +++ b/ios/gamma/modals/contained-modal/RNSContainedModalHostComponentView.h @@ -0,0 +1,20 @@ +#pragma once + +#import "RNSReactBaseView.h" + +NS_ASSUME_NONNULL_BEGIN + +@interface RNSContainedModalHostComponentView : RNSReactBaseView + +@end + +#pragma mark - Props + +@interface RNSContainedModalHostComponentView () + +@property (nonatomic, readonly) BOOL isOpen; +@property (nonatomic, copy, readonly, nullable) NSString *targetContainerId; + +@end + +NS_ASSUME_NONNULL_END diff --git a/ios/gamma/modals/contained-modal/RNSContainedModalHostComponentView.mm b/ios/gamma/modals/contained-modal/RNSContainedModalHostComponentView.mm new file mode 100644 index 0000000000..3a3b041a74 --- /dev/null +++ b/ios/gamma/modals/contained-modal/RNSContainedModalHostComponentView.mm @@ -0,0 +1,244 @@ +#import "RNSContainedModalHostComponentView.h" +#import "RNSContainedModalContentController.h" +#import "RNSContainedModalContentView.h" +#import "RNSContainedModalHostShadowStateProxy.h" +#import "RNSContainedModalProviderComponentView.h" +#import "RNSContainedModalProviders.h" + +#import +#import +#import +#import +#import + +namespace react = facebook::react; + +@interface RNSContainedModalHostComponentView () +@end + +@implementation RNSContainedModalHostComponentView { + RNSContainedModalHostShadowStateProxy *_Nonnull _shadowStateProxy; + + RNSContainedModalContentController *_controller; + NSString *_targetContainerId; + RCTSurfaceTouchHandler *_Nullable _touchHandler; + BOOL _isOpen; +} + +- (instancetype)initWithFrame:(CGRect)frame +{ + if (self = [super initWithFrame:frame]) { + [self initState]; + } + return self; +} + +- (void)initState +{ + [self resetProps]; + [self setupController]; + + _shadowStateProxy = [RNSContainedModalHostShadowStateProxy new]; +} + +- (void)resetProps +{ + static const auto defaultProps = std::make_shared(); + _props = defaultProps; + + _isOpen = NO; + _targetContainerId = nil; +} + +- (void)setupController +{ + _controller = [RNSContainedModalContentController new]; + _controller.delegate = self; + _controller.presentationProvider = self; +} + +- (void)didMoveToWindow +{ + [super didMoveToWindow]; + + if (self.window != nil) { + [_controller setNeedsPresentationUpdate]; + [_controller flushPendingUpdates]; + } +} + +#pragma mark - RNSContainedModalContentControllerDelegate + +- (void)modalControllerViewDidLayoutSubviews:(RNSContainedModalContentController *)controller +{ + [self syncTouchHandlerOrigin]; + [self syncShadowNodeState]; +} + +- (void)modalControllerViewDidDisappear:(RNSContainedModalContentController *)controller +{ + // The touch handler is attached to the controller's content view on present/layout + // Once the modal is dismissed, that content view is no longer on screen, so the + // handler must be detached. + [self detachTouchHandler]; +} + +#pragma mark - RNSContainedModalPresentationProvider + +- (nullable UIView *)hostView +{ + return self; +} + +- (nullable UIWindow *)hostWindow +{ + return self.window; +} + +#pragma mark - RCTComponentViewProtocol + +- (void)updateState:(const react::State::Shared &)state oldState:(const react::State::Shared &)oldState +{ + [super updateState:state oldState:oldState]; + [_shadowStateProxy updateState:state oldState:oldState]; +} + ++ (react::ComponentDescriptorProvider)componentDescriptorProvider +{ + return react::concreteComponentDescriptorProvider(); +} + ++ (BOOL)shouldBeRecycled +{ + return NO; +} + +- (void)mountChildComponentView:(UIView *)childComponentView index:(NSInteger)index +{ + [_controller.contentView insertReactSubview:childComponentView atIndex:index]; +} + +- (void)unmountChildComponentView:(UIView *)childComponentView index:(NSInteger)index +{ + [_controller.contentView removeReactSubview:childComponentView]; +} + +- (void)updateProps:(const react::Props::Shared &)props oldProps:(const react::Props::Shared &)oldProps +{ + const auto &oldComponentProps = *std::static_pointer_cast(_props); + const auto &newComponentProps = *std::static_pointer_cast(props); + + if (oldComponentProps.targetContainerId != newComponentProps.targetContainerId) { + _targetContainerId = RCTNSStringFromStringNilIfEmpty(newComponentProps.targetContainerId); + } + + if (oldComponentProps.isOpen != newComponentProps.isOpen) { + _isOpen = static_cast(newComponentProps.isOpen); + [_controller setNeedsPresentationUpdate]; + } + + // TODO: determine if _controller setNeedsApperance/BehaviorUpdate is necessary if _isOpen + + [super updateProps:props oldProps:oldProps]; +} + +- (void)invalidate +{ + [self detachTouchHandler]; + + if (_controller != nil) { + if (_controller.presentingViewController != nil) { + [_controller dismissViewControllerAnimated:NO completion:nil]; + } + _controller = nil; + } +} + +#pragma mark - RCTMountingTransactionObserving + +- (void)mountingTransactionDidMount:(const facebook::react::MountingTransaction &)transaction + withSurfaceTelemetry:(const facebook::react::SurfaceTelemetry &)surfaceTelemetry +{ + [_controller flushPendingUpdates]; +} + +#pragma mark - Touch Handling overrides + +- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event +{ + // The modal's React children are teleported into the presented controller's + // content view (RNSContainedModalContentView), which has its own touch handler. + // This host view itself is an empty, zero-sized anchor in the React tree, so it + // must never be a hit-test target - returning nil lets touches pass through to + // whatever is actually behind it instead of being swallowed by this empty view. + return nil; +} + +#pragma mark - Layout helpers + +- (void)syncShadowNodeState +{ + if (_controller == nil || _controller.contentView == nil) { + return; + } + + // contentOriginOffset is the vector from the host view's origin to the content view's origin, + // both expressed in window space. It offsets child layout positions to account for the fact that + // React children are mounted in a separate UIViewController hierarchy. + CGPoint contentViewOriginInWindow = [_controller.contentView convertPoint:CGPointZero toView:nil]; + CGPoint hostOriginInWindow = [self convertPoint:CGPointZero toView:nil]; + CGPoint contentOriginOffset = CGPointMake(contentViewOriginInWindow.x - hostOriginInWindow.x, + contentViewOriginInWindow.y - hostOriginInWindow.y); + + [_shadowStateProxy updateShadowStateWithBounds:_controller.contentView.bounds origin:contentOriginOffset]; +} + +#pragma mark - Touch Handling helpers + +- (void)syncTouchHandlerOrigin +{ + if (_controller == nil || _controller.contentView == nil) { + return; + } + + // Touch handler requires absolute positioning coordinates, relatively to root (UIWindow) + CGPoint contentViewOriginInWindow = [_controller.contentView convertPoint:CGPointZero toView:nil]; + [self updateTouchHandlerWithOrigin:contentViewOriginInWindow]; +} + +- (void)updateTouchHandlerWithOrigin:(CGPoint)origin +{ + if (_touchHandler == nil) { + _touchHandler = [RCTSurfaceTouchHandler new]; + [_touchHandler attachToView:_controller.contentView]; + } + + // Aligns touch coordinate space with window coordinate space + _touchHandler.viewOriginOffset = origin; +} + +- (void)detachTouchHandler +{ + if (_touchHandler != nil) { + [_touchHandler detachFromView:_controller.contentView]; + _touchHandler = nil; + } +} + +#pragma mark - Dynamic frameworks support + +#ifdef RCT_DYNAMIC_FRAMEWORKS ++ (void)load +{ + [super load]; +} +#endif // RCT_DYNAMIC_FRAMEWORKS + +@end + +Class RNSContainedModalHostCls(void) +{ + return RNSContainedModalHostComponentView.class; +} diff --git a/ios/gamma/modals/contained-modal/RNSContainedModalHostShadowStateProxy.h b/ios/gamma/modals/contained-modal/RNSContainedModalHostShadowStateProxy.h new file mode 100644 index 0000000000..d2c471002c --- /dev/null +++ b/ios/gamma/modals/contained-modal/RNSContainedModalHostShadowStateProxy.h @@ -0,0 +1,22 @@ +#pragma once + +#import + +#ifdef __cplusplus +#import +#endif + +NS_ASSUME_NONNULL_BEGIN + +@interface RNSContainedModalHostShadowStateProxy : NSObject + +#ifdef __cplusplus +- (void)updateState:(facebook::react::State::Shared const &)state + oldState:(facebook::react::State::Shared const &)oldState; +#endif + +- (void)updateShadowStateWithBounds:(CGRect)bounds origin:(CGPoint)origin; + +@end + +NS_ASSUME_NONNULL_END diff --git a/ios/gamma/modals/contained-modal/RNSContainedModalHostShadowStateProxy.mm b/ios/gamma/modals/contained-modal/RNSContainedModalHostShadowStateProxy.mm new file mode 100644 index 0000000000..f242cf6a47 --- /dev/null +++ b/ios/gamma/modals/contained-modal/RNSContainedModalHostShadowStateProxy.mm @@ -0,0 +1,44 @@ +#import "RNSContainedModalHostShadowStateProxy.h" + +#import +#import +#import + +namespace react = facebook::react; + +@implementation RNSContainedModalHostShadowStateProxy { + react::RNSContainedModalHostShadowNode::ConcreteState::Shared _state; + CGRect _lastScheduledFrame; +} + +- (instancetype)init +{ + if (self = [super init]) { + _lastScheduledFrame = CGRectNull; + } + return self; +} + +- (void)updateState:(react::State::Shared const &)state oldState:(react::State::Shared const &)oldState +{ + _state = std::static_pointer_cast(state); +} + +- (void)updateShadowStateWithBounds:(CGRect)bounds origin:(CGPoint)origin +{ + if (_state == nullptr) { + return; + } + + CGRect frame = {origin, bounds.size}; + + if (!CGRectEqualToRect(frame, _lastScheduledFrame)) { + auto newState = react::RNSContainedModalHostState{RCTSizeFromCGSize(bounds.size), RCTPointFromCGPoint(origin)}; + + _state->updateState(std::move(newState), facebook::react::EventQueue::UpdateMode::unstable_Immediate); + + _lastScheduledFrame = frame; + } +} + +@end diff --git a/ios/gamma/modals/contained-modal/RNSContainedModalPresentationManager.h b/ios/gamma/modals/contained-modal/RNSContainedModalPresentationManager.h new file mode 100644 index 0000000000..6cc2235c53 --- /dev/null +++ b/ios/gamma/modals/contained-modal/RNSContainedModalPresentationManager.h @@ -0,0 +1,19 @@ +#pragma once + +#import +#import "RNSContainedModalProviders.h" + +@class RNSContainedModalContentController; + +NS_ASSUME_NONNULL_BEGIN + +@interface RNSContainedModalPresentationManager : NSObject + +- (void)updatePresentationIfNeededWithProvider:(id)provider + controller:(RNSContainedModalContentController *)controller; + +- (void)handleNativeDismiss; + +@end + +NS_ASSUME_NONNULL_END diff --git a/ios/gamma/modals/contained-modal/RNSContainedModalPresentationManager.mm b/ios/gamma/modals/contained-modal/RNSContainedModalPresentationManager.mm new file mode 100644 index 0000000000..eeb46a88d9 --- /dev/null +++ b/ios/gamma/modals/contained-modal/RNSContainedModalPresentationManager.mm @@ -0,0 +1,131 @@ +#import "RNSContainedModalPresentationManager.h" +#import "RNSContainedModalContentController.h" +#import "RNSContainedModalPresentationState.h" +#import "RNSContainedModalProviderComponentView.h" + +#import + +@implementation RNSContainedModalPresentationManager { + RNSContainedModalPresentationState _state; + __weak RNSContainedModalProviderComponentView *_cachedProviderView; +} + +- (instancetype)init +{ + if (self = [super init]) { + _state = RNSContainedModalPresentationStateDismissed; + } + return self; +} + +- (void)updatePresentationIfNeededWithProvider:(id)provider + controller:(RNSContainedModalContentController *)controller +{ + BOOL shouldBeOpen = provider.isOpen; + + if (shouldBeOpen) { + [self presentIfNeededWithProvider:provider controller:controller]; + } else { + [self dismissIfNeededWithProvider:provider controller:controller]; + } +} + +#pragma mark - Provider resolution + +// A contained modal is presented from the provider whose `containerId` matches the +// modal's `targetContainerId`, so it stays contained within that provider's bounds. +- (nullable RNSContainedModalProviderComponentView *)findProviderViewForProvider: + (id)provider +{ + NSString *targetContainerId = [provider targetContainerId]; + + if (targetContainerId == nil) { + RCTAssert(NO, @"[RNScreens] ContainedModal has a nil targetContainerId; cannot resolve a provider to present in."); + return nil; + } + + UIView *currentView = [provider hostView]; + while (currentView != nil) { + if ([currentView isKindOfClass:[RNSContainedModalProviderComponentView class]]) { + RNSContainedModalProviderComponentView *providerView = (RNSContainedModalProviderComponentView *)currentView; + + if ([targetContainerId isEqualToString:providerView.containerId]) { + return providerView; + } + } + currentView = currentView.superview; + } + + RCTAssert(NO, @"[RNScreens] No ContainedModalProvider found matching targetContainerId '%@'.", targetContainerId); + return nil; +} + +- (void)presentIfNeededWithProvider:(id)provider + controller:(RNSContainedModalContentController *)controller +{ + if (_state != RNSContainedModalPresentationStateDismissed) { + return; + } + + // This resolves the provider only once - on the first presentation. + // The provider is an ancestor of the ContainedModal in the React tree, so it cannot + // be unmounted without also unmounting the modal. + if (_cachedProviderView == nil) { + _cachedProviderView = [self findProviderViewForProvider:provider]; + } + + UIViewController *presentationSourceViewController = _cachedProviderView.contextViewController; + if (presentationSourceViewController == nil) { + return; + } + + _state = RNSContainedModalPresentationStatePresenting; + + __weak auto weakSelf = self; + [presentationSourceViewController presentViewController:controller + animated:YES + completion:^{ + auto strongSelf = weakSelf; + if (!strongSelf) { + return; + } + + strongSelf->_state = RNSContainedModalPresentationStatePresented; + [strongSelf updatePresentationIfNeededWithProvider:provider + controller:controller]; + }]; +} + +- (void)dismissIfNeededWithProvider:(id)provider + controller:(RNSContainedModalContentController *)controller +{ + if (_state != RNSContainedModalPresentationStatePresented) { + return; + } + + if (controller.presentingViewController == nil) { + _state = RNSContainedModalPresentationStateDismissed; + return; + } + + _state = RNSContainedModalPresentationStateDismissing; + + __weak auto weakSelf = self; + [controller dismissViewControllerAnimated:YES + completion:^{ + auto strongSelf = weakSelf; + if (!strongSelf) { + return; + } + + strongSelf->_state = RNSContainedModalPresentationStateDismissed; + [strongSelf updatePresentationIfNeededWithProvider:provider controller:controller]; + }]; +} + +- (void)handleNativeDismiss +{ + _state = RNSContainedModalPresentationStateDismissed; +} + +@end diff --git a/ios/gamma/modals/contained-modal/RNSContainedModalPresentationState.h b/ios/gamma/modals/contained-modal/RNSContainedModalPresentationState.h new file mode 100644 index 0000000000..5dae190bc3 --- /dev/null +++ b/ios/gamma/modals/contained-modal/RNSContainedModalPresentationState.h @@ -0,0 +1,10 @@ +#pragma once + +#import + +typedef NS_ENUM(NSInteger, RNSContainedModalPresentationState) { + RNSContainedModalPresentationStateDismissed, + RNSContainedModalPresentationStateDismissing, + RNSContainedModalPresentationStatePresented, + RNSContainedModalPresentationStatePresenting +}; diff --git a/ios/gamma/modals/contained-modal/RNSContainedModalProviderComponentView.h b/ios/gamma/modals/contained-modal/RNSContainedModalProviderComponentView.h new file mode 100644 index 0000000000..a869865c0a --- /dev/null +++ b/ios/gamma/modals/contained-modal/RNSContainedModalProviderComponentView.h @@ -0,0 +1,19 @@ +#pragma once + +#import +#import "RNSReactBaseView.h" + +NS_ASSUME_NONNULL_BEGIN + +@interface RNSContainedModalProviderComponentView : RNSReactBaseView + +@property (nonatomic, strong, readonly) UIViewController *contextViewController; + +// Identifies this provider (container). A `ContainedModal` is presented in the +// provider whose `containerId` matches the modal's `targetContainerId`. +// `nil` when unset. +@property (nonatomic, copy, readonly, nullable) NSString *containerId; + +@end + +NS_ASSUME_NONNULL_END diff --git a/ios/gamma/modals/contained-modal/RNSContainedModalProviderComponentView.mm b/ios/gamma/modals/contained-modal/RNSContainedModalProviderComponentView.mm new file mode 100644 index 0000000000..5637d91f0a --- /dev/null +++ b/ios/gamma/modals/contained-modal/RNSContainedModalProviderComponentView.mm @@ -0,0 +1,117 @@ +#import "RNSContainedModalProviderComponentView.h" +#import "RNSContainedModalProviderController.h" + +#import +#import +#import +#import + +namespace react = facebook::react; + +@interface RNSContainedModalProviderComponentView () +@property (nonatomic, copy, readwrite, nullable) NSString *containerId; +@end + +@implementation RNSContainedModalProviderComponentView + +- (instancetype)initWithFrame:(CGRect)frame +{ + if (self = [super initWithFrame:frame]) { + static const auto defaultProps = std::make_shared(); + _props = defaultProps; + + _contextViewController = [[RNSContainedModalProviderController alloc] init]; + } + return self; +} + +- (void)layoutSubviews +{ + [super layoutSubviews]; + _contextViewController.view.frame = self.bounds; +} + +- (void)didMoveToWindow +{ + [super didMoveToWindow]; + + if (self.window != nil) { + [self reactAddControllerToClosestParent:_contextViewController]; + } else { + // When the host leaves the window (e.g. navigating back), detach the controller + // from its parent. Otherwise it keeps a stale parentViewController pointing at the + // already-removed screen controller, and on the next mount it would be presented + // from a detached view controller - producing a corrupt, mis-sized presentation. + [self reactRemoveControllerFromParent:_contextViewController]; + } +} + +- (void)reactAddControllerToClosestParent:(UIViewController *)controller +{ + if (controller.parentViewController != nil) { + return; + } + + UIView *parentView = (UIView *)self.reactSuperview; + while (parentView != nil) { + if (parentView.reactViewController != nil) { + [parentView.reactViewController addChildViewController:controller]; + [self addSubview:controller.view]; + [controller didMoveToParentViewController:parentView.reactViewController]; + break; + } + parentView = (UIView *)parentView.reactSuperview; + } +} + +- (void)reactRemoveControllerFromParent:(UIViewController *)controller +{ + if (controller.parentViewController == nil) { + return; + } + + [controller willMoveToParentViewController:nil]; + [controller.view removeFromSuperview]; + [controller removeFromParentViewController]; +} + +#pragma mark - RCTComponentViewProtocol + ++ (react::ComponentDescriptorProvider)componentDescriptorProvider +{ + return react::concreteComponentDescriptorProvider(); +} + ++ (BOOL)shouldBeRecycled +{ + return NO; +} + +- (void)mountChildComponentView:(UIView *)childComponentView index:(NSInteger)index +{ + [_contextViewController.view insertSubview:childComponentView atIndex:index]; +} + +- (void)unmountChildComponentView:(UIView *)childComponentView index:(NSInteger)index +{ + [childComponentView removeFromSuperview]; +} + +- (void)updateProps:(const react::Props::Shared &)props oldProps:(const react::Props::Shared &)oldProps +{ + const auto &oldComponentProps = *std::static_pointer_cast(_props); + const auto &newComponentProps = *std::static_pointer_cast(props); + + if (oldComponentProps.containerId != newComponentProps.containerId) { + self.containerId = RCTNSStringFromStringNilIfEmpty(newComponentProps.containerId); + } + + [super updateProps:props oldProps:oldProps]; +} + +@end + +Class RNSContainedModalProviderCls(void) +{ + return RNSContainedModalProviderComponentView.class; +} diff --git a/ios/gamma/modals/contained-modal/RNSContainedModalProviderController.h b/ios/gamma/modals/contained-modal/RNSContainedModalProviderController.h new file mode 100644 index 0000000000..6c62bcc56f --- /dev/null +++ b/ios/gamma/modals/contained-modal/RNSContainedModalProviderController.h @@ -0,0 +1,11 @@ +#pragma once + +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface RNSContainedModalProviderController : UIViewController + +@end + +NS_ASSUME_NONNULL_END diff --git a/ios/gamma/modals/contained-modal/RNSContainedModalProviderController.mm b/ios/gamma/modals/contained-modal/RNSContainedModalProviderController.mm new file mode 100644 index 0000000000..6282e142e1 --- /dev/null +++ b/ios/gamma/modals/contained-modal/RNSContainedModalProviderController.mm @@ -0,0 +1,20 @@ +#import "RNSContainedModalProviderController.h" + +@implementation RNSContainedModalProviderController + +- (instancetype)init +{ + if (self = [super init]) { + self.definesPresentationContext = YES; + } + return self; +} + +- (void)loadView +{ + UIView *view = [[UIView alloc] init]; + view.backgroundColor = [UIColor clearColor]; + self.view = view; +} + +@end diff --git a/ios/gamma/modals/contained-modal/RNSContainedModalProviders.h b/ios/gamma/modals/contained-modal/RNSContainedModalProviders.h new file mode 100644 index 0000000000..ff27027236 --- /dev/null +++ b/ios/gamma/modals/contained-modal/RNSContainedModalProviders.h @@ -0,0 +1,19 @@ +#pragma once + +#import + +NS_ASSUME_NONNULL_BEGIN + +@protocol RNSContainedModalPresentationProvider + +- (BOOL)isOpen; +- (nullable UIView *)hostView; +- (nullable UIWindow *)hostWindow; + +// The id of the container (provider) this modal should be presented in. +// Matched against `RNSContainedModalProviderComponentView.containerId`. +- (nullable NSString *)targetContainerId; + +@end + +NS_ASSUME_NONNULL_END diff --git a/ios/gamma/modals/contained-modal/RNSContainedModalUpdateCoordinator.h b/ios/gamma/modals/contained-modal/RNSContainedModalUpdateCoordinator.h new file mode 100644 index 0000000000..f11e87d707 --- /dev/null +++ b/ios/gamma/modals/contained-modal/RNSContainedModalUpdateCoordinator.h @@ -0,0 +1,21 @@ +#pragma once + +#import + +#import "RNSContainedModalUpdateFlags.h" + +NS_ASSUME_NONNULL_BEGIN + +@interface RNSContainedModalUpdateCoordinator : NSObject + +- (void)setNeeds:(RNSContainedModalUpdateFlags)flags; + +- (BOOL)needsAll:(RNSContainedModalUpdateFlags)flags; +- (BOOL)needsAny:(RNSContainedModalUpdateFlags)flags; + +- (void)updateIfNeeded:(RNSContainedModalUpdateFlags)flags performOperations:(dispatch_block_t)block; +- (void)updateIfAnyNeeded:(RNSContainedModalUpdateFlags)flags performOperations:(dispatch_block_t)block; + +@end + +NS_ASSUME_NONNULL_END diff --git a/ios/gamma/modals/contained-modal/RNSContainedModalUpdateCoordinator.mm b/ios/gamma/modals/contained-modal/RNSContainedModalUpdateCoordinator.mm new file mode 100644 index 0000000000..038ce5943a --- /dev/null +++ b/ios/gamma/modals/contained-modal/RNSContainedModalUpdateCoordinator.mm @@ -0,0 +1,52 @@ +#import "RNSContainedModalUpdateCoordinator.h" + +@implementation RNSContainedModalUpdateCoordinator { + RNSContainedModalUpdateFlags _updateFlags; +} + +- (instancetype)init +{ + if (self = [super init]) { + _updateFlags = RNSContainedModalUpdateFlagsNone; + } + return self; +} + +- (void)setNeeds:(RNSContainedModalUpdateFlags)flags +{ + _updateFlags |= flags; +} + +- (BOOL)needsAll:(RNSContainedModalUpdateFlags)flags +{ + if (flags == RNSContainedModalUpdateFlagsNone) { + return NO; + } + return (_updateFlags & flags) == flags; +} + +- (BOOL)needsAny:(RNSContainedModalUpdateFlags)flags +{ + if (flags == RNSContainedModalUpdateFlagsNone) { + return NO; + } + return (_updateFlags & flags) != 0; +} + +- (void)updateIfNeeded:(RNSContainedModalUpdateFlags)flags performOperations:(dispatch_block_t)block +{ + if ([self needsAll:flags]) { + block(); + _updateFlags &= ~flags; + } +} + +- (void)updateIfAnyNeeded:(RNSContainedModalUpdateFlags)flags performOperations:(dispatch_block_t)block +{ + if ([self needsAny:flags]) { + block(); + _updateFlags &= ~flags; + } +} + +@end diff --git a/ios/gamma/modals/contained-modal/RNSContainedModalUpdateFlags.h b/ios/gamma/modals/contained-modal/RNSContainedModalUpdateFlags.h new file mode 100644 index 0000000000..e28a394d17 --- /dev/null +++ b/ios/gamma/modals/contained-modal/RNSContainedModalUpdateFlags.h @@ -0,0 +1,13 @@ +#pragma once + +#import + +typedef NS_OPTIONS(NSUInteger, RNSContainedModalUpdateFlags) { + RNSContainedModalUpdateFlagsNone = 0, + // The modal needs to be presented or dismissed to match the requested open state. + RNSContainedModalUpdateFlagsPresentation = 1 << 0, + // The modals's visual appearance configuration needs to be re-applied. + RNSContainedModalUpdateFlagsAppearance = 1 << 1, + // The modal's behavioral layout needs to be re-applied. + RNSContainedModalUpdateFlagsBehavior = 1 << 2, +}; diff --git a/ios/stubs/RNSGammaStubs.h b/ios/stubs/RNSGammaStubs.h index ce49c9ae03..18392735cd 100644 --- a/ios/stubs/RNSGammaStubs.h +++ b/ios/stubs/RNSGammaStubs.h @@ -27,6 +27,12 @@ NS_ASSUME_NONNULL_BEGIN @interface RNSFormSheetHostComponentView : NSObject @end +@interface RNSContainedModalHostComponentView : NSObject +@end + +@interface RNSContainedModalProviderHostComponentView : NSObject +@end + @interface RNSStackHeaderConfigComponentView : NSObject @end diff --git a/ios/stubs/RNSGammaStubs.mm b/ios/stubs/RNSGammaStubs.mm index 7b168c4892..4cbfab37eb 100644 --- a/ios/stubs/RNSGammaStubs.mm +++ b/ios/stubs/RNSGammaStubs.mm @@ -18,6 +18,12 @@ @implementation RNSScrollViewMarkerComponentView @implementation RNSFormSheetHostComponentView @end +@implementation RNSContainedModalHostComponentView +@end + +@implementation RNSContainedModalProviderHostComponentView +@end + @implementation RNSFormSheetContentWrapperComponentView @end From cab800c58a79c4b1ab96835e80808113a915fd64 Mon Sep 17 00:00:00 2001 From: Szymon Gaczol Date: Thu, 18 Jun 2026 13:20:04 +0200 Subject: [PATCH 19/25] added a single feature test --- .../contained-modal/index.ts | 14 ++ .../test-contained-modal-base-ios/index.tsx | 141 ++++++++++++++++++ .../scenario-description.ts | 11 ++ .../test-contained-modal-base-ios/scenario.md | 92 ++++++++++++ apps/src/tests/single-feature-tests/index.tsx | 2 + 5 files changed, 260 insertions(+) create mode 100644 apps/src/tests/single-feature-tests/contained-modal/index.ts create mode 100644 apps/src/tests/single-feature-tests/contained-modal/test-contained-modal-base-ios/index.tsx create mode 100644 apps/src/tests/single-feature-tests/contained-modal/test-contained-modal-base-ios/scenario-description.ts create mode 100644 apps/src/tests/single-feature-tests/contained-modal/test-contained-modal-base-ios/scenario.md diff --git a/apps/src/tests/single-feature-tests/contained-modal/index.ts b/apps/src/tests/single-feature-tests/contained-modal/index.ts new file mode 100644 index 0000000000..49213f790c --- /dev/null +++ b/apps/src/tests/single-feature-tests/contained-modal/index.ts @@ -0,0 +1,14 @@ +import type { ScenarioGroup } from '@apps/tests/shared/helpers'; +import TestContainedModalBase from './test-contained-modal-base-ios'; + +const scenarios = { + TestContainedModalBase, +}; + +const ContainedModalScenarioGroup: ScenarioGroup = { + name: 'ContainedModal', + details: 'Single feature tests for ContainedModals', + scenarios, +}; + +export default ContainedModalScenarioGroup; diff --git a/apps/src/tests/single-feature-tests/contained-modal/test-contained-modal-base-ios/index.tsx b/apps/src/tests/single-feature-tests/contained-modal/test-contained-modal-base-ios/index.tsx new file mode 100644 index 0000000000..301cf08789 --- /dev/null +++ b/apps/src/tests/single-feature-tests/contained-modal/test-contained-modal-base-ios/index.tsx @@ -0,0 +1,141 @@ +import React, { useState } from 'react'; +import { Button, Pressable, StyleSheet, Text, View } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { + ContainedModal, + ContainedModalProvider, +} from 'react-native-screens/experimental'; +import { scenarioDescription } from './scenario-description'; +import { createScenario } from '@apps/tests/shared/helpers'; +import { Colors } from '@apps/shared/styling'; + +const CONTAINER_ID = 'contained-modal-base'; + +export function App() { + const insets = useSafeAreaInsets(); + const [isOpen, setIsOpen] = useState(false); + const [partialProvider, setPartialProvider] = useState(false); + const [insideCount, setInsideCount] = useState(0); + const [outsideCount, setOutsideCount] = useState(0); + + return ( + + + ContainedModal Test +