Skip to content

feat(iOS, FormSheet v5): Add basic setup for standalone FormSheet native component#3947

Merged
t0maboro merged 114 commits into
mainfrom
@t0maboro/formsheet-modal-v5
May 14, 2026
Merged

feat(iOS, FormSheet v5): Add basic setup for standalone FormSheet native component#3947
t0maboro merged 114 commits into
mainfrom
@t0maboro/formsheet-modal-v5

Conversation

@t0maboro

@t0maboro t0maboro commented Apr 28, 2026

Copy link
Copy Markdown
Contributor

Description

Introducing the iOS implementation of the standalone FormSheet component.

Key Architectural Decisions:

  • On the JS side, the FormSheet is placed exactly where it is defined in the component tree, but its frame is intentionally cleared. It acts as a logical host component.
  • The presentation and hierarchy are split into a clear 3-tier architecture to maintain Separation of Concerns:
    • RNSFormSheetHostComponentView handles the Fabric lifecycle, prop updates, state management and attaching children.
    • RNSFormSheetHostController is responsible for the native UIModalPresentationFormSheet presentation.
    • RNSFormSheetContentView is a dedicated clean UIKit view assigned to the controller to safely manage the physical existence of React subviews.
  • The actual UI is driven by a dedicated RNSFormSheetHostController. This controller creates a transparent top-level UIView and is presented modally over the current UIWindow hierarchy via the closest valid presentingViewController.
  • The sheet's visibility is entirely driven by the isOpen prop. This declarative boolean is translated into imperative presentViewController and dismissViewController calls. When a user natively dismisses the sheet (e.g., via a swipe-down gesture), the RNSFormSheetHostControllerDelegate triggers the RNSFormSheetHostComponentEventEmitter to fire the onNativeDismiss event, allowing the JS to synchronize its local state.
  • To ensure React children are sized correctly within the dynamic sheet, we observe viewDidLayoutSubviews inside the presented controller. When the native bounds change, the new size and origin offset are captured and dispatched via a C++ state update. This explicitly forces Yoga to synchronously recalculate the content layout using the sheet's actual native dimensions.
  • Child components defined in JS are intercepted during Fabric's mount/unmount lifecycle. Instead of being added to the FormSheet's Host view, they are explicitly reparented into the presented controller's RNSFormSheetContentView.

Implementation details:

  • Fully supports iOS 16+ custom detents, with a safe fallback to system detents for iOS 15. Detents are strictly validated: an empty array gracefully defaults to a large detent, and provided values MUST be in strictly ascending order.
  • Implements an independent RCTSurfaceTouchHandler attached to the controller's view, utilizing viewOriginOffset to correctly align the React touch coordinate space with the natively presented window.
  • Delays the zeroing of the Shadow Node size until the dismissal animation completes to prevent the layout from collapsing while sliding down.

Caution

Dynamic content origin updates aren't supported in the context of synchronization with the ShadowTree state. If an ancestor offset changes, the frame of our Host view might not be updated at all, because from the Host perspective, the frame doesn't change, so there's no need to re-layout the content.

Caution

Regarding the layout, it may currently become slightly desynchronized, as noted in the discussion: #3947 (review)
The root cause is that UIDropShadowView (an ancestor of ContentView) applies a transform: scale, which is not straightforward to detect. As a result, the bounds of ContentView remain unchanged, and layout updates triggered by UIDropShadowView are not observable from our components.
This can lead to a minor offset during the initial layout pass on iOS 26, since the FormSheet is not anchored to the leading and trailing edges of the screen.
I wouldn’t classify this as a regression - we observed similar behaviors in the v4 implementation, and it did not raise any reported issues. For now, I prefer to leave it as-is rather than introduce fragile workarounds. I'll open a follow-up ticket to track this in case the desynchronization becomes problematic.

(ik that too many tasks were covered in this single PR, I'm refining this approach for other modal components)
Closes https://github.com/software-mansion/react-native-screens-labs/issues/1254
Closes https://github.com/software-mansion/react-native-screens-labs/issues/1255
Closes https://github.com/software-mansion/react-native-screens-labs/issues/1256
Closes https://github.com/software-mansion/react-native-screens-labs/issues/1257
Closes https://github.com/software-mansion/react-native-screens-labs/issues/1258
Closes https://github.com/software-mansion/react-native-screens-labs/issues/1274

Changes

  • Added RNSFormSheetHostComponentView to handle the Fabric lifecycle.
  • Added RNSFormSheetHostController to manage modal presentation.
  • Added RNSFormSheetHostContentView to isolate react subviews reparenting.
  • Added RNSFormSheetHostComponentEventEmitter and updated the JS spec to emit the onNativeDismiss event.
  • Created RNSFormSheetHostShadowNode definitions for synchronous layout updates via C++ state proxy.
  • Added two Fabric test scenarios in the playground app to verify layout and Stack v5 integration.

Before & after - visual documentation

Base layout test Stack v5 integration test
basic-layout.mov
formsheet-with-stack-v5.mov

Test plan

  1. FormSheet Test: Basic functionality
  • Verified the sheet opens at the initial detent when isOpen is set to true.
  • Verified that content layouts properly.
  • Expanded the sheet to the 1.0 detent and confirmed the layout dynamically adapts.
  • Tapped "Dismiss from JS" to verify programmatic dismissal safely triggers the transition and unmounts the view without layout collapse.
  1. FormSheet with Nested Stack v5
  • Opened the FormSheet and verified that the nested StackContainer fills the FormSheet's bounds
  • Pushed "Screen A" onto the nested stack and confirmed the navigation occurs entirely within the sheet.
  • Expanded the sheet to the maximum detent and verified the stack layout adapts.
  • Natively swiped down to dismiss the sheet, re-opened it via the JS button, and confirmed the nested stack state was preserved.

Checklist

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

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 an iOS-only FormSheet (v5) component to the gamma/experimental API surface, backed by a new Fabric component view and supporting native controller/state plumbing, plus manual test scenarios in the test app.

Changes:

  • Introduces a new FormSheet JS component (with Android/Web stubs) and exports it via react-native-screens/experimental.
  • Adds iOS native implementation (RNSFormSheetComponentView + RNSFormSheetController) and registers the component for codegen.
  • Adds C++ shadow node/state/descriptor glue and adds new test-app scenarios for manual validation.

Reviewed changes

Copilot reviewed 26 out of 26 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
src/fabric/gamma/modals/form-sheet/FormSheetNativeComponent.ts New codegen native component interface for RNSFormSheet (iOS-only).
src/components/gamma/modals/form-sheet/* JS public API wrapper (FormSheet), types, and Android/Web fallbacks.
src/components/gamma/modals/form-sheet/index.ts Barrel export for the new component.
src/experimental/index.ts Exposes FormSheet from the experimental entrypoint.
package.json Registers RNSFormSheet in codegenConfig.ios.components.
ios/gamma/modals/form-sheet/* Native controller, component view, and event emitter for iOS FormSheet presentation + events.
common/cpp/react/renderer/components/rnscreens/RNSFormSheet* New shadow node/state/component descriptor for layout + origin offset handling.
apps/src/tests//form-sheet/ Adds new manual scenarios for single-feature and integration testing.

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

Comment thread ios/gamma/modals/form-sheet/RNSFormSheetController.h Outdated
Comment thread ios/gamma/modals/form-sheet/RNSFormSheetHostComponentView.mm Outdated
Comment thread src/components/gamma/modals/form-sheet/FormSheet.tsx Outdated
Comment thread apps/src/tests/single-feature-tests/form-sheet/test-form-sheet-base/scenario.md Outdated
Comment thread ios/gamma/modals/form-sheet/RNSFormSheetHostController.mm Outdated
@t0maboro t0maboro requested a review from Copilot April 29, 2026 11:12
@t0maboro t0maboro changed the title FormSheet v5 feat(iOS, FormSheet v5): Add basic setup for standalone FormSheet native component Apr 29, 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

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


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

Comment thread ios/gamma/modals/form-sheet/RNSFormSheetHostComponentView.mm Outdated
Comment thread src/components/gamma/modals/form-sheet/FormSheet.android.tsx Outdated
Comment thread src/components/gamma/modals/form-sheet/FormSheet.web.tsx Outdated
Comment thread apps/src/tests/single-feature-tests/form-sheet/test-form-sheet-base/index.tsx Outdated
Comment thread apps/src/tests/single-feature-tests/form-sheet/test-form-sheet-base/index.tsx Outdated
Comment thread ios/gamma/modals/form-sheet/RNSFormSheetComponentView.mm Outdated
@t0maboro t0maboro requested a review from Copilot April 29, 2026 11:27
@t0maboro t0maboro marked this pull request as ready for review April 29, 2026 11:34

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

Copilot reviewed 26 out of 26 changed files in this pull request and generated 4 comments.


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

Comment thread package.json
Comment thread ios/gamma/modals/form-sheet/RNSFormSheetComponentView.mm Outdated
Comment thread ios/gamma/modals/form-sheet/RNSFormSheetHostComponentView.mm
Comment thread ios/gamma/modals/form-sheet/RNSFormSheetHostController.mm Outdated

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

Copilot reviewed 28 out of 28 changed files in this pull request and generated 1 comment.


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

Comment thread src/fabric/gamma/modals/form-sheet/FormSheetNativeComponent.ts Outdated

@kkafar kkafar left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Nice job!

I've left remarks below. Please answer them before we proceed.

Comment thread common/cpp/react/renderer/components/rnscreens/RNSFormSheetComponentDescriptor.h Outdated
Comment thread common/cpp/react/renderer/components/rnscreens/RNSFormSheetComponentDescriptor.h Outdated
Comment thread common/cpp/react/renderer/components/rnscreens/RNSFormSheetState.h Outdated
Comment thread ios/gamma/modals/form-sheet/RNSFormSheetHostComponentEventEmitter.h Outdated
Comment thread ios/gamma/modals/form-sheet/RNSFormSheetHostController.mm Outdated
Comment thread ios/gamma/modals/form-sheet/RNSFormSheetHostController.mm Outdated
Comment thread ios/gamma/modals/form-sheet/RNSFormSheetHostController.h Outdated
Comment thread src/components/gamma/modals/form-sheet/FormSheet.android.tsx
Comment thread src/components/gamma/modals/form-sheet/FormSheet.types.ts Outdated

@kligarski kligarski 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.

What about this situation:

      <View style={{ position: 'absolute', top: 200, left: 100 }}>
        <FormSheet
          isOpen={isOpen}
          onDismiss={() => setIsOpen(false)}
          detents={[0.6, 1.0]}>
          <View style={styles.sheetContent}>
            <Text style={styles.sheetTitle}>FormSheet content</Text>
            <View style={styles.spacing} />
            <Button
              title="Dismiss from JS"
              color={Colors.primary}
              onPress={() => setIsOpen(false)}
            />
          </View>
        </FormSheet>
      </View>
absolute.mov

Comment thread src/fabric/gamma/modals/form-sheet/FormSheetHostNativeComponent.ts
Comment thread apps/src/tests/single-feature-tests/index.tsx
Comment thread src/components/gamma/modals/form-sheet/FormSheet.types.ts Outdated
Comment thread src/components/gamma/modals/form-sheet/FormSheet.types.ts Outdated
Comment thread ios/gamma/modals/form-sheet/RNSFormSheetComponentView.mm Outdated
Comment thread ios/gamma/modals/form-sheet/RNSFormSheetController.h Outdated
Comment thread ios/gamma/modals/form-sheet/RNSFormSheetHostComponentView.mm Outdated
Comment thread ios/gamma/modals/form-sheet/RNSFormSheetHostComponentView.mm Outdated
Comment thread ios/gamma/modals/form-sheet/RNSFormSheetHostComponentView.mm

@LKuchno LKuchno left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Screen and scenarios looks good :)
I added few comments about naming conventions and directory structure and small suggestion about scenario to be consider.


const scenarioDescription: ScenarioDescription = {
name: 'Basic functionality',
key: 'test-formsheet-base',

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Following naming convention for directories I would set key to 'test-form-sheet-base-ios'

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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


const scenarioDescription: ScenarioDescription = {
name: 'FormSheet with Nested Stack v5',
key: 'test-formsheet-nested-stack-v5',

@LKuchno LKuchno Apr 30, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Since this is a CIT scenario, the directory name should look slightly different. I would propose: test-form-sheet-in-stack-v5-ios. The platform must be included as this test is iOS-only.
@kkafar , what do you think about the naming? Since no additional props are being tested, I wouldn't include the <component-a>-<component-b>-<test-case-name> part here, as the test case is basically a <component-a>-in-<component-b> check.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I think we should keep to what's decided in the RFC on test naming. It might result in long names, but we did that for a reason. Let's stick to it. I see that the name now is test-form-sheet-stack-v5-nesting-stack-in-form-sheet-ios and that makes sense to me.

@kligarski kligarski 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.

Something seems off regarding the shadow tree offset synchronization.

First, I wondered why we're using [self convertPoint:CGPointZero toView:formSheetContentView] and then sending offset as negative instead of [formSheetContentView convertPoint:CGPointZero toView:self] which makes more sense to me (no "-" necessary). But after I compared both, I get different (absolute) values:

[dbg123] original: {-2.9599811375705726e-14, -388.80829015544043} alt: {2.8421709430404007e-14, 373.33333333333337}

I decided to check with devtools what's going on and I discovered that the offset is incorrect on first update and after trying to change the size of the sheet, it's still incorrect but it's different. You can see that pressable loses focus earlier when I swipe at the top.

original.mov

Then I used the second version. The offset is incorrect on first render as well but after trying to resize the sheet, it looks correct.

modified.mov

I think that we should debug this further. Maybe due to sheet padding on iOS 26 there are some transforms applied and they mess with the calculations (maybe also timing-related?)?

Native code
  // For Yoga, we need to apply the offset in RNSFormSheetHostContentView coordinates
  auto formSheetContentView = (RNSFormSheetContentView *)_controller.view;
  CGPoint hostOriginInContentViewSpace = [self convertPoint:CGPointZero toView:formSheetContentView];
  CGPoint contentViewOriginInHostSpace = [formSheetContentView convertPoint:CGPointZero toView:self];
  NSLog(@"[dbg123] original: %@ alt: %@", NSStringFromCGPoint(hostOriginInContentViewSpace), NSStringFromCGPoint(contentViewOriginInHostSpace));
  CGPoint contentOriginOffset = CGPointMake(-hostOriginInContentViewSpace.x, -hostOriginInContentViewSpace.y);
  //   CGPoint contentOriginOffset = CGPointMake(contentViewOriginInHostSpace.x, contentViewOriginInHostSpace.y);
JS test (note the disabled hitSlop for debugging!) ```tsx import React, { useState } from 'react'; import { Button, StyleSheet, Text, View } from 'react-native'; import { FormSheet } from 'react-native-screens/experimental'; import type { ScenarioDescription } from '@apps/tests/shared/helpers'; import { createScenario } from '@apps/tests/shared/helpers'; import { Colors } from '@apps/shared/styling'; import PressableWithFeedback from '@apps/shared/PressableWithFeedback';

const scenarioDescription: ScenarioDescription = {
name: 'Basic functionality',
key: 'test-form-sheet-base-ios',
details: 'Allows to test the basic functionality of FormSheet component.',
platforms: ['ios'],
};

export function App() {
const [isOpen, setIsOpen] = useState(false);

return (

FormSheet Test
<Button
title="Open FormSheet"
color={Colors.primary}
onPress={() => setIsOpen(true)}
/>
<FormSheet
isOpen={isOpen}
onNativeDismiss={() => setIsOpen(false)}
detents={[0.6]}>

{/FormSheet content
/}
{/<Button
title="Dismiss from JS"
color={Colors.primary}
onPress={() => setIsOpen(false)}
/>
/}

<View style={{ width: 100, height: 50 }} />




);
}

const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: Colors.offBackground,
},
title: {
fontSize: 20,
fontWeight: 'bold',
marginBottom: 20,
color: Colors.text,
},
sheetContent: {
flex: 1,
backgroundColor: Colors.background,
// padding: 24,
// justifyContent: 'center',
// alignItems: 'center',
},
sheetTitle: {
fontSize: 22,
fontWeight: '600',
marginBottom: 12,
color: Colors.text,
},
spacing: {
height: 32,
},
});

export default createScenario(App, scenarioDescription);


</details>

Comment thread ios/gamma/modals/form-sheet/RNSFormSheetHostComponentView.mm Outdated

#pragma mark - Presentation Logic

- (void)updatePresentationState

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.

I wasn't sure about how we handle isOpen from JS and native, I asked LLM for some edge cases and I tried quick change false -> open from JS:

Code and logs
<FormSheet
        isOpen={isOpen}
        onNativeDismiss={() => {
          console.log('[dbg123] onNativeDismiss');
          setIsOpen(false);
        }}
        detents={[0.6, 1.0]}>
        <View style={styles.sheetContent}>
          <Text style={styles.sheetTitle}>FormSheet content</Text>
          <View style={styles.spacing} />
          <Button
            title="Dismiss from JS"
            color={Colors.primary}
            onPress={() => {
              console.log('[dbg123] Dismiss From JS (setIsOpen(false))');
              setIsOpen(false);
              setTimeout(() => {
                console.log(
                  '[dbg123] Dismiss From JS - Timeout (setIsOpen(true))',
                );
                setIsOpen(true);
              }, 500);
            }}
          />
        </View>
      </FormSheet>

The sheet doesn't reappear:

JS-native-desync.mov
This is what LLM suspected but I didn't check if that's true:
  1. The "Animation In-Flight" Trap (Rapid Toggle Desync)

The Scenario: JS rapidly toggles isOpen from true -> false -> true.
The Problem: UIKit takes time to animate dismissals, but React updates props almost instantly.

JS sets isOpen={false}.

Native updateProps triggers updatePresentationState.

Native calls [_controller dismissViewControllerAnimated:YES completion:...]. The dismissal animation begins.

While the animation is running, JS sets isOpen={true}.

Native updateProps triggers updatePresentationState again.

The code checks isPresented = _controller.presentingViewController != nil. Because UIKit is still in the middle of animating the dismissal, presentingViewController is still not nil, so isPresented evaluates to YES.

Your logic hits this block:

// 2. _isOpen == YES and isPresented == YES: This occurs when the sheet is already visible ... require no action

The native code does nothing.

A fraction of a second later, the UIKit dismissal animation completes, and the sheet disappears from the screen.
The Result: JS thinks the sheet is open (isOpen={true}). Native _isOpen is YES. But the physical UI is completely gone. Total desynchronization.

@t0maboro t0maboro May 5, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

thanks for catching that, let me add it to my issue tracker, this might be more complicated issue to fix, so let me fix it in the followup PR: https://github.com/software-mansion/react-native-screens-labs/issues/1420

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Yeah. I do not know how we'll control the sheet yet. It's down to the discussion in the RFC. For now this is fine as PoC.

@t0maboro t0maboro force-pushed the @t0maboro/formsheet-modal-v5 branch from ed20408 to bb8786c Compare May 5, 2026 12:12
@t0maboro

t0maboro commented May 5, 2026

Copy link
Copy Markdown
Contributor Author

Regarding the layout, it may currently become slightly desynchronized, as noted in the discussion: #3947 (review)
The root cause is that UIDropShadowView (an ancestor of ContentView) applies a transform: scale, which is not straightforward to detect. As a result, the bounds of ContentView remain unchanged, and layout updates triggered by UIDropShadowView are not observable from our components.
This can lead to a minor offset during the initial layout pass on iOS 26, since the FormSheet is not anchored to the leading and trailing edges of the screen.
I wouldn’t classify this as a regression - we observed similar behaviors in the v4 implementation, and it did not raise any reported issues. For now, I prefer to leave it as-is rather than introduce fragile workarounds. I'll open a follow-up ticket to track this in case the desynchronization becomes problematic.

Ticket: https://github.com/software-mansion/react-native-screens-labs/issues/1421

cc @kkafar @kligarski

@t0maboro t0maboro requested a review from kligarski May 5, 2026 12:30

@kligarski kligarski 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.

I suggest that you add a separate ticket to https://github.com/software-mansion/react-native-screens-labs/issues/1419 for the drop shadow scaling issue (not matching 1:1 to what's on screen with margin on the left).

@t0maboro t0maboro force-pushed the @t0maboro/formsheet-modal-v5 branch from 9eebb45 to 7efa595 Compare May 14, 2026 09:39
@t0maboro

Copy link
Copy Markdown
Contributor Author

android e2e failed with reasons that are unrelated to this PR, I'm pushing it forward

@t0maboro t0maboro merged commit b9179dc into main May 14, 2026
7 of 8 checks passed
@t0maboro t0maboro deleted the @t0maboro/formsheet-modal-v5 branch May 14, 2026 10:30
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.

6 participants