Skip to content

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

Open
t0maboro wants to merge 111 commits intomainfrom
@t0maboro/formsheet-modal-v5
Open

feat(iOS, FormSheet v5): Add basic setup for standalone FormSheet native component#3947
t0maboro wants to merge 111 commits intomainfrom
@t0maboro/formsheet-modal-v5

Conversation

@t0maboro
Copy link
Copy Markdown
Contributor

@t0maboro t0maboro commented Apr 28, 2026

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

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

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

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
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

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
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

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
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

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
Copy link
Copy Markdown
Member

@kkafar kkafar left a comment

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 on lines +32 to +35
if (stateData.frameSize.width >= 0 && stateData.frameSize.height >= 0) {
layoutableShadowNode.setSize(
Size{stateData.frameSize.width, stateData.frameSize.height});
}
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.

Is this ever not true?

I need to read the code further leaving notes for myself.

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.

Usually what I've done recently is to initialise the frameSize with special empty value and then compare against it here.

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.

@@ -0,0 +1,43 @@
#pragma once

#ifndef ANDROID
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.

It's okay. I recently started preferring #if !defined(ANDROID) as more readable. Up to you, I don't mind either. Just showing an alternative.

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.


class JSI_EXPORT RNSFormSheetState final {
public:
using Shared = std::shared_ptr<const RNSFormSheetState>;
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.

This is something IIRC react-native got rid of & we didn't yet. Likely we don't use it anywhere, so we can safely remove it. Worth trying imo.

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.

Talking about using Shared = ... here.

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.


@interface RNSFormSheetComponentEventEmitter : NSObject

- (BOOL)emitOnDismiss;
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.

General comment: we need naming convention to determine whether we should use in such scenarios onDismiss or onDismissed. We should be consistent.

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.

I was following stack v5 convention, I see -ed suffix rather in v4 impl., for now I'll leave it as it is, but creating a ticket to revisit it: https://github.com/software-mansion/react-native-screens-labs/issues/1230

@end

@implementation RNSFormSheetController {
CGRect _lastNotifiedFrame;
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.

The name here conveys very little information. We don't know whether we (the controller) have been notified of that frame or we notified some one.

Also from it's current usage, I don't see how is it different from self.view.frame?

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.

similarly as above, it describes the frame relatively to window coordinates, not parent coordinates, allow me to gather all this changes together after fixing an issue with non-zero FormSheet offset reported below

Comment on lines +30 to +34
- (void)viewWillAppear:(BOOL)animated
{
[super viewWillAppear:animated];
self.presentationController.delegate = self;
}
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.

We'd like in the future to forward these via the delegate -> host -> event emitter, since we'd like to notify JS of these lifecycle events.

Copy link
Copy Markdown
Contributor Author

@t0maboro t0maboro Apr 30, 2026

Choose a reason for hiding this comment

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

writing down, I'm planning to start creating tickets for FormSheet soon, so I'll include that there, for now I'm keeping it in my local notes

@property (nonatomic, weak, nullable) id<RNSFormSheetControllerDelegate> delegate;

- (void)insertReactSubview:(UIView *)subview atIndex:(NSInteger)index;
- (void)removeReactSubview:(UIView *)subview;
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.

Subview manipulation methods shouldn't leave on controller. I'd suggest manipulating it's view directly.

What do you think?

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.

Comment thread src/components/gamma/modals/form-sheet/FormSheet.android.tsx
Comment on lines +1 to +24
import type { ViewProps } from 'react-native';

export interface FormSheetProps extends ViewProps {
/**
* Determines whether the form sheet is currently visible.
* When `true`, the sheet is presented. When `false`, it is dismissed.
*/
isOpen: boolean;

/**
* An array of fractional screen heights (ranging from `0` to `1`) that define
* the resting positions of the sheet.
*
* On iOS, these map directly to `UISheetPresentationController` detents.
*/
detents?: number[] | undefined;

/**
* Called when the sheet is dismissed.
* It is highly recommended to use this callback to synchronize
* your local state to prevent UI mismatch (e.g., updating `isOpen` to `false`).
*/
onDismiss?: (() => void) | undefined;
}
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.

We need to work on an document describing the complete API surface we plan here.

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.

I'll add it to my notes to include it while planning the scope & tasks

Copy link
Copy Markdown
Contributor

@kligarski kligarski left a comment

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
Copy link
Copy Markdown
Collaborator

@LKuchno LKuchno left a comment

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',
Copy link
Copy Markdown
Collaborator

@LKuchno LKuchno Apr 30, 2026

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
Contributor

@kligarski kligarski left a comment

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.

Copy link
Copy Markdown
Contributor Author

@t0maboro t0maboro May 5, 2026

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
Copy link
Copy Markdown
Contributor Author

t0maboro commented May 6, 2026

ScrollView doesn't scroll until the form sheet is fully expanded -- is this expected?

Simulator.Screen.Recording.-.iPad.Pro.13-inch.M5.-.2026-05-06.at.13.08.12.mov

Tbh, I haven't tested ScrollView integration yet, but I have that in mind: https://github.com/software-mansion/react-native-screens-labs/issues/1275

@t0maboro t0maboro requested a review from kmichalikk May 6, 2026 12:58
@t0maboro
Copy link
Copy Markdown
Contributor Author

t0maboro commented May 8, 2026

ScrollView doesn't scroll until the form sheet is fully expanded -- is this expected?

Simulator.Screen.Recording.-.iPad.Pro.13-inch.M5.-.2026-05-06.at.13.08.12.mov

Found that while working on props: https://developer.apple.com/documentation/uikit/uisheetpresentationcontroller/prefersscrollingexpandswhenscrolledtoedge cc @kmichalikk

@kkafar
Copy link
Copy Markdown
Member

kkafar commented May 8, 2026

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.

Yep, we must write that down as a recommendation in our yet-to-be-created documentation. We should recommend having stable frame relative to window of the host & provider.

Copy link
Copy Markdown
Member

@kkafar kkafar left a comment

Choose a reason for hiding this comment

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

Looks really good. I've left few remarks. These are mostly naming-related and few questions. Let's answer them before proceeding.

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.

Test name here does not conform with CIT naming convention introduced in https://github.com/software-mansion/react-native-screens-labs/commit/b73db82e771f5b287f09e860a855e7ca339d6414.

Please remember also to update the scenario key.

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.

);
}

function StackSetup() {
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.

Rebase the PR on top of main, I believe you're missing exports here. Do not recall whether @sgaczol PR has already landed or not.

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.

PR with local ESLint rule is open, rebasing won't work, so I'll add exports to all components manually

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.

Comment on lines +32 to +33
layoutableShadowNode.setSize(
Size{stateData.frameSize.width, stateData.frameSize.height});
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.

That code will be problematic when implementing fitToContents. Also,
during the very first layout you'll set this to (0, 0), right? Is that intentional?

Now, back to the original problem.
Each time you setSize on a shadow node, this size is hard-coded. Yoga does not compute dimensions for that particular node then, no matter the layout constraints. Please see: #3295. The issue had arisen multiple times (e.g. in header subviews), but I feel like @kmichalikk described it well.

Please see also descriptions of the PRs I link below, this knowledge will be handy later:

I don't know yet how to solve this 100% correctly, but I think that right now, for starters, you should guard against applying setSize before you receive first state from the host realm. I think I do that in one of above PRs by adding a helper method isStateEmpty or something.

Unless you did this intentionally, let me know what do you think here.

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.

I found a better way to handle that probably, so let me explain the intention I had before:

  • I was rendering the FormSheet (Host) in JS with styles forcing a 0x0 frame so it wouldn't disrupt the React layout hierarchy.
  • Upon mounting, it would stay 0x0 until we actually wanted to show the content.
  • When isOpen changed from false to true, a synchronous state update was sent with the actual frame based on the sheet's detents (W x H).
  • When we dismissed the FormSheet, the Host instance remained alive in the React tree, still holding that W x H frame. Because we don't unmount the Host from the hierarchy, I had to send a state update on dismiss to reset its size back to 0x0 manually.

The refined approach I'm implementing now:

  • To support fitToContents, the Host actually needs to have a non-zero frame from Yoga. In this setup, the Host may even act as ScreenContentWrapper from v4.
  • To prevent this non-zero Host from affecting the layout of its siblings in the React tree, it is now styled with position: 'absolute', top: 0, left: 0.
  • Because we let Yoga compute the Host's size, reading the Host's size gives us the exact content size needed to cover the fitToContents logic.
  • Since the Host now has a non-zero frame in the Element tree, it would normally block touch events for the screen underneath it. To prevent this, I overrode hitTest:withEvent: on the Host view to always return nil.
  • The touch events for the Host's children are safe, as they are handled by a separate RCTSurfaceTouchHandler attached directly to the ContentView (where the React children are natively re-parented).

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.

- (instancetype)initWithFrame:(CGRect)frame
{
if (self = [super initWithFrame:frame]) {
self.backgroundColor = [UIColor clearColor];
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.

Why clearColor here? What's the default?

Copy link
Copy Markdown
Contributor Author

@t0maboro t0maboro May 8, 2026

Choose a reason for hiding this comment

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

nil, I want to have more control over the ContentView (UIView) in the background and do not rely on UIView defaults, because this one is attached manually to the hierarchy by me, so I don't want anything, especially React View to sample anything from that view under some conditions

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.

In other components we have EventEmitter, w/o additional Component infix, e.g. RNSTabsHostEventEmitter or RNSStackScreenEventEmitter.

I don't this this one is worse or better I just want it to be consistent.

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.


// Touch handler requires absolute positioning coordinates, relatively to root (UIWindow)
CGPoint contentViewOriginInWindow = [_controller.view convertPoint:CGPointZero toView:nil];
[self updateTouchHandlerWithOrigin:contentViewOriginInWindow];
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 bit bigger nit-pick: I don't like the fact that the touch handler update "is hidden" in syncShadowNodeState method. Seems like it is outside of the scope fo this method. I'd suggest renaming it to updateOnLayoutInavlidation or something. Don't have a great suggestion, but we need something not misleading.

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.

{
RCTAssert([self.view isKindOfClass:[RNSFormSheetContentView class]],
@"[RNScreens] ContentView must be of type RNSFormSheetContentView");
return (RNSFormSheetContentView *)self.view;
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.

use static_cast for consistency

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.

- (void)viewWillAppear:(BOOL)animated
{
[super viewWillAppear:animated];
self.presentationController.delegate = self;
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.

Why is the delegate initialised here? Why not in initialiser? I don't think we've done something like this in v4 implementation.

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.

My point is: view lifecycle method does not seem like the right place for the delegate initialisation, unless you plan to clean it up in viewDidDisappear. In any other case, this should likely be done in initialiser, OR , if the self.presentationController is still null there follow the approach in RNSScreen?

Haven't verified what do we do in v4, hopefully not exactly the same 😅 Please check it out.

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
Contributor Author

Choose a reason for hiding this comment

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

sorry, I forgot about the issue I had with native dismissal, because UISheetPresentationController was recreated for every presentation, while RNSFormSheetContentController is living longer, because the associated Host is still living in the React hierarchy, just not presenting the content, so I wanted to ensure, that the delegate, which will allow detecting the native dismissal will be always available, when we'd like to present the content

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.

reverting faf0d58 & adding cleanup in viewDidDisappear, let me know if this approach is okay for you

Comment on lines +10 to +18
const styles = StyleSheet.create({
host: {
position: 'absolute',
top: 0,
left: 0,
width: 0,
height: 0,
},
});
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.

These styles require in-code explanation IMO. It's vastly not clear when reading the code why you want to do it like that.

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.


interface NativeProps extends ViewProps {
isOpen?: CT.WithDefault<boolean, false>;
detents?: CT.Double[] | undefined;
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.

Okay, I don't mind that much here.

FYI: You can make _detents as std::vector<CGFloat> + actually copying values over from one vector to another would be cheaper, than copy-constructing a new vector on each assignment. C++ stdlib has functions for this.

@t0maboro t0maboro requested a review from kkafar May 8, 2026 13:43
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