From 00c04516c2b48ebc6fe3f6333e136f016bcf0faa Mon Sep 17 00:00:00 2001 From: Janic Duplessis Date: Wed, 21 Oct 2026 01:11:35 -0400 Subject: [PATCH] feat: add useBarRegion for the iOS 27.1 vertical bar layout region Report where UIKit places bar content of a given extent, such as the iPhone Duo status column, relative to the nearest provider. The provider passes the requested extents to the native view, which measures them in the same pass as divisions and occlusions. Android and iOS before 27.1 return null. --- docs/workflow.md | 3 +- e2e/android.mjs | 5 ++ example/src/App.tsx | 61 +++++++++++++- ios/ReservedRegionsView.mm | 87 +++++++++++++++++--- src/ReservedRegionsView.tsx | 5 +- src/ReservedRegionsViewNativeComponent.ts | 11 +++ src/__tests__/index.test.tsx | 96 ++++++++++++++++++++++- src/index.tsx | 88 +++++++++++++++++---- website/docs/api.md | 38 +++++++++ website/docs/coordinates.md | 38 +++++++++ website/docs/example.md | 3 + website/docs/platforms.md | 6 ++ 12 files changed, 410 insertions(+), 31 deletions(-) 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.