Skip to content
Merged
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
8 changes: 8 additions & 0 deletions example/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -20,6 +21,7 @@ type Example =
| 'corner-adaptation'
| 'react-navigation'
| 'native-stack'
| 'form-sheet'
| 'additional-insets';

export default function App() {
Expand Down Expand Up @@ -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');
});
Expand Down Expand Up @@ -98,6 +103,9 @@ export default function App() {
case 'native-stack':
content = <ReactNavigationNativeStackExample />;
break;
case 'form-sheet':
content = <FormSheetExample />;
break;
case 'additional-insets':
content = <AdditionalInsetsExample />;
break;
Expand Down
161 changes: 161 additions & 0 deletions example/src/FormSheetExample.tsx
Original file line number Diff line number Diff line change
@@ -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<Routes>();

// 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 (
<View style={styles.grid}>
{Array.from({ length: count }, (_, i) => (
<SafeAreaView key={i} edges={['left', 'right']} style={styles.cell} />
))}
</View>
);
}

function HomeScreen() {
const navigation = useNavigation<NativeStackNavigationProp<Routes, 'Home'>>();
return (
<View style={styles.home}>
<Button title="Open sheet" onPress={() => navigation.navigate('Sheet')} />
<Button
title="Open sheet with a SafeAreaProvider"
onPress={() => navigation.navigate('SheetWithProvider')}
/>
</View>
);
}

function SheetScreen({ title = 'SafeAreaView in a form sheet' }) {
const [busy, setBusy] = React.useState(false);
const [manyViews, setManyViews] = React.useState(false);
useBusyJsThread(busy);
return (
<ScrollView contentContainerStyle={styles.sheetContent}>
<SafeAreaView edges={['left', 'right']}>
<View style={styles.box}>
<Text style={styles.title}>{title}</Text>
<Text style={styles.text}>
Rotate the device. The red border must stay clear of the notch on
both sides, right after the rotation.
</Text>
<View style={styles.row}>
<Text style={styles.text}>Busy JS thread</Text>
<Switch value={busy} onValueChange={setBusy} />
</View>
<View style={styles.row}>
<Text style={styles.text}>Mount 200 SafeAreaViews</Text>
<Switch value={manyViews} onValueChange={setManyViews} />
</View>
{manyViews && <ManySafeAreaViews />}
</View>
</SafeAreaView>
</ScrollView>
);
}

function SheetWithProviderScreen() {
return (
<SafeAreaProvider>
<SheetScreen title="SafeAreaView under a provider in a form sheet" />
</SafeAreaProvider>
);
}

const sheetOptions = {
presentation: 'formSheet',
sheetAllowedDetents: [1.0],
sheetGrabberVisible: true,
headerShown: false,
} as const;

export default function FormSheetExample() {
return (
<SafeAreaProvider>
<NavigationContainer>
<Stack.Navigator>
<Stack.Screen name="Home" component={HomeScreen} />
<Stack.Screen
name="Sheet"
component={SheetScreen}
options={sheetOptions}
/>
<Stack.Screen
name="SheetWithProvider"
component={SheetWithProviderScreen}
options={sheetOptions}
/>
</Stack.Navigator>
</NavigationContainer>
</SafeAreaProvider>
);
}

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' },
});
46 changes: 45 additions & 1 deletion ios/Fabric/RNCSafeAreaViewComponentView.mm
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,38 @@
#import "RNCSafeAreaProviderComponentView.h"
#import "RNCSafeAreaUtils.h"

#include <type_traits>
#include <utility>

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 <typename Queue, typename = void>
struct SupportsImmediateStateUpdate : std::false_type {};

template <typename Queue>
struct SupportsImmediateStateUpdate<Queue, std::void_t<decltype(Queue::UpdateMode::unstable_Immediate)>>
: 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 <typename Queue = EventQueue, typename State, typename Callback>
void updateStateImmediately(const State &state, Callback &&callback)
{
if constexpr (SupportsImmediateStateUpdate<Queue>::value) {
state->updateState(std::forward<Callback>(callback), Queue::UpdateMode::unstable_Immediate);
} else {
state->updateState(std::forward<Callback>(callback));
}
}

} // namespace

@interface RNCSafeAreaViewComponentView () <RCTRNCSafeAreaViewViewProtocol>
@end

Expand Down Expand Up @@ -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];
Expand Down Expand Up @@ -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;
Expand Down
Loading