Skip to content

feat(iOS, ContainedModal): Add basic setup for ContainedModal native component#4190

Open
sgaczol wants to merge 25 commits into
mainfrom
@sgaczol/contained-modal
Open

feat(iOS, ContainedModal): Add basic setup for ContainedModal native component#4190
sgaczol wants to merge 25 commits into
mainfrom
@sgaczol/contained-modal

Conversation

@sgaczol

@sgaczol sgaczol commented Jun 18, 2026

Copy link
Copy Markdown
Collaborator

Description

Introducing the iOS implementation of the standalone ContainedModal component, together with its ContainedModalProvider.

A ContainedModal is presented within the bounds of a provider instead of over the whole window. The two are matched by id: the modal's targetContainerId is compared against a provider's containerId, and the modal is presented inside the matching provider's frame. This makes it possible to scope a modal to an arbitrary region of the screen (e.g. one column of a split layout) rather than the full screen.

Key architectural decisions:

  • On the JS side, the ContainedModal host is a zero-sized, absolutely-positioned logical anchor — it never participates in layout of its siblings. The actual React children are "teleported" and mounted inside a separate UIViewController hierarchy (RNSContainedModalContentController / RNSContainedModalContentView), so the host's hitTest: always returns nil and never intercepts touches meant for the content behind it.
  • The ContainedModalProvider is, by contrast, a regular space-filling container. Its bounds define the presentation area, and it hosts the UIViewController the modal is presented from (resolved once, lazily, by walking up to the provider whose containerId matches).
  • Presentation is state-driven: flipping isOpen from false → true presents the modal, and true → false dismisses it. The present/dismiss lifecycle and its state machine live in RNSContainedModalPresentationManager, kept separate from the controller and the component view.
  • Presentation style is UIModalPresentationOverCurrentContext with a CrossDissolve transition and a transparent content background, so the provider's content stays visible behind the presented modal.

This mirrors the architecture established for the standalone FormSheet component (#3947), reusing the same host-anchor + teleported-content + presentation-manager split.

Note

ContainedModal is currently an iOS-only component. The Android and Web entry points are present but no-ops for now. It is exported from react-native-screens/experimental.

Closes https://github.com/software-mansion/react-native-screens-labs/issues/1359
Closes https://github.com/software-mansion/react-native-screens-labs/issues/1361
Closes https://github.com/software-mansion/react-native-screens-labs/issues/1364
Closes https://github.com/software-mansion/react-native-screens-labs/issues/1367
Closes https://github.com/software-mansion/react-native-screens-labs/issues/1356

Changes

  • JS API & exports
    • Added ContainedModal (isOpen, targetContainerId) and ContainedModalProvider (containerId, style) components, types, and platform splits (.tsx, .android.tsx, .web.tsx).
    • Exposed both components via src/experimental.
  • Codegen
    • Added Fabric native-component specs: ContainedModalHostNativeComponent and ContainedModalProviderNativeComponent.
  • C++ / shared layer
    • Added the host shadow node, state, and component descriptor (RNSContainedModalHostShadowNode, RNSContainedModalHostState, RNSContainedModalHostComponentDescriptor).
  • iOS native implementation (ios/gamma/modals/contained-modal/)
    • Host & provider component views, content view & content controller, providers protocol.
    • Presentation manager (present/dismiss state machine), update coordinator & flags, configuration applicator, shadow-state proxy.
    • Wired the gamma stubs (RNSGammaStubs).
  • Touch handling
    • The content view's RCTSurfaceTouchHandler is attached lazily on present/layout and detached on viewDidDisappear: (via the controller→host delegate), in addition to invalidate. This prevents a dismissed modal from leaving behind a dead touch zone that silently swallows taps on the content behind it, and it re-attaches correctly on re-present.
  • Build
    • Minor package.json wiring for the new sources.

Caution

Dynamic content-origin updates aren't supported in the context of synchronization with the ShadowTree state. If an ancestor's offset changes, the frame of our host view might not be updated at all — from the host's perspective the frame doesn't change (syncShadowNodeState only schedules a new state update when the computed origin or content bounds actually change), so no content re-layout is triggered.

Note

The JavaScript files unrelated to the single-feature test (a separate batch of .js/.ts changes) are tracked in their own PR — see #4171.

Scaffolding for future work

To keep this PR focused on the core present/dismiss flow, several pieces are deliberately included as scaffolding for upcoming appearance/behavior props (mirroring the FormSheet structure) but are not wired up yet:

  • RNSContainedModalConfigurationApplicator — currently an empty class (no methods). It is instantiated in the controller but never invoked. It will host the appearance/behavior configuration logic, analogous to RNSFormSheetConfigurationApplicator.
  • RNSContainedModalUpdateFlagsRNSContainedModalUpdateFlagsAppearance and RNSContainedModalUpdateFlagsBehavior are defined but never set on a live update path. Only …Presentation is actually driven today (from didMoveToWindow and the isOpen prop change).
  • setNeedsAppearanceUpdate / setNeedsBehaviorUpdate (controller) — implemented and set their respective flags, but nothing calls them yet. See the // TODO: determine if setNeedsApperance/BehaviorUpdate is necessary if _isOpen note in the host view.
  • RNSContainedModalUpdateCoordinator's needsAny: / updateIfAnyNeeded: — provided for completeness alongside needsAll: / updateIfNeeded:, but only the …All / updateIfNeeded: variants are used so far.
  • RNSContainedModalPresentationManager's handleNativeDismiss — defined to reset state on a UIKit-initiated dismissal, but not yet hooked to a presentation-controller delegate (contained modals are currently only dismissed programmatically via isOpen). FormSheet already wires the equivalent through presentationControllerDidDismiss:.

After - visual documentation

ContainedModalDemo.mov

Test plan

Run test-contained-modal-base-ios and verify if the modal's behavior matches the expected one in scenario.md.

Checklist

  • Included code example that can be used to test this change (single-feature test).
  • For visual changes, included screenshots / GIFs / recordings documenting the change.
  • For API changes, updated relevant public types.
  • Ensured that CI passes.

@sgaczol sgaczol force-pushed the @sgaczol/contained-modal branch from f74bf6a to 2d121b6 Compare June 18, 2026 11:42
@sgaczol sgaczol requested a review from LKuchno June 18, 2026 11:54
@sgaczol sgaczol changed the title feat(iOS, ContainedModal): add a ContainedModal component feat(iOS, ContainedModal): Add basic setup for ContainedModal native component Jun 18, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a new experimental iOS-contained modal primitive (ContainedModal) and its bounding container (ContainedModalProvider) to react-native-screens, including the Fabric/codegen surface, native iOS implementation (host anchor + teleported content + presentation manager), and a single-feature test scenario in the example app.

Changes:

  • Added JS components/types (with Android/Web no-op shims) and exported them via react-native-screens/experimental.
  • Added Fabric codegen specs + component registry wiring for the new host/provider native components.
  • Implemented the iOS native contained-modal stack (provider controller, host/proxy/state syncing, presentation manager/update coordination) and added a single-feature test scenario.

Reviewed changes

Copilot reviewed 45 out of 45 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
src/fabric/gamma/modals/contained-modal/ContainedModalProviderNativeComponent.ts Codegen spec for the provider native component (Fabric).
src/fabric/gamma/modals/contained-modal/ContainedModalHostNativeComponent.ts Codegen spec for the host native component (Fabric, interfaceOnly).
src/experimental/index.ts Exports ContainedModal/Provider from the experimental entry point.
src/components/gamma/modals/contained-modal/index.ts Barrel exports for the contained-modal components/types.
src/components/gamma/modals/contained-modal/ContainedModalProvider.web.tsx Web no-op stub with warning.
src/components/gamma/modals/contained-modal/ContainedModalProvider.types.ts Public JS types for ContainedModalProvider.
src/components/gamma/modals/contained-modal/ContainedModalProvider.tsx iOS/native implementation wrapper using the provider native component.
src/components/gamma/modals/contained-modal/ContainedModalProvider.android.tsx Android no-op stub with warning.
src/components/gamma/modals/contained-modal/ContainedModal.web.tsx Web no-op stub with warning.
src/components/gamma/modals/contained-modal/ContainedModal.types.ts Public JS types for ContainedModal.
src/components/gamma/modals/contained-modal/ContainedModal.tsx iOS/native implementation wrapper using a zero-sized host anchor.
src/components/gamma/modals/contained-modal/ContainedModal.android.tsx Android no-op stub with warning.
package.json Registers the new Fabric component classes in the codegen component map.
ios/stubs/RNSGammaStubs.mm Adds Gamma stubs for the new component views.
ios/stubs/RNSGammaStubs.h Adds Gamma stub declarations for the new component views.
ios/gamma/modals/contained-modal/RNSContainedModalUpdateFlags.h Defines update flags for presentation/appearance/behavior.
ios/gamma/modals/contained-modal/RNSContainedModalUpdateCoordinator.mm Implements update-flag coordination helpers.
ios/gamma/modals/contained-modal/RNSContainedModalUpdateCoordinator.h Declares update-flag coordination helpers.
ios/gamma/modals/contained-modal/RNSContainedModalProviders.h Declares provider protocol used to drive presentation.
ios/gamma/modals/contained-modal/RNSContainedModalProviderController.mm Defines a provider VC that sets definesPresentationContext.
ios/gamma/modals/contained-modal/RNSContainedModalProviderController.h Header for provider VC.
ios/gamma/modals/contained-modal/RNSContainedModalProviderComponentView.mm Provider Fabric component view embedding the provider VC and reparenting children.
ios/gamma/modals/contained-modal/RNSContainedModalProviderComponentView.h Header for provider component view + containerId API.
ios/gamma/modals/contained-modal/RNSContainedModalPresentationState.h Presentation state enum for manager state machine.
ios/gamma/modals/contained-modal/RNSContainedModalPresentationManager.mm Present/dismiss state machine + provider resolution/caching.
ios/gamma/modals/contained-modal/RNSContainedModalPresentationManager.h Header for presentation manager.
ios/gamma/modals/contained-modal/RNSContainedModalHostShadowStateProxy.mm Updates Fabric shadow state with teleported content bounds/origin offset.
ios/gamma/modals/contained-modal/RNSContainedModalHostShadowStateProxy.h Header for host shadow-state proxy.
ios/gamma/modals/contained-modal/RNSContainedModalHostComponentView.mm Host anchor Fabric component view: mounts children into content VC, manages touch handler + state sync.
ios/gamma/modals/contained-modal/RNSContainedModalHostComponentView.h Header for host component view.
ios/gamma/modals/contained-modal/RNSContainedModalContentView.mm Clear background content view used by the presented controller.
ios/gamma/modals/contained-modal/RNSContainedModalContentView.h Header for content view reparenting helpers.
ios/gamma/modals/contained-modal/RNSContainedModalContentController.mm Presented controller: update flushing + presentation manager invocation + delegate callbacks.
ios/gamma/modals/contained-modal/RNSContainedModalContentController.h Header for content controller and its signals/delegate.
ios/gamma/modals/contained-modal/RNSContainedModalConfigurationApplicator.mm Placeholder configuration applicator (scaffolding).
ios/gamma/modals/contained-modal/RNSContainedModalConfigurationApplicator.h Header for configuration applicator (scaffolding).
common/cpp/react/renderer/components/rnscreens/RNSContainedModalHostState.h C++ state definition for host (frame size + content offset).
common/cpp/react/renderer/components/rnscreens/RNSContainedModalHostShadowNode.h Shadow node definition with overridden content-origin offset.
common/cpp/react/renderer/components/rnscreens/RNSContainedModalHostShadowNode.cpp Shadow node implementation.
common/cpp/react/renderer/components/rnscreens/RNSContainedModalHostComponentDescriptor.h Component descriptor adopting size from state (layout integration).
apps/src/tests/single-feature-tests/index.tsx Adds ContainedModal scenario group to example app scenario registry.
apps/src/tests/single-feature-tests/contained-modal/test-contained-modal-base-ios/scenario.md Manual test plan/scenario for contained modal behavior.
apps/src/tests/single-feature-tests/contained-modal/test-contained-modal-base-ios/scenario-description.ts Scenario metadata shown in the test app.
apps/src/tests/single-feature-tests/contained-modal/test-contained-modal-base-ios/index.tsx Single-feature test implementation showcasing provider-bounded presentation + touch routing.
apps/src/tests/single-feature-tests/contained-modal/index.ts Scenario-group entry point for contained-modal tests.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread ios/stubs/RNSGammaStubs.mm Outdated
Comment thread ios/stubs/RNSGammaStubs.h
Comment on lines +33 to +34
@interface RNSContainedModalProviderHostComponentView : NSObject
@end

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment thread ios/gamma/modals/contained-modal/RNSContainedModalUpdateFlags.h Outdated
Comment thread ios/gamma/modals/contained-modal/RNSContainedModalHostComponentView.mm Outdated

const ContainedModalScenarioGroup: ScenarioGroup<keyof typeof scenarios> = {
name: 'ContainedModal',
details: 'Single feature tests for ContainedModals',

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

'ContainedModals' makes more sense, especially when we add a different style (CurrentContext) in the future

sgaczol and others added 4 commits June 18, 2026 14:44
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants