Skip to content
Draft
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
3 changes: 2 additions & 1 deletion docs/workflow.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions e2e/android.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
};
}

Expand Down Expand Up @@ -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) {
Expand Down
61 changes: 59 additions & 2 deletions example/src/App.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
Expand Down Expand Up @@ -42,12 +60,21 @@ function RegionInspector() {
]}
/>
))}
{bar ? (
<View
style={[
styles.overlay,
styles.bar,
{ left: bar.frame.x, top: bar.frame.y, width: bar.frame.width, height: bar.frame.height },
]}
/>
) : null}
</View>
<ScrollView contentContainerStyle={[styles.content, { paddingTop: Math.max(insets.top, 24) + 24 }]}>
<Text style={styles.title}>Reserved regions</Text>
<Text style={styles.description}>
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.
</Text>
<Text style={styles.metric}>
Provider: {frame.width.toFixed(1)} × {frame.height.toFixed(1)}
Expand All @@ -73,7 +100,20 @@ function RegionInspector() {
</Text>
))
)}
<Text style={styles.metric}>{describeBar(barButtonSize, bar)}</Text>
<Text style={styles.metric}>{describeBar(48, wideBar)}</Text>
<Text style={styles.metric}>Bar button taps: {barTaps}</Text>
</ScrollView>
{bar ? (
<Pressable
accessibilityLabel="Bar button"
accessibilityRole="button"
onPress={() => setBarTaps((taps) => taps + 1)}
style={[styles.barButton, { left: bar.frame.x + (bar.frame.width - barButtonSize) / 2, top: bar.frame.y }]}
>
<Text style={styles.barButtonText}>+</Text>
</Pressable>
) : null}
<View pointerEvents="none" style={styles.boundary} />
</>
);
Expand Down Expand Up @@ -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,
Expand Down
87 changes: 78 additions & 9 deletions ios/ReservedRegionsView.mm
Original file line number Diff line number Diff line change
Expand Up @@ -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<NSDictionary *> *_currentRegions;
NSArray<NSDictionary *> *_currentBars;
std::vector<double> _barExtents;
BOOL _hasDispatchedRegions;
}

Expand All @@ -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<const ReservedRegionsViewProps &>(*props);
if (newProps.barExtents != _barExtents) {
_barExtents = newProps.barExtents;
[self setNeedsLayout];
}
[super updateProps:props oldProps:oldProps];
}

- (void)layoutSubviews
{
[super layoutSubviews];
Expand Down Expand Up @@ -74,6 +95,7 @@ - (void)updateRegions
}

NSMutableArray<NSDictionary *> *regions = [NSMutableArray new];
NSMutableArray<NSDictionary *> *bars = [NSMutableArray new];
#if defined(__IPHONE_27_1) && __IPHONE_OS_VERSION_MAX_ALLOWED >= __IPHONE_27_1
if (@available(iOS 27.1, *)) {
NSArray<UIViewReservedRegionKind *> *kinds = @[
Expand All @@ -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) {
Expand All @@ -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;
}

Expand Down
5 changes: 3 additions & 2 deletions src/ReservedRegionsView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <View {...props} />;
}
11 changes: 11 additions & 0 deletions src/ReservedRegionsViewNativeComponent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<CodegenTypes.Double>;
onRegionsChange?: CodegenTypes.DirectEventHandler<RegionsChangeEvent>;
}

Expand Down
Loading
Loading