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.