diff --git a/docs/workflow.md b/docs/workflow.md
index b678a5d..637c3c2 100644
--- a/docs/workflow.md
+++ b/docs/workflow.md
@@ -92,7 +92,8 @@ Ready, that there is exactly one division and that the division is a line at
most 1 point wide (the foldable emulator reports a one-pixel hinge) whose x is
between 40 and 60 percent of the provider width and whose height matches the
provider height within 2 points, and that there is exactly one
-occlusion whose frame sits in the provider top-right corner. It then taps Content
+occlusion whose frame sits in the provider top-right corner, and that both bar
+region lines read none. It then taps Content
box and asserts Ready, one remaining division and no occlusion. A screenshot of
each asserted state is written to the ignored `e2e/artifacts/` directory, and a
failure prints the accessibility snapshot before exiting non-zero. The posture
diff --git a/e2e/android.mjs b/e2e/android.mjs
index 844f40d..df15199 100644
--- a/e2e/android.mjs
+++ b/e2e/android.mjs
@@ -123,6 +123,7 @@ function readScreen() {
height: Number(provider[2]),
divisions: regions.filter((region) => region.kind === 'division'),
occlusions: regions.filter((region) => region.kind === 'occlusion'),
+ bars: texts.filter((text) => /^Bar \d+: /.test(text)),
};
}
@@ -154,6 +155,10 @@ function assertFullScreen(screen) {
`full screen: occlusion right edge ${(occlusion.x + occlusion.width).toFixed(1)} is within 2 points of provider width ${width.toFixed(1)}`,
);
expect(occlusion.y === 0, `full screen: occlusion y is 0.0, got ${occlusion.y.toFixed(1)}`);
+ expect(
+ screen.bars.length === 2 && screen.bars.every((bar) => bar.endsWith(': none')),
+ `full screen: no bar region for either extent, got ${JSON.stringify(screen.bars)}`,
+ );
}
function assertContentBox(screen) {
diff --git a/example/src/App.tsx b/example/src/App.tsx
index bf7343a..50c0f81 100644
--- a/example/src/App.tsx
+++ b/example/src/App.tsx
@@ -1,16 +1,34 @@
import { useState } from 'react';
import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { SafeAreaProvider, useSafeAreaFrame, useSafeAreaInsets } from 'react-native-safe-area-context';
-import { ReservedRegionsProvider, useReservedRegions, useReservedRegionsReady } from 'react-native-reserved-regions';
+import {
+ ReservedRegionsProvider,
+ useBarRegion,
+ useReservedRegions,
+ useReservedRegionsReady,
+ type BarRegion,
+} from 'react-native-reserved-regions';
const layouts = ['Full screen', 'Shift 40', 'Inset 24', 'Content box'] as const;
type Layout = (typeof layouts)[number];
+const barButtonSize = 44;
+
+function describeBar(extent: number, bar: BarRegion | null) {
+ if (bar === null) {
+ return `Bar ${extent}: none`;
+ }
+ const { x, y, width, height } = bar.frame;
+ return `Bar ${extent}: ${bar.edge} · x ${x.toFixed(1)} · y ${y.toFixed(1)} · ${width.toFixed(1)} × ${height.toFixed(1)}`;
+}
function RegionInspector() {
const regions = useReservedRegions();
const isReady = useReservedRegionsReady();
const insets = useSafeAreaInsets();
const frame = useSafeAreaFrame();
+ const bar = useBarRegion({ extent: barButtonSize });
+ const wideBar = useBarRegion({ extent: 48 });
+ const [barTaps, setBarTaps] = useState(0);
return (
<>
@@ -42,12 +60,21 @@ function RegionInspector() {
]}
/>
))}
+ {bar ? (
+
+ ) : null}
Reserved regions
Both providers use the blue rectangle. Green marks the safe area; orange marks divisions; purple marks
- occlusions.
+ occlusions; teal marks the 44-point bar region, which holds the round button.
Provider: {frame.width.toFixed(1)} × {frame.height.toFixed(1)}
@@ -73,7 +100,20 @@ function RegionInspector() {
))
)}
+ {describeBar(barButtonSize, bar)}
+ {describeBar(48, wideBar)}
+ Bar button taps: {barTaps}
+ {bar ? (
+ setBarTaps((taps) => taps + 1)}
+ style={[styles.barButton, { left: bar.frame.x + (bar.frame.width - barButtonSize) / 2, top: bar.frame.y }]}
+ >
+ +
+
+ ) : null}
>
);
@@ -194,6 +234,23 @@ const styles = StyleSheet.create({
occlusion: {
backgroundColor: '#7f3fbd99',
},
+ bar: {
+ backgroundColor: '#1f9e8a66',
+ },
+ barButton: {
+ position: 'absolute',
+ width: barButtonSize,
+ height: barButtonSize,
+ borderRadius: barButtonSize / 2,
+ alignItems: 'center',
+ justifyContent: 'center',
+ backgroundColor: '#14213d',
+ },
+ barButtonText: {
+ color: '#ffffff',
+ fontSize: 24,
+ fontWeight: '600',
+ },
controls: {
position: 'absolute',
left: 16,
diff --git a/ios/ReservedRegionsView.mm b/ios/ReservedRegionsView.mm
index 04b3897..4311af4 100644
--- a/ios/ReservedRegionsView.mm
+++ b/ios/ReservedRegionsView.mm
@@ -8,8 +8,15 @@
using namespace facebook::react;
+static NSDictionary *NoBar(double extent)
+{
+ return @{@"extent": @(extent), @"edge": @"none", @"frame": [NSValue valueWithCGRect:CGRectZero]};
+}
+
@implementation ReservedRegionsView {
NSArray *_currentRegions;
+ NSArray *_currentBars;
+ std::vector _barExtents;
BOOL _hasDispatchedRegions;
}
@@ -32,12 +39,26 @@ - (instancetype)initWithFrame:(CGRect)frame
[weakSelf setNeedsLayout];
}];
[self addInteraction:hingeInteraction];
+ // The bar layout region depends on UITraitCollection.verticalBarEdge, which UIKit's header says to observe
+ // through systemTraitsAffectingVerticalBarEdge.
+ [self registerForTraitChanges:UITraitCollection.systemTraitsAffectingVerticalBarEdge
+ withAction:@selector(setNeedsLayout)];
}
#endif
}
return self;
}
+- (void)updateProps:(Props::Shared const &)props oldProps:(Props::Shared const &)oldProps
+{
+ const auto &newProps = static_cast(*props);
+ if (newProps.barExtents != _barExtents) {
+ _barExtents = newProps.barExtents;
+ [self setNeedsLayout];
+ }
+ [super updateProps:props oldProps:oldProps];
+}
+
- (void)layoutSubviews
{
[super layoutSubviews];
@@ -74,6 +95,7 @@ - (void)updateRegions
}
NSMutableArray *regions = [NSMutableArray new];
+ NSMutableArray *bars = [NSMutableArray new];
#if defined(__IPHONE_27_1) && __IPHONE_OS_VERSION_MAX_ALLOWED >= __IPHONE_27_1
if (@available(iOS 27.1, *)) {
NSArray *kinds = @[
@@ -89,11 +111,21 @@ - (void)updateRegions
}
}
#endif
+ for (double extent : _barExtents) {
+ NSDictionary *bar = NoBar(extent);
+#if defined(__IPHONE_27_1) && __IPHONE_OS_VERSION_MAX_ALLOWED >= __IPHONE_27_1
+ if (@available(iOS 27.1, *)) {
+ bar = [self barForExtent:extent];
+ }
+#endif
+ [bars addObject:bar];
+ }
- if ([_currentRegions isEqualToArray:regions]) {
+ if ([_currentRegions isEqualToArray:regions] && [_currentBars isEqualToArray:bars]) {
return;
}
_currentRegions = [regions copy];
+ _currentBars = [bars copy];
auto regionPayloads = folly::dynamic::array();
for (NSDictionary *region in regions) {
@@ -107,27 +139,64 @@ - (void)updateRegions
("height", frame.size.height))
("occludesContent", false));
}
+ auto barPayloads = folly::dynamic::array();
+ for (NSDictionary *bar in bars) {
+ CGRect frame = [bar[@"frame"] CGRectValue];
+ barPayloads.push_back(folly::dynamic::object
+ ("extent", [bar[@"extent"] doubleValue])
+ ("edge", [bar[@"edge"] UTF8String])
+ ("frame", folly::dynamic::object
+ ("x", frame.origin.x)
+ ("y", frame.origin.y)
+ ("width", frame.size.width)
+ ("height", frame.size.height)));
+ }
+ auto payload = folly::dynamic::object("regions", std::move(regionPayloads))("bars", std::move(barPayloads));
auto eventEmitter = _eventEmitter;
if (_hasDispatchedRegions) {
// React Native's EventQueue replaces this view's pending unique event when it is the last one queued.
- eventEmitter->dispatchUniqueEvent(
- "regionsChange",
- folly::dynamic::object("regions", std::move(regionPayloads)));
+ eventEmitter->dispatchUniqueEvent("regionsChange", std::move(payload));
return;
}
_hasDispatchedRegions = YES;
- eventEmitter->experimental_flushSync([eventEmitter, regions = std::move(regionPayloads)]() mutable {
- eventEmitter->dispatchEvent(
- "regionsChange",
- folly::dynamic::object("regions", std::move(regions)),
- RawEvent::Category::Discrete);
+ eventEmitter->experimental_flushSync([eventEmitter, payload = std::move(payload)]() mutable {
+ eventEmitter->dispatchEvent("regionsChange", std::move(payload), RawEvent::Category::Discrete);
});
}
+#if defined(__IPHONE_27_1) && __IPHONE_OS_VERSION_MAX_ALLOWED >= __IPHONE_27_1
+- (NSDictionary *)barForExtent:(double)extent API_AVAILABLE(ios(27.1))
+{
+ UIWindow *window = self.window;
+ UIVerticalBarEdge edge = self.traitCollection.verticalBarEdge;
+ if (edge == UIVerticalBarEdgeUnspecified) {
+ return NoBar(extent);
+ }
+ UIViewLayoutRegion *layoutRegion = [UIViewLayoutRegion
+ layoutRegionForBarOnDirectionalEdge:edge == UIVerticalBarEdgeLeading ? NSDirectionalRectEdgeLeading
+ : NSDirectionalRectEdgeTrailing
+ extent:extent];
+ // UIKit computes a view's bar layout region from that view's own bounds and safe area, so a provider that is
+ // shifted or inset away from the column gets a region at its own trailing edge. Only the window's region matches
+ // the system bar.
+ CGRect windowBar = UIEdgeInsetsInsetRect(window.bounds, [window edgeInsetsForLayoutRegion:layoutRegion]);
+ CGRect frame = [window convertRect:windowBar toView:self];
+ if (CGRectIsEmpty(windowBar) || !CGRectIntersectsRect(frame, self.bounds)) {
+ return NoBar(extent);
+ }
+ return @{
+ @"extent": @(extent),
+ @"edge": edge == UIVerticalBarEdgeLeading ? @"leading" : @"trailing",
+ @"frame": [NSValue valueWithCGRect:frame],
+ };
+}
+#endif
+
- (void)prepareForRecycle
{
[super prepareForRecycle];
_currentRegions = nil;
+ _currentBars = nil;
_hasDispatchedRegions = NO;
}
diff --git a/src/ReservedRegionsView.tsx b/src/ReservedRegionsView.tsx
index 2b95f19..ddac5e9 100644
--- a/src/ReservedRegionsView.tsx
+++ b/src/ReservedRegionsView.tsx
@@ -3,12 +3,13 @@ import { View, type ViewProps } from 'react-native';
import type { RegionsChangeEvent } from './ReservedRegionsViewNativeComponent';
export type ReservedRegionsViewProps = ViewProps & {
+ barExtents?: readonly number[];
onRegionsChange?: (event: { nativeEvent: RegionsChangeEvent }) => void;
};
-export function ReservedRegionsView({ onRegionsChange, ...props }: ReservedRegionsViewProps) {
+export function ReservedRegionsView({ onRegionsChange, barExtents: _barExtents, ...props }: ReservedRegionsViewProps) {
useEffect(() => {
- onRegionsChange?.({ nativeEvent: { regions: [] } });
+ onRegionsChange?.({ nativeEvent: { regions: [], bars: [] } });
}, [onRegionsChange]);
return ;
}
diff --git a/src/ReservedRegionsViewNativeComponent.ts b/src/ReservedRegionsViewNativeComponent.ts
index 350f0ce..9e38e8d 100644
--- a/src/ReservedRegionsViewNativeComponent.ts
+++ b/src/ReservedRegionsViewNativeComponent.ts
@@ -11,9 +11,20 @@ export type RegionsChangeEvent = Readonly<{
};
occludesContent: boolean;
}[];
+ bars?: {
+ extent: CodegenTypes.Double;
+ edge: string;
+ frame: {
+ x: CodegenTypes.Double;
+ y: CodegenTypes.Double;
+ width: CodegenTypes.Double;
+ height: CodegenTypes.Double;
+ };
+ }[];
}>;
export interface NativeProps extends ViewProps {
+ barExtents?: ReadonlyArray;
onRegionsChange?: CodegenTypes.DirectEventHandler;
}
diff --git a/src/__tests__/index.test.tsx b/src/__tests__/index.test.tsx
index bd6fd9c..89afa2f 100644
--- a/src/__tests__/index.test.tsx
+++ b/src/__tests__/index.test.tsx
@@ -5,6 +5,7 @@ import { act, create } from 'react-test-renderer';
import {
ReservedRegionsGate,
ReservedRegionsProvider,
+ useBarRegion,
useReservedRegions,
useReservedRegionsReady,
type ReservedRegion,
@@ -13,25 +14,32 @@ import type { RegionsChangeEvent } from '../ReservedRegionsViewNativeComponent';
type Handler = (event: { nativeEvent: RegionsChangeEvent }) => void;
const mockHandlers = new Map();
+const mockBarExtents = new Map();
jest.mock('../ReservedRegionsView', () => ({
ReservedRegionsView: ({
children,
+ barExtents,
onRegionsChange,
testID = 'default',
ref,
}: {
children: React.ReactNode;
+ barExtents?: readonly number[];
onRegionsChange: Handler;
testID?: string;
ref?: React.Ref>;
}) => {
mockHandlers.set(testID, onRegionsChange);
+ mockBarExtents.set(testID, barExtents);
const { View: MockView } = jest.requireActual('react-native');
return {children};
},
}));
-beforeEach(() => mockHandlers.clear());
+beforeEach(() => {
+ mockHandlers.clear();
+ mockBarExtents.clear();
+});
const division = {
kind: 'division',
@@ -174,6 +182,82 @@ it('attaches a ref to the provider view', async () => {
await act(() => renderer.unmount());
});
+const barFrame = { x: 394, y: 174, width: 48, height: 470 };
+
+function Bar({ extent }: { extent: number }) {
+ useBarRegion({ extent });
+ return null;
+}
+
+function barTree(extents: number[]) {
+ return (
+
+ {extents.map((extent, index) => (
+
+ ))}
+
+ );
+}
+
+it('requests each bar extent once and stops requesting it after the last hook unmounts', async () => {
+ let renderer: ReturnType;
+ await act(() => {
+ renderer = create(barTree([48, 44, 48]));
+ });
+ expect(mockBarExtents.get('default')).toEqual([48, 44]);
+ await act(() => renderer.update(barTree([48, 44])));
+ expect(mockBarExtents.get('default')).toEqual([48, 44]);
+ await act(() => renderer.update(barTree([44])));
+ expect(mockBarExtents.get('default')).toEqual([44]);
+ await act(() => renderer.update(barTree([])));
+ expect(mockBarExtents.get('default')).toEqual([]);
+ await act(() => renderer.unmount());
+});
+
+it('returns null until the extent is measured, then the provider-relative bar region', async () => {
+ const observed: ReturnType[] = [];
+ function Probe() {
+ observed.push(useBarRegion({ extent: 48 }));
+ return null;
+ }
+ let renderer: ReturnType;
+ await act(() => {
+ renderer = create(
+
+
+ ,
+ );
+ });
+ expect(observed.at(-1)).toBeNull();
+ await act(() => mockHandlers.get('default')?.({ nativeEvent: { regions: [] } }));
+ expect(observed.at(-1)).toBeNull();
+ await act(() =>
+ mockHandlers.get('default')?.({
+ nativeEvent: { regions: [], bars: [{ extent: 44, edge: 'trailing', frame: barFrame }] },
+ }),
+ );
+ expect(observed.at(-1)).toBeNull();
+ await act(() =>
+ mockHandlers.get('default')?.({
+ nativeEvent: { regions: [], bars: [{ extent: 48, edge: 'trailing', frame: barFrame }] },
+ }),
+ );
+ expect(observed.at(-1)).toEqual({ edge: 'trailing', frame: barFrame });
+ await act(() =>
+ mockHandlers.get('default')?.({
+ nativeEvent: { regions: [], bars: [{ extent: 48, edge: 'leading', frame: barFrame }] },
+ }),
+ );
+ expect(observed.at(-1)).toEqual({ edge: 'leading', frame: barFrame });
+ await act(() =>
+ mockHandlers.get('default')?.({
+ nativeEvent: { regions: [], bars: [{ extent: 48, edge: 'none', frame: { x: 0, y: 0, width: 0, height: 0 } }] },
+ }),
+ );
+ expect(observed.at(-1)).toBeNull();
+ await act(() => renderer.unmount());
+});
+
it.each([useReservedRegions, useReservedRegionsReady])('%p throws outside a provider', (useHook) => {
function Probe() {
useHook();
@@ -186,6 +270,14 @@ it.each([useReservedRegions, useReservedRegionsReady])('%p throws outside a prov
}).toThrow(`${useHook.name} must be used inside ReservedRegionsProvider`);
});
+it('useBarRegion throws outside a provider', () => {
+ expect(() => {
+ act(() => {
+ create();
+ });
+ }).toThrow('useBarRegion must be used inside ReservedRegionsProvider');
+});
+
it('the unsupported-platform view reports a known empty result', async () => {
const { ReservedRegionsView } =
jest.requireActual('../ReservedRegionsView.tsx');
@@ -194,6 +286,6 @@ it('the unsupported-platform view reports a known empty result', async () => {
await act(() => {
renderer = create();
});
- expect(onRegionsChange).toHaveBeenCalledWith({ nativeEvent: { regions: [] } });
+ expect(onRegionsChange).toHaveBeenCalledWith({ nativeEvent: { regions: [], bars: [] } });
await act(() => renderer.unmount());
});
diff --git a/src/index.tsx b/src/index.tsx
index f80b7b6..d0fd086 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -32,22 +32,71 @@ export type ReservedRegion =
frame: ReservedRegionFrame;
}>;
+/** Where the system places bar content of a given extent, such as the iPhone Duo status column. */
+export type BarRegion = Readonly<{
+ /** The layout-direction edge that holds the vertical bar. */
+ edge: 'leading' | 'trailing';
+ /** The bar layout region's bounds in the provider's coordinate space. */
+ frame: ReservedRegionFrame;
+}>;
+
+/** Options for useBarRegion. */
+export type BarRegionOptions = Readonly<{
+ /** Width in points of the bar content to place, such as a 44-point button. */
+ extent: number;
+}>;
+
/** Props for ReservedRegionsProvider. */
type ReservedRegionsProviderProps = ViewProps & {
/** Receives the provider's native view, for example to call `measure`. */
ref?: React.Ref>;
};
+type MeasuredBar = Readonly<{ extent: number; region: BarRegion | null }>;
+
type ReservedRegionsSnapshot = Readonly<{
regions: readonly ReservedRegion[];
+ bars: readonly MeasuredBar[];
isReady: boolean;
}>;
-const ReservedRegionsContext = React.createContext(null);
+type ReservedRegionsContextValue = Readonly<{
+ snapshot: ReservedRegionsSnapshot;
+ registerBarExtent: (extent: number) => () => void;
+}>;
+
+const ReservedRegionsContext = React.createContext(null);
+
+function useReservedRegionsContext(hookName: string): ReservedRegionsContextValue {
+ const context = React.useContext(ReservedRegionsContext);
+ if (context === null) {
+ throw new Error(`${hookName} must be used inside ReservedRegionsProvider`);
+ }
+ return context;
+}
/** Makes active reserved regions available to descendants in provider coordinates. */
export function ReservedRegionsProvider({ children, ...props }: ReservedRegionsProviderProps): React.JSX.Element {
- const [snapshot, setSnapshot] = React.useState({ regions: [], isReady: false });
+ const [snapshot, setSnapshot] = React.useState({ regions: [], bars: [], isReady: false });
+ const [barExtents, setBarExtents] = React.useState([]);
+ const barExtentCounts = React.useRef(new Map());
+ const registerBarExtent = React.useCallback((extent: number) => {
+ const counts = barExtentCounts.current;
+ const count = counts.get(extent) ?? 0;
+ counts.set(extent, count + 1);
+ if (count === 0) {
+ setBarExtents([...counts.keys()]);
+ }
+ return () => {
+ const remaining = (counts.get(extent) ?? 0) - 1;
+ if (remaining > 0) {
+ counts.set(extent, remaining);
+ return;
+ }
+ counts.delete(extent);
+ setBarExtents([...counts.keys()]);
+ };
+ }, []);
const onRegionsChange = React.useCallback((event: { nativeEvent: RegionsChangeEvent }) => {
const regions = event.nativeEvent.regions.flatMap((region): ReservedRegion[] => {
if (region.kind === 'division') {
@@ -58,12 +107,17 @@ export function ReservedRegionsProvider({ children, ...props }: ReservedRegionsP
}
return [];
});
- setSnapshot({ regions, isReady: true });
+ const bars = (event.nativeEvent.bars ?? []).map((bar): MeasuredBar => ({
+ extent: bar.extent,
+ region: bar.edge === 'leading' || bar.edge === 'trailing' ? { edge: bar.edge, frame: bar.frame } : null,
+ }));
+ setSnapshot({ regions, bars, isReady: true });
}, []);
+ const context = React.useMemo(() => ({ snapshot, registerBarExtent }), [snapshot, registerBarExtent]);
return (
-
-
+
+
{children}
@@ -72,11 +126,7 @@ export function ReservedRegionsProvider({ children, ...props }: ReservedRegionsP
/** Returns active regions reported by the nearest ReservedRegionsProvider. */
export function useReservedRegions(): readonly ReservedRegion[] {
- const snapshot = React.useContext(ReservedRegionsContext);
- if (snapshot === null) {
- throw new Error('useReservedRegions must be used inside ReservedRegionsProvider');
- }
- return snapshot.regions;
+ return useReservedRegionsContext('useReservedRegions').snapshot.regions;
}
/**
@@ -84,11 +134,19 @@ export function useReservedRegions(): readonly ReservedRegion[] {
* result. Remains true for that provider's lifetime; does not imply platform support.
*/
export function useReservedRegionsReady(): boolean {
- const snapshot = React.useContext(ReservedRegionsContext);
- if (snapshot === null) {
- throw new Error('useReservedRegionsReady must be used inside ReservedRegionsProvider');
- }
- return snapshot.isReady;
+ return useReservedRegionsContext('useReservedRegionsReady').snapshot.isReady;
+}
+
+/**
+ * Returns where the system places bar content of the given extent, such as the iPhone Duo
+ * status column, in the nearest provider's coordinates. Returns null when no vertical bar
+ * overlaps the provider, on platforms without vertical bars, and until the provider has
+ * measured this extent, which happens one layout pass after the extent is first requested.
+ */
+export function useBarRegion({ extent }: BarRegionOptions): BarRegion | null {
+ const { snapshot, registerBarExtent } = useReservedRegionsContext('useBarRegion');
+ React.useLayoutEffect(() => registerBarExtent(extent), [registerBarExtent, extent]);
+ return snapshot.bars.find((bar) => bar.extent === extent)?.region ?? null;
}
/** Renders children after the nearest provider reports its first measurement. */
diff --git a/website/docs/api.md b/website/docs/api.md
index c06a234..37676f2 100644
--- a/website/docs/api.md
+++ b/website/docs/api.md
@@ -55,6 +55,44 @@ Readiness and regions update together. Readiness stays `true` until the provider
Calling the hook outside a provider throws an error.
+## useBarRegion
+
+```ts
+function useBarRegion(options: BarRegionOptions): BarRegion | null;
+```
+
+Returns where the system places bar content of the given `extent`, in the nearest provider’s coordinates. On iPhone Duo this is the status column; native back buttons and tab bar items are positioned from the same region.
+
+`extent` is the width in points of the content you place, such as a 44-point button. The region depends on it, so each extent is measured separately. Hooks that request the same extent share one measurement.
+
+Returns `null` when:
+
+- No vertical bar overlaps the provider.
+- The app is built with an SDK older than iOS 27.1, or runs on an older iOS version.
+- The app runs on Android or another platform.
+- The provider has not measured this extent yet.
+
+The provider measures a new extent one layout pass after the hook first requests it, so the first render returns `null` even on a device with a vertical bar. `useReservedRegionsReady` and `ReservedRegionsGate` do not wait for bar regions.
+
+Calling the hook outside a provider throws an error.
+
+See [Vertical bars](./coordinates.md#vertical-bars) for an example.
+
+## BarRegion
+
+```ts
+type BarRegionOptions = Readonly<{
+ extent: number;
+}>;
+
+type BarRegion = Readonly<{
+ edge: 'leading' | 'trailing';
+ frame: ReservedRegionFrame;
+}>;
+```
+
+`edge` is the layout-direction edge that holds the vertical bar. `frame` is the region for content of the requested extent, relative to the provider. Like other frames, it is not clipped to the provider.
+
## ReservedRegionFrame
Describes a region’s position and size.
diff --git a/website/docs/coordinates.md b/website/docs/coordinates.md
index 41489a6..3fd368f 100644
--- a/website/docs/coordinates.md
+++ b/website/docs/coordinates.md
@@ -43,6 +43,44 @@ Example of a camera cutout, using illustrative values:
}
```
+### Vertical bars
+
+Some displays place system status in a vertical bar along one side, such as the status column on iPhone Duo. Occlusions mark where the column hides content. A bar region marks where controls go: UIKit centers it on the status icons and places native bar items, such as the back button, inside it.
+
+Request the region with the width of the content you place:
+
+```tsx
+import { Pressable, StyleSheet } from 'react-native';
+import { useBarRegion } from 'react-native-reserved-regions';
+
+function BackButton({ onPress }: { onPress: () => void }) {
+ const bar = useBarRegion({ extent: 44 });
+ if (bar === null) {
+ return null;
+ }
+ return (
+
+ );
+}
+
+const styles = StyleSheet.create({
+ button: { position: 'absolute', width: 44, height: 44 },
+});
+```
+
+Measured on the iPhone Duo simulator running iOS 27.1, with a full-screen provider:
+
+| Posture | Provider | Occlusions | Bar region, extent 44 | Bar region, extent 48 |
+| -------- | --------- | ------------------------------------------------------------- | --------------------- | --------------------- |
+| Folded | 466 × 678 | camera `399.7, 29.3, 37 × 37`, status area `382, 0, 84 × 174` | `396, 174, 44 × 470` | `394, 174, 48 × 470` |
+| Unfolded | 951 × 669 | status area `867, 0, 84 × 124` | `881, 124, 44 × 511` | `879, 124, 48 × 511` |
+
+The bar region starts below the status area and is centered on the same vertical line for each extent.
+
### Position and size
The provider is the view your app measures. Both region types include a `frame` describing their position and size relative to that view.
diff --git a/website/docs/example.md b/website/docs/example.md
index 8dd3592..30e2f4f 100644
--- a/website/docs/example.md
+++ b/website/docs/example.md
@@ -49,11 +49,14 @@ See [Getting Started](./installation.md) for platform requirements.
| Dashed green | Safe area edges |
| Orange | Divisions |
| Purple | Occlusions |
+| Teal | Bar region |
Zero-width or zero-height divisions are drawn as two-point lines so they remain visible. The text shows their original measurements.
The app displays Pending until measurement completes, then Ready. The example displays the regions reported by your device.
+When the device has a vertical bar, the example draws the 44-point bar region and places a round button inside it. The text lists the bar regions for 44- and 48-point extents and counts taps on the button.
+
## Change the provider bounds
| Mode | Layout |
diff --git a/website/docs/platforms.md b/website/docs/platforms.md
index e398f8f..9a33aa0 100644
--- a/website/docs/platforms.md
+++ b/website/docs/platforms.md
@@ -18,6 +18,8 @@ Divisions are reported with `occludesContent: false`. They mark a boundary in th
Regions with `kind: 'occlusion'` identify areas where content is hidden, such as a camera cutout or space reserved by iOS. Keep text and controls outside these areas.
+`useBarRegion` reads `UITraitCollection.verticalBarEdge` and the window’s `UIViewLayoutRegion` for a bar on that edge, then converts the frame to provider coordinates. It returns `null` when the edge is unspecified, the region is empty or the region does not overlap the provider. The window is queried because UIKit computes a view’s own bar region from that view’s bounds: a provider shifted or inset away from the column would get a region at its own edge instead of the column.
+
[Apple: adaptive layouts on iPhone Duo](https://developer.apple.com/videos/play/tech-talks/111463/)
## Android
@@ -40,6 +42,8 @@ Results can change when the device folds or unfolds. Use a compatible foldable d
The library does not report keyboards, system bars, or overlapping app windows as occlusions.
+`useBarRegion` always returns `null` on Android.
+
[Android: FoldingFeature](https://developer.android.com/reference/androidx/window/layout/FoldingFeature) · [Android: DisplayCutout](https://developer.android.com/reference/android/view/DisplayCutout)
## Other platforms
@@ -57,6 +61,8 @@ The provider measures regions when its layout or the supported device features c
| The provider lays out | The provider mounts or lays out |
| The provider moves into a window | WindowManager reports a fold change |
| A hinge update arrives | Window insets reach the provider |
+| The vertical bar edge changes | |
+| `useBarRegion` adds an extent | |
Scrolling, moving a parent view, or applying a transform does not trigger a measurement.