Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/src/tests/single-feature-tests/form-sheet/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import TestFormSheetInitialDetentIndex from './test-form-sheet-initial-detent-in
import TestFormSheetLargestUndimmedDetentIndex from './test-form-sheet-largest-undimmed-detent-index-ios';
import TestFormSheetOnDetentChanged from './test-form-sheet-on-detent-changed-ios';
import TestFormSheetPreferredCornerRadius from './test-form-sheet-preferred-corner-radius-ios';
import TestFormSheetPresentationState from './test-form-sheet-presentation-state-ios';

const scenarios = {
TestFormSheetBase,
Expand All @@ -15,6 +16,7 @@ const scenarios = {
TestFormSheetLargestUndimmedDetentIndex,
TestFormSheetOnDetentChanged,
TestFormSheetPreferredCornerRadius,
TestFormSheetPresentationState,
};

const FormSheetScenarioGroup: ScenarioGroup<keyof typeof scenarios> = {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import React, { useState } from 'react';
import { Button, StyleSheet, Text, View } from 'react-native';
import { FormSheet } from 'react-native-screens/experimental';
import { scenarioDescription } from './scenario-description';
import { createScenario } from '@apps/tests/shared/helpers';
import { Colors } from '@apps/shared/styling';

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

return (
<View style={styles.container}>
<Text style={styles.title}>FormSheet Test</Text>
<Button
title="Open FormSheet"
color={Colors.primary}
onPress={() => setIsOpen(true)}
/>
<FormSheet
isOpen={isOpen}
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="Quickly dismiss & present"
color={Colors.primary}
onPress={() => {
setIsOpen(false);
setTimeout(() => {
setIsOpen(true);
}, 32);
}}
/>
</View>
</FormSheet>
</View>
);
}

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);
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import type { ScenarioDescription } from '@apps/tests/shared/helpers';

export const scenarioDescription: ScenarioDescription = {
name: 'Presentation state',
key: 'test-form-sheet-presentation-state-ios',
details:
'Verifies the presentation state machine when subjected to rapid consecutive open/close state changes from JS.',
platforms: ['ios'],
e2eCoverage: 'tbd',
smokeTest: false,
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# Test Scenario: Presentation state

## Details

**Description:** Verify the presentation state machine. This test ensures that when the React Native state rapidly toggles between `false` and `true` the native layer correctly queues the presentation changes and prevents state desynchronization.

**OS test creation version:** iOS: 18.6 and 26.4

## E2E test

Other: Planned, but will be implemented separately.

## Prerequisites

- iOS device or simulator: iPhone

## Steps - iPhone

### Baseline

1. Launch the app and navigate to the **Presentation state** screen.

- [ ] Expected: Content with the button "Open FormSheet" is shown.

---

### Initialization

2. Tap the "Open FormSheet" button.

- [ ] Expected: The FormSheet opens smoothly and displays its content.

---

### Rapid State Toggling (Stress Test)

3. Tap the "Quickly dismiss & present" button.

- [ ] Expected: The FormSheet should begin the dismissal animation. As soon as the dismissal animation finishes, the FormSheet should immediately automatically re-present itself. The final state should be opened FormSheet.

---

### Final Dismissal Verification

4. Grab the top edge of the FormSheet and swipe down completely to natively dismiss it.

- [ ] Expected: The FormSheet dismisses and returns the user to the underlying main screen. The native state is synchronized, and tapping "Open FormSheet" again works correctly.
4 changes: 4 additions & 0 deletions ios/gamma/modals/form-sheet/RNSFormSheetContentController.h
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ NS_ASSUME_NONNULL_BEGIN

@property (nonatomic, readonly, nonnull) RNSFormSheetContentView *contentView;

#pragma mark - Presentation

- (void)prepareForPresentation;

#pragma mark - Signals

- (void)setNeedsPresentationUpdate;
Expand Down
52 changes: 13 additions & 39 deletions ios/gamma/modals/form-sheet/RNSFormSheetContentController.mm
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
#import "RNSFormSheetContentController.h"
#import "RNSFormSheetConfigurationApplicator.h"
#import "RNSFormSheetContentView.h"
#import "RNSFormSheetPresentationManager.h"
#import "RNSFormSheetUpdateCoordinator.h"
#import "RNSFormSheetUpdateFlags.h"
#import "RNSPresentationSourceProvider.h"
Expand All @@ -19,6 +20,7 @@ @interface RNSFormSheetContentController () <UIAdaptivePresentationControllerDel
@implementation RNSFormSheetContentController {
RNSFormSheetUpdateCoordinator *_Nonnull _updateCoordinator;
RNSFormSheetConfigurationApplicator *_Nonnull _configurationApplicator;
RNSFormSheetPresentationManager *_Nonnull _presentationManager;

BOOL _needsInitialDetentReset;
}
Expand All @@ -30,6 +32,7 @@ - (instancetype)init

_updateCoordinator = [RNSFormSheetUpdateCoordinator new];
_configurationApplicator = [RNSFormSheetConfigurationApplicator new];
_presentationManager = [RNSFormSheetPresentationManager new];

_needsInitialDetentReset = NO;
}
Expand Down Expand Up @@ -78,14 +81,7 @@ - (void)updatePresentationState
return;
}

if (presentationProvider.isOpen) {
UIWindow *window = presentationProvider.hostWindow;
if (window != nil) {
[self presentFromWindowIfNeeded:window];
}
} else {
[self dismissIfNeeded];
}
[_presentationManager updatePresentationIfNeededWithProvider:presentationProvider controller:self];
}

- (void)prepareForPresentation
Expand All @@ -96,38 +92,14 @@ - (void)prepareForPresentation
#if !TARGET_OS_TV
self.sheetPresentationController.delegate = self;
#endif // !TARGET_OS_TV
}

// TODO: @t0maboro - This presentation logic is currently quite primitive.
// We are not entirely safe from rapid conflicting updates, and there are edge cases
// where the presentation state might become desynchronized. Addressing this robustly
// might require an approach similar to the tabs implementation using state provenance,
// which will be handled separately.
// Followup ticket: https://github.com/software-mansion/react-native-screens-labs/issues/1420
- (void)presentFromWindowIfNeeded:(nonnull UIWindow *)window
{
if (self.presentingViewController != nil) {
return;
}

UIViewController *presentationSourceViewController =
[RNSPresentationSourceProvider findViewControllerForPresentationInWindow:window];
if (presentationSourceViewController == nil) {
RCTLogError(
@"[RNScreens] Failed to present form sheet: The source view controller cannot be found for target window.");
return;
}

[self prepareForPresentation];
[presentationSourceViewController presentViewController:self animated:YES completion:nil];
}

- (void)dismissIfNeeded
{
if (self.presentingViewController == nil) {
return;
}
[self dismissViewControllerAnimated:YES completion:nil];
// Since UIKit has recreated sheetPresentationController, any configuration that could be applied
// during the Dismissed or Dismissing state was lost.
// We must force a full configuration update for this new instance.
[self setNeedsAppearanceUpdate];
[self setNeedsBehaviorUpdate];
[self setNeedsInitialDetentReset];
[self updateConfigurationIfNeeded];
}

#pragma mark - Sheet Configuration
Expand Down Expand Up @@ -189,6 +161,8 @@ - (void)flushPendingUpdates

- (void)presentationControllerDidDismiss:(UIPresentationController *)presentationController
{
[_presentationManager handleNativeDismiss];

[self.delegate sheetControllerDidNativeDismiss:self];
}

Expand Down
19 changes: 19 additions & 0 deletions ios/gamma/modals/form-sheet/RNSFormSheetPresentationManager.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
#pragma once

#import <Foundation/Foundation.h>
#import "RNSFormSheetProviders.h"

@class RNSFormSheetContentController;

NS_ASSUME_NONNULL_BEGIN

@interface RNSFormSheetPresentationManager : NSObject

- (void)updatePresentationIfNeededWithProvider:(id<RNSFormSheetPresentationProvider>)provider
controller:(RNSFormSheetContentController *)controller;

- (void)handleNativeDismiss;

@end

NS_ASSUME_NONNULL_END
102 changes: 102 additions & 0 deletions ios/gamma/modals/form-sheet/RNSFormSheetPresentationManager.mm
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
#import "RNSFormSheetPresentationManager.h"
#import "RNSFormSheetContentController.h"
#import "RNSFormSheetPresentationState.h"
#import "RNSPresentationSourceProvider.h"

#import <React/RCTLog.h>

@implementation RNSFormSheetPresentationManager {
RNSFormSheetPresentationState _state;
}

- (instancetype)init
{
if (self = [super init]) {
_state = RNSFormSheetPresentationStateDismissed;
}
return self;
}

- (void)updatePresentationIfNeededWithProvider:(id<RNSFormSheetPresentationProvider>)provider
controller:(RNSFormSheetContentController *)controller
{
BOOL shouldBeOpen = provider.isOpen;

if (shouldBeOpen) {
[self presentIfNeededWithProvider:provider controller:controller];
} else {
[self dismissIfNeededWithProvider:provider controller:controller];
}
}

- (void)presentIfNeededWithProvider:(id<RNSFormSheetPresentationProvider>)provider
controller:(RNSFormSheetContentController *)controller
{
if (_state != RNSFormSheetPresentationStateDismissed) {
return;
}

UIWindow *window = provider.hostWindow;
if (window == nil) {
return;
}

UIViewController *presentationSourceViewController =
[RNSPresentationSourceProvider findViewControllerForPresentationInWindow:window];
if (presentationSourceViewController == nil) {
RCTLogError(
@"[RNScreens] Failed to present form sheet: The source view controller cannot be found for target window.");
return;
}

_state = RNSFormSheetPresentationStatePresenting;
[controller prepareForPresentation];

__weak auto weakSelf = self;
[presentationSourceViewController presentViewController:controller
animated:YES
completion:^{
auto strongSelf = weakSelf;
if (!strongSelf) {
return;
}

strongSelf->_state = RNSFormSheetPresentationStatePresented;
[strongSelf updatePresentationIfNeededWithProvider:provider
controller:controller];
}];
}

- (void)dismissIfNeededWithProvider:(id<RNSFormSheetPresentationProvider>)provider
controller:(RNSFormSheetContentController *)controller
{
if (_state != RNSFormSheetPresentationStatePresented) {
return;
}

if (controller.presentingViewController == nil) {
_state = RNSFormSheetPresentationStateDismissed;
return;
}

_state = RNSFormSheetPresentationStateDismissing;

__weak auto weakSelf = self;
[controller dismissViewControllerAnimated:YES
completion:^{
auto strongSelf = weakSelf;
if (!strongSelf) {
return;
}

strongSelf->_state = RNSFormSheetPresentationStateDismissed;
[strongSelf updatePresentationIfNeededWithProvider:provider controller:controller];
}];
}

- (void)handleNativeDismiss
{
_state = RNSFormSheetPresentationStateDismissed;
}

@end
10 changes: 10 additions & 0 deletions ios/gamma/modals/form-sheet/RNSFormSheetPresentationState.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
#pragma once

Comment thread
t0maboro marked this conversation as resolved.
#import <Foundation/Foundation.h>

typedef NS_ENUM(NSInteger, RNSFormSheetPresentationState) {
RNSFormSheetPresentationStateDismissed,
RNSFormSheetPresentationStateDismissing,
RNSFormSheetPresentationStatePresented,
RNSFormSheetPresentationStatePresenting
};