diff --git a/example/src/App.tsx b/example/src/App.tsx
index 7b25fbd4..096e0da8 100644
--- a/example/src/App.tsx
+++ b/example/src/App.tsx
@@ -3,6 +3,7 @@ import { DevSettings, View, Text, StatusBar } from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
import AdditionalInsetsExample from './AdditionalInsetsExample';
import CornerAdaptationExample from './CornerAdaptationExample';
+import FormSheetExample from './FormSheetExample';
import HooksExample from './HooksExample';
import ListenerExample from './ListenerExample';
import SafeAreaViewExample from './SafeAreaViewExample';
@@ -20,6 +21,7 @@ type Example =
| 'corner-adaptation'
| 'react-navigation'
| 'native-stack'
+ | 'form-sheet'
| 'additional-insets';
export default function App() {
@@ -70,6 +72,9 @@ export default function App() {
DevSettings.addMenuItem('Show Native Stack Example', () => {
setCurrentExample('native-stack');
});
+ DevSettings.addMenuItem('Show Form Sheet Example', () => {
+ setCurrentExample('form-sheet');
+ });
DevSettings.addMenuItem('Show Additional Insets Example', () => {
setCurrentExample('additional-insets');
});
@@ -98,6 +103,9 @@ export default function App() {
case 'native-stack':
content = ;
break;
+ case 'form-sheet':
+ content = ;
+ break;
case 'additional-insets':
content = ;
break;
diff --git a/example/src/FormSheetExample.tsx b/example/src/FormSheetExample.tsx
new file mode 100644
index 00000000..f929983e
--- /dev/null
+++ b/example/src/FormSheetExample.tsx
@@ -0,0 +1,161 @@
+import * as React from 'react';
+import {
+ Button,
+ ScrollView,
+ StyleSheet,
+ Switch,
+ Text,
+ View,
+} from 'react-native';
+import { NavigationContainer, useNavigation } from '@react-navigation/native';
+import {
+ createNativeStackNavigator,
+ NativeStackNavigationProp,
+} from '@react-navigation/native-stack';
+import { SafeAreaProvider, SafeAreaView } from 'react-native-safe-area-context';
+
+// A SafeAreaView in a native formSheet has no SafeAreaProvider above it in the view hierarchy, so
+// it is its own provider. Rotate the device with the sheet open: the red border must stay clear of
+// the notch / Dynamic Island on both sides, also when "Busy JS thread" is on. The second sheet
+// wraps the same content in a SafeAreaProvider, so its SafeAreaView follows the provider instead.
+
+type Routes = {
+ Home: undefined;
+ Sheet: undefined;
+ SheetWithProvider: undefined;
+};
+
+const Stack = createNativeStackNavigator();
+
+// Blocks the JS thread for most of every second, like a busy app. With the default asynchronous
+// state update the insets then lag behind a rotation by several frames.
+function useBusyJsThread(enabled: boolean) {
+ React.useEffect(() => {
+ if (!enabled) {
+ return;
+ }
+ const id = setInterval(() => {
+ const end = Date.now() + 900;
+ while (Date.now() < end) {
+ // busy
+ }
+ }, 1000);
+ return () => clearInterval(id);
+ }, [enabled]);
+}
+
+// Mounts and unmounts many SafeAreaViews, to check that the state update stays cheap while
+// they mount.
+function ManySafeAreaViews() {
+ const [count, setCount] = React.useState(0);
+ React.useEffect(() => {
+ const id = setInterval(() => setCount((c) => (c === 0 ? 200 : 0)), 500);
+ return () => clearInterval(id);
+ }, []);
+ return (
+
+ {Array.from({ length: count }, (_, i) => (
+
+ ))}
+
+ );
+}
+
+function HomeScreen() {
+ const navigation = useNavigation>();
+ return (
+
+
+ );
+}
+
+function SheetScreen({ title = 'SafeAreaView in a form sheet' }) {
+ const [busy, setBusy] = React.useState(false);
+ const [manyViews, setManyViews] = React.useState(false);
+ useBusyJsThread(busy);
+ return (
+
+
+
+ {title}
+
+ Rotate the device. The red border must stay clear of the notch on
+ both sides, right after the rotation.
+
+
+ Busy JS thread
+
+
+
+ Mount 200 SafeAreaViews
+
+
+ {manyViews && }
+
+
+
+ );
+}
+
+function SheetWithProviderScreen() {
+ return (
+
+
+
+ );
+}
+
+const sheetOptions = {
+ presentation: 'formSheet',
+ sheetAllowedDetents: [1.0],
+ sheetGrabberVisible: true,
+ headerShown: false,
+} as const;
+
+export default function FormSheetExample() {
+ return (
+
+
+
+
+
+
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ home: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: 12 },
+ sheetContent: { paddingVertical: 24 },
+ box: {
+ minHeight: 600,
+ borderColor: 'red',
+ borderWidth: 4,
+ padding: 16,
+ gap: 12,
+ },
+ title: { fontSize: 18, fontWeight: 'bold' },
+ text: { fontSize: 15 },
+ row: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ },
+ grid: { flexDirection: 'row', flexWrap: 'wrap', gap: 2 },
+ cell: { width: 8, height: 8, backgroundColor: 'red' },
+});
diff --git a/ios/Fabric/RNCSafeAreaViewComponentView.mm b/ios/Fabric/RNCSafeAreaViewComponentView.mm
index 7d4d4517..1f90a439 100644
--- a/ios/Fabric/RNCSafeAreaViewComponentView.mm
+++ b/ios/Fabric/RNCSafeAreaViewComponentView.mm
@@ -12,8 +12,38 @@
#import "RNCSafeAreaProviderComponentView.h"
#import "RNCSafeAreaUtils.h"
+#include
+#include
+
using namespace facebook::react;
+namespace {
+
+// EventQueue::UpdateMode, and with it unstable_Immediate, came in React Native 0.82. It is detected
+// instead of checked against a version, so older React Native and react-native-macos (which has its
+// own version numbers) still build and keep the default asynchronous update.
+template
+struct SupportsImmediateStateUpdate : std::false_type {};
+
+template
+struct SupportsImmediateStateUpdate>
+ : std::true_type {};
+
+// Commits the state update right away where React Native supports it, the way react-native-screens
+// updates its screens' state. A commit that arrives while a mount transaction is running is mounted
+// after it (RCTMountingManager queues a follow-up transaction), so this does not nest mounts.
+template
+void updateStateImmediately(const State &state, Callback &&callback)
+{
+ if constexpr (SupportsImmediateStateUpdate::value) {
+ state->updateState(std::forward(callback), Queue::UpdateMode::unstable_Immediate);
+ } else {
+ state->updateState(std::forward(callback));
+ }
+}
+
+} // namespace
+
@interface RNCSafeAreaViewComponentView ()
@end
@@ -91,6 +121,16 @@ - (void)didMoveToWindow
}
}
+- (void)safeAreaInsetsDidChange
+{
+ [super safeAreaInsetsDidChange];
+ // Without a provider above it (e.g. in a native sheet), the view is its own provider and no
+ // provider notification reaches it, so follow its own insets here.
+ if (_providerView == self) {
+ [self updateStateIfNecessaryMeasuringCorners:YES];
+ }
+}
+
- (void)safeAreaProviderInsetsDidChange:(NSNotification *)notification
{
[self updateStateIfNecessaryMeasuringCorners:YES];
@@ -173,7 +213,11 @@ - (void)updateState
return;
}
- _state->updateState(
+ // Commit the new insets with the layout pass that changed them (e.g. a rotation), instead of
+ // waiting for the next event beat, which leaves stale insets on screen for some frames in a
+ // busy app.
+ updateStateImmediately(
+ _state,
[=](RNCSafeAreaViewShadowNode::ConcreteState::Data const &oldData)
-> RNCSafeAreaViewShadowNode::ConcreteState::SharedData {
auto newData = oldData;