fix(app): use the top band beside the iPhone Duo status region

On iOS 27.1+, the window layout probe publishes the top bar guide frame
and active occlusion rects. When the top bar starts above the system top
inset, the safe-area top moves up to it, and each header row that lands
in the band trims its trailing padding to the status region. A row
whose controls cannot fit beside it starts below the region on its own.

When the guide is missing or starts at or below the system top, the
override is a no-op, which is the case on every iPhone and on the Duo
outer screen.

Co-authored-by: Elsa Granger <zeyugao@outlook.com>
This commit is contained in:
Mohamed Boudra
2026-11-06 05:10:28 +01:00
co-authored by Elsa Granger
parent 3ed57e9dd7
commit 7bafd0fdad
13 changed files with 532 additions and 80 deletions
@@ -18,6 +18,8 @@ final class WindowLayoutView: ExpoView {
private var barGuide: UILayoutGuide?
private var barEdge = "none"
private var bottomBarGuide: UILayoutGuide?
private var topBarGuide: UILayoutGuide?
private var occlusions: [CGRect] = []
private var measuredBounds = CGRect.zero
required init(appContext: AppContext? = nil) {
@@ -38,6 +40,7 @@ final class WindowLayoutView: ExpoView {
super.didMoveToWindow()
previousPayload = nil
bottomBarGuide = nil
topBarGuide = nil
setNeedsUpdateConstraints()
scheduleUpdate()
}
@@ -62,6 +65,8 @@ final class WindowLayoutView: ExpoView {
if #available(iOS 27.1, *) {
// UIKit tracks these reads and invalidates constraints when regions change.
divisions = reservedRegions(kind: .division).compactMap { $0.isActive ? $0.frame : nil }
// Frames already include the system's interactive margins.
occlusions = reservedRegions(kind: .occlusion).compactMap { $0.isActive ? $0.frame : nil }
let edge: String
switch traitCollection.verticalBarEdge {
case .leading: edge = "leading"
@@ -73,6 +78,11 @@ final class WindowLayoutView: ExpoView {
// screen's corners rather than the rectangular safe area.
bottomBarGuide = layoutGuide(for: .bar(onEdge: UIRectEdge.bottom, extent: 44))
}
if topBarGuide == nil {
// Where a top bar rests. Beside a partial status region it can start above the
// rectangular safe area; content still keeps out of the occlusions.
topBarGuide = layoutGuide(for: .bar(onEdge: UIRectEdge.top, extent: 44))
}
if edge != barEdge {
barEdge = edge
barGuide = edge == "none" ? nil : layoutGuide(
@@ -106,11 +116,17 @@ final class WindowLayoutView: ExpoView {
if let bottomBar = bottomBarGuide?.layoutFrame, bottomBar.height > 0 {
bottomClearance = max(0, bounds.maxY - bottomBar.maxY)
}
var topBar: Any = NSNull()
if let frame = topBarGuide?.layoutFrame, frame.width > 0, frame.height > 0 {
topBar = rect(frame)
}
let payload: [String: Any] = [
"bounds": rect(bounds),
"verticalBarEdge": barEdge,
"verticalBar": barPayload,
"bottomClearance": bottomClearance,
"topBar": topBar,
"occlusions": occlusions.map(rect),
"divisions": divisions.map(rect)
]
guard !NSDictionary(dictionary: payload).isEqual(to: previousPayload ?? [:]) else { return }
@@ -1,6 +1,6 @@
import { useCallback, useMemo, type ReactNode } from "react";
import { useCallback, useMemo, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Text, View } from "react-native";
import { Text, View, type LayoutChangeEvent } from "react-native";
import { router } from "expo-router";
import { ArrowLeft } from "lucide-react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
@@ -12,12 +12,19 @@ import {
iconButtonChromeGlyphSize,
mutedIconColorMapping,
} from "@/components/ui/icon-button-chrome";
import { TOUCH_TARGET } from "@/components/ui/control-sizing";
import { HEADER_INNER_HEIGHT_MOBILE, HEADER_TOP_PADDING_MOBILE } from "@/constants/layout";
import { SPACING } from "@/styles/theme";
import { useTopBandRow } from "@/window-layout";
import type { ShortcutKey } from "@/utils/format-shortcut";
const ThemedArrowLeft = withUnistyles(ArrowLeft);
const NO_SHORTCUT: ShortcutKey[] = [];
const ROW_PADDING = SPACING[3];
const ROW_GAP = SPACING[2];
// Below this the title is unreadable; the row starts below the system's occlusions instead.
const MIN_TITLE_WIDTH = 96;
interface CompactHeaderProps {
/** The leading island: the sidebar menu, or a back button. */
@@ -51,21 +58,38 @@ export function CompactHeader({
actions,
}: CompactHeaderProps) {
const insets = useSafeAreaInsets();
const [actionsWidth, setActionsWidth] = useState(0);
const measureActions = useCallback(
(event: LayoutChangeEvent) => setActionsWidth(event.nativeEvent.layout.width),
[],
);
// The trailing side gives way to the occlusion, so it needs no padding of its own.
const topBand = useTopBandRow({
offset: insets.top + HEADER_TOP_PADDING_MOBILE,
height: HEADER_INNER_HEIGHT_MOBILE,
minimumWidth:
ROW_PADDING +
TOUCH_TARGET +
ROW_GAP +
MIN_TITLE_WIDTH +
(actions ? ROW_GAP + actionsWidth : 0),
});
const rootStyle = useMemo(
() => [
styles.root,
{
paddingTop: insets.top + HEADER_TOP_PADDING_MOBILE,
paddingTop: insets.top + HEADER_TOP_PADDING_MOBILE + topBand.top,
paddingRight: Math.max(ROW_PADDING, topBand.trailing),
marginLeft: insets.left,
marginRight: insets.right,
},
],
[insets.top, insets.left, insets.right],
[insets.top, insets.left, insets.right, topBand.top, topBand.trailing],
);
return (
<IconButtonChromeProvider value="header-island">
<View style={rootStyle}>
<View ref={topBand.ref} onLayout={topBand.onLayout} style={rootStyle}>
<View style={styles.row}>
<View style={styles.island}>
{navigation === "menu" ? <SidebarMenuToggle /> : <BackButton onBack={onBack} />}
@@ -78,7 +102,11 @@ export function CompactHeader({
subtitle={subtitle}
/>
</View>
{actions ? <View style={styles.island}>{actions}</View> : null}
{actions ? (
<View style={styles.island} onLayout={measureActions}>
{actions}
</View>
) : null}
</View>
</View>
</IconButtonChromeProvider>
@@ -140,13 +168,13 @@ function BackButton({ onBack }: { onBack?: () => void }) {
const styles = StyleSheet.create((theme) => ({
root: {
paddingHorizontal: theme.spacing[3],
paddingLeft: ROW_PADDING,
},
row: {
height: HEADER_INNER_HEIGHT_MOBILE,
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
gap: ROW_GAP,
},
titleGroup: {
flex: 1,
@@ -1,5 +1,5 @@
import { ComposerDockBackground } from "@/composer/dock";
import { useMemo, type ReactNode } from "react";
import { useCallback, useMemo, useState, type ReactNode } from "react";
import type { LayoutChangeEvent } from "react-native";
import { View, type StyleProp, type ViewStyle } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
@@ -12,6 +12,11 @@ import {
} from "@/constants/layout";
import { WindowChromeSafeArea } from "@/utils/desktop-window";
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
import { TOUCH_TARGET } from "@/components/ui/control-sizing";
import { useTopBandRow } from "@/window-layout";
// Leading content that must stay readable in the top band: a navigation button and a title.
const MIN_LEADING_WIDTH = TOUCH_TARGET + 8 + 96;
interface ScreenHeaderProps {
left?: ReactNode;
@@ -41,17 +46,37 @@ export function ScreenHeader({
const topPadding = isMobile ? HEADER_TOP_PADDING_MOBILE : 0;
const baseHorizontalPadding = isMobile ? theme.spacing[2] : theme.spacing[3];
const [rightWidth, setRightWidth] = useState(0);
const measureRight = useCallback(
(event: LayoutChangeEvent) => setRightWidth(event.nativeEvent.layout.width),
[],
);
const topBand = useTopBandRow({
offset: insets.top + topPadding,
height: isMobile ? HEADER_INNER_HEIGHT_MOBILE : HEADER_INNER_HEIGHT,
minimumWidth: baseHorizontalPadding + MIN_LEADING_WIDTH + rightWidth,
});
const innerStyle = useMemo(
() => [styles.inner, { paddingTop: insets.top + topPadding }],
[insets.top, topPadding],
() => [styles.inner, { paddingTop: insets.top + topPadding + topBand.top }],
[insets.top, topPadding, topBand.top],
);
const rowStyle = useMemo(() => [styles.row, borderless && styles.borderless], [borderless]);
const leftCombinedStyle = useMemo(() => [styles.left, leftStyle], [leftStyle]);
const rightCombinedStyle = useMemo(() => [styles.right, rightStyle], [rightStyle]);
// The occlusion replaces the row's trailing padding rather than adding to it.
const rightCombinedStyle = useMemo(
() => [
styles.right,
rightStyle,
topBand.trailing > baseHorizontalPadding && {
marginRight: topBand.trailing - baseHorizontalPadding,
},
],
[rightStyle, topBand.trailing, baseHorizontalPadding],
);
return (
<ComposerDockBackground style={styles.header} testID="composer-dock-header">
<View style={innerStyle}>
<View ref={topBand.ref} onLayout={topBand.onLayout} style={innerStyle}>
<WindowChromeSafeArea
placement="inline"
horizontalPadding={baseHorizontalPadding}
@@ -60,7 +85,9 @@ export function ScreenHeader({
>
<TitlebarDragRegion />
<View style={leftCombinedStyle}>{left}</View>
<View style={rightCombinedStyle}>{right}</View>
<View style={rightCombinedStyle} onLayout={measureRight}>
{right}
</View>
</WindowChromeSafeArea>
</View>
</ComposerDockBackground>
+21 -10
View File
@@ -29,7 +29,7 @@ import {
resolveDesktopSidebarInsetLeft,
resolveDesktopSidebarWidth,
} from "@/components/desktop-sidebar-layout";
import { useWindowLayout, resolveDesktopDivisionLayout } from "@/window-layout";
import { useWindowLayout, resolveDesktopDivisionLayout, useTopBandRow } from "@/window-layout";
import {
SIDEBAR_RESIZE_ACTIVATION_OFFSET,
SIDEBAR_RESIZE_FAIL_OFFSET,
@@ -46,6 +46,7 @@ import { SidebarSectionHeader } from "@/components/sidebar/sidebar-section-heade
import { Shortcut } from "@/components/ui/shortcut";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { HEADER_INNER_HEIGHT, useIsCompactFormFactor } from "@/constants/layout";
import { TOUCH_TARGET } from "@/components/ui/control-sizing";
import { isNative as platformIsNative } from "@/constants/platform";
import { useOpenAddProject } from "@/hooks/use-open-add-project";
import { useImportSession } from "@/hooks/use-import-session";
@@ -527,6 +528,14 @@ function MobileSidebar({
closeSidebar();
}, [closeSidebar]);
// The first nav row carries the close button; the panel slides in, so re-measure once settled.
const navBand = useTopBandRow({
offset: theme.spacing[2],
height: TOUCH_TARGET,
minimumWidth: TOUCH_TARGET * 2,
settled: active,
});
const navBandStyle = useMemo(() => ({ paddingTop: navBand.top }), [navBand.top]);
const closeButton = useMemo(
() => (
<SidebarCloseButton
@@ -534,10 +543,10 @@ function MobileSidebar({
onPress={closeSidebar}
testID="sidebar-close"
nativeID="sidebar-close"
style={styles.mobileCloseButton(insets.right)}
style={styles.mobileCloseButton(insets.right, navBand.trailing)}
/>
),
[labels.closeSidebar, closeSidebar, insets.right],
[labels.closeSidebar, closeSidebar, insets.right, navBand.trailing],
);
const mobileSidebarInsetStyle = useMemo(
@@ -550,11 +559,13 @@ function MobileSidebar({
);
const navRows = (
<SidebarNavRows
style={styles.mobileNavRowsGroup}
onBeforeNavigate={closeSidebar}
firstRowTrailing={closeButton}
/>
<View ref={navBand.ref} onLayout={navBand.onLayout} style={navBandStyle}>
<SidebarNavRows
style={styles.mobileNavRowsGroup}
onBeforeNavigate={closeSidebar}
firstRowTrailing={closeButton}
/>
</View>
);
return (
@@ -894,8 +905,8 @@ const styles = StyleSheet.create((theme) => ({
flex: 1,
minHeight: 0,
},
mobileCloseButton: (rightInset: number) => ({
marginRight: theme.spacing[2] + rightInset,
mobileCloseButton: (rightInset: number, topBandTrailing: number) => ({
marginRight: Math.max(theme.spacing[2] + rightInset, topBandTrailing),
}),
desktopSidebarBorder: (hasDivision: boolean) => ({
borderRightWidth: hasDivision ? 0 : 1,
+20 -1
View File
@@ -19,6 +19,8 @@ import type { Theme } from "@/styles/theme";
import { useTranslation } from "react-i18next";
import { useIsCompactFormFactor } from "@/constants/layout";
import { isWeb } from "@/constants/platform";
import { TOUCH_TARGET } from "@/components/ui/control-sizing";
import { useTopBandRow } from "@/window-layout";
import { AlertTriangle, CheckCircle2, Info } from "lucide-react-native";
import { getOverlayRoot, OVERLAY_Z } from "@/lib/overlay-root";
import {
@@ -200,13 +202,30 @@ export function ToastViewport({
const topOffset =
placement === "app-shell" ? insets.top + headerTopPadding + headerHeight : panelClearance;
// A centred toast cannot trim one side, so it always moves below an occlusion it meets.
const topBand = useTopBandRow({
offset: 0,
height: TOUCH_TARGET,
minimumWidth: Number.POSITIVE_INFINITY,
});
const widthBoundaryStyle = useMemo(
() => [styles.widthBoundary, { paddingTop: topBand.top }],
[topBand.top],
);
const content = (
<View
style={styles.container(placement, topOffset)}
pointerEvents="box-none"
collapsable={false}
>
<View style={styles.widthBoundary} pointerEvents="box-none" collapsable={false}>
<View
ref={topBand.ref}
onLayout={topBand.onLayout}
style={widthBoundaryStyle}
pointerEvents="box-none"
collapsable={false}
>
{toast ? (
<Animated.View
entering={toastEntering}
@@ -13,6 +13,7 @@ import {
type ControlSizing,
} from "@/components/ui/control-geometry";
import { StyleSheet } from "react-native-unistyles";
import { useTopBandRow } from "@/window-layout";
import { useComposerFullscreenExit } from "./fullscreen-exit";
import { resolveGlyphRailOffset } from "./state";
@@ -83,6 +84,12 @@ export function ComposerInputPresentation({
// The toolbar row bleeds 6px past the input padding; the inline glyph sits on that edge.
const enterStyle = useMemo(() => [styles.enterButton, { right: -6 + railOffset }], [railOffset]);
const exitStyle = useMemo(() => [styles.iconButton, { marginRight: railOffset }], [railOffset]);
const exitSize = TOOLBAR_CONTROL_SIZES[controlSizing];
const topBand = useTopBandRow({ offset: 0, height: exitSize, minimumWidth: exitSize });
const exitRowStyle = useMemo(
() => [styles.exitRow, { paddingTop: topBand.top, paddingRight: topBand.trailing }],
[topBand.top, topBand.trailing],
);
return (
<View
ref={exitScopeRef}
@@ -90,7 +97,7 @@ export function ComposerInputPresentation({
testID={fullscreen ? "composer-fullscreen" : undefined}
>
{fullscreen ? (
<View style={styles.exitRow}>
<View ref={topBand.ref} onLayout={topBand.onLayout} style={exitRowStyle}>
<Button
variant="ghost"
size="sm"
@@ -16,6 +16,11 @@ import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-
import type { SplitPane } from "@/stores/workspace-layout-store";
import type { WorkspaceTab } from "@/workspace-tabs/model";
import { WindowChromeRegion, WindowChromeSafeArea } from "@/utils/desktop-window";
import { TOUCH_TARGET } from "@/components/ui/control-sizing";
import { useTopBandRow } from "@/window-layout";
// One tab, the new-tab button and the header action stay usable beside an occlusion.
const TOP_BAND_MIN_RAIL_WIDTH = TOUCH_TARGET * 3;
interface ExplorerSidebarDockProps extends Pick<
WorkspaceDesktopTabsRowProps,
@@ -117,48 +122,60 @@ export function ExplorerSidebarDock({
[onCloseOtherTabs, tabs],
);
const topBand = useTopBandRow({
offset: 0,
height: TOUCH_TARGET,
minimumWidth: TOP_BAND_MIN_RAIL_WIDTH,
});
const railBandStyle = useMemo(
() => ({ paddingTop: topBand.top, paddingRight: topBand.trailing }),
[topBand.top, topBand.trailing],
);
return (
<RetainedPanel active>
<WindowChromeRegion corners="top-right">
<View style={styles.dock} testID="workspace-explorer-sidebar">
<WindowChromeSafeArea placement="inline" style={styles.tabRail}>
<TitlebarDragRegion />
<View style={styles.tabRow}>
<WorkspaceDesktopTabsRow
host="explorer"
launchPurpose="supporting"
isFocused={isWorkspaceFocused}
ownsKeyboardShortcuts={false}
setHoveredCloseTabKey={setHoveredCloseTabKey}
onCopyResumeCommand={onCopyResumeCommand}
onCopyAgentId={onCopyAgentId}
onCopyTerminalId={onCopyTerminalId}
onCopyFilePath={onCopyFilePath}
onReloadAgent={onReloadAgent}
onRenameTab={onRenameTab}
onCloseTabsToLeft={handleCloseTabsToLeft}
onCloseTabsToRight={handleCloseTabsToRight}
onCloseOtherTabs={handleCloseOtherTabs}
focusModeEnabled={false}
onExitFocusMode={onExitFocusMode}
externalDndContext={externalDndContext}
paneId={pane.id}
tabs={tabItems}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
activeDragTabId={activeDragTabId}
tabDropPreviewIndex={
tabDropPreview?.paneId === pane.id ? tabDropPreview.indicatorIndex : null
}
onNavigateTab={handleSelectTab}
onCloseTab={onCloseTab}
onCreateNewTab={onCreateNewTab}
onReorderTabs={handleReorderTabs}
/>
</View>
{headerAction ? <View style={styles.headerAction}>{headerAction}</View> : null}
<View pointerEvents="none" style={styles.tabRailDivider} />
</WindowChromeSafeArea>
<View ref={topBand.ref} onLayout={topBand.onLayout} style={railBandStyle}>
<WindowChromeSafeArea placement="inline" style={styles.tabRail}>
<TitlebarDragRegion />
<View style={styles.tabRow}>
<WorkspaceDesktopTabsRow
host="explorer"
launchPurpose="supporting"
isFocused={isWorkspaceFocused}
ownsKeyboardShortcuts={false}
setHoveredCloseTabKey={setHoveredCloseTabKey}
onCopyResumeCommand={onCopyResumeCommand}
onCopyAgentId={onCopyAgentId}
onCopyTerminalId={onCopyTerminalId}
onCopyFilePath={onCopyFilePath}
onReloadAgent={onReloadAgent}
onRenameTab={onRenameTab}
onCloseTabsToLeft={handleCloseTabsToLeft}
onCloseTabsToRight={handleCloseTabsToRight}
onCloseOtherTabs={handleCloseOtherTabs}
focusModeEnabled={false}
onExitFocusMode={onExitFocusMode}
externalDndContext={externalDndContext}
paneId={pane.id}
tabs={tabItems}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
activeDragTabId={activeDragTabId}
tabDropPreviewIndex={
tabDropPreview?.paneId === pane.id ? tabDropPreview.indicatorIndex : null
}
onNavigateTab={handleSelectTab}
onCloseTab={onCloseTab}
onCreateNewTab={onCreateNewTab}
onReorderTabs={handleReorderTabs}
/>
</View>
{headerAction ? <View style={styles.headerAction}>{headerAction}</View> : null}
<View pointerEvents="none" style={styles.tabRailDivider} />
</WindowChromeSafeArea>
</View>
<View style={styles.content}>
<WorkspacePanelHost
paneId={pane.id}
+4 -1
View File
@@ -1,9 +1,12 @@
import { createContext, useContext } from "react";
import { useWindowDimensions } from "react-native";
import type { WindowLayout } from "./model";
import type { TopBand, WindowLayout } from "./model";
export const WindowLayoutContext = createContext<WindowLayout | null>(null);
/** Published by `WindowLayoutProvider` beside the safe-area insets it derives from it. */
export const TopBandContext = createContext<TopBand | null>(null);
export function useWindowLayout(): WindowLayout | null {
const layout = useContext(WindowLayoutContext);
const { width, height } = useWindowDimensions();
+16 -4
View File
@@ -2,12 +2,13 @@ import { useCallback, useMemo, useState, type ReactNode } from "react";
import { StyleSheet, View, type NativeSyntheticEvent, type ViewProps } from "react-native";
import { requireNativeViewManager } from "expo-modules-core";
import { SafeAreaInsetsContext, useSafeAreaInsets } from "react-native-safe-area-context";
import { WindowLayoutContext, useWindowLayout } from "./context";
import { resolveSafeAreaInsets, type WindowLayout } from "./model";
import { TopBandContext, WindowLayoutContext, useWindowLayout } from "./context";
import { resolveSafeAreaInsets, resolveTopBand, type WindowLayout } from "./model";
export { useWindowLayout } from "./context";
export { WindowLayoutActions, WindowLayoutActionsHost } from "./actions";
export { WindowLayoutSurface, WindowLayoutDivisionGap } from "./surface";
export { useTopBandRow, type TopBandRow } from "./top-band";
export {
resolveDesktopDivisionLayout,
resolveWorkspaceActionPlacement,
@@ -41,12 +42,23 @@ export function WindowLayoutProvider({ children }: { children: ReactNode }) {
);
}
/** The one bottom inset every descendant reads through `useSafeAreaInsets()`. */
/**
* The one top and bottom inset every descendant reads through `useSafeAreaInsets()`, and
* the top band whose occlusions top-edge rows clear with `useTopBandRow()`.
*/
function WindowSafeAreaInsets({ children }: { children: ReactNode }) {
const systemInsets = useSafeAreaInsets();
const layout = useWindowLayout();
const insets = useMemo(() => resolveSafeAreaInsets(systemInsets, layout), [systemInsets, layout]);
return <SafeAreaInsetsContext.Provider value={insets}>{children}</SafeAreaInsetsContext.Provider>;
const topBand = useMemo(
() => resolveTopBand(systemInsets.top, layout),
[systemInsets.top, layout],
);
return (
<TopBandContext.Provider value={topBand}>
<SafeAreaInsetsContext.Provider value={insets}>{children}</SafeAreaInsetsContext.Provider>
</TopBandContext.Provider>
);
}
const styles = StyleSheet.create({ root: { flex: 1 } });
+1
View File
@@ -3,6 +3,7 @@ import type { ReactNode } from "react";
export { useWindowLayout } from "./context";
export { WindowLayoutActions, WindowLayoutActionsHost } from "./actions";
export { WindowLayoutSurface, WindowLayoutDivisionGap } from "./surface";
export { useTopBandRow, type TopBandRow } from "./top-band";
export {
resolveDesktopDivisionLayout,
resolveWorkspaceActionPlacement,
+175 -6
View File
@@ -1,35 +1,204 @@
import { describe, expect, it } from "vitest";
import { resolveSafeAreaInsets, type WindowLayout } from "./model";
import {
resolveSafeAreaInsets,
resolveTopBand,
resolveTopBandClearance,
type WindowLayout,
type WindowLayoutRect,
} from "./model";
const systemInsets = { top: 59, right: 0, bottom: 34, left: 0 };
function layout(bottomClearance: number | null | undefined): WindowLayout {
function layout(input: Partial<WindowLayout> = {}): WindowLayout {
return {
bounds: { x: 0, y: 0, width: 402, height: 874 },
verticalBarEdge: "none",
verticalBar: null,
bottomClearance,
divisions: [],
...input,
};
}
describe("resolveSafeAreaInsets", () => {
it("replaces only the bottom with the measured bar clearance", () => {
expect(resolveSafeAreaInsets(systemInsets, layout(24))).toEqual({
expect(resolveSafeAreaInsets(systemInsets, layout({ bottomClearance: 24 }))).toEqual({
...systemInsets,
bottom: 24,
});
});
it("keeps a measured zero instead of falling back to the system inset", () => {
expect(resolveSafeAreaInsets(systemInsets, layout(0)).bottom).toBe(0);
expect(resolveSafeAreaInsets(systemInsets, layout({ bottomClearance: 0 })).bottom).toBe(0);
});
it.each([null, undefined])("leaves system insets alone without a clearance (%s)", (value) => {
expect(resolveSafeAreaInsets(systemInsets, layout(value))).toBe(systemInsets);
expect(resolveSafeAreaInsets(systemInsets, layout({ bottomClearance: value }))).toBe(
systemInsets,
);
});
it("leaves system insets alone before the native layout arrives", () => {
expect(resolveSafeAreaInsets(systemInsets, null)).toBe(systemInsets);
});
it("starts the top where the top bar rests when it sits above the system top", () => {
const insets = resolveSafeAreaInsets(
systemInsets,
layout({ topBar: { x: 0, y: 12, width: 402, height: 44 } }),
);
expect(insets).toEqual({ ...systemInsets, top: 12 });
});
it("keeps the system top when the top bar rests at or below it", () => {
const insets = resolveSafeAreaInsets(
systemInsets,
layout({ topBar: { x: 0, y: 59, width: 402, height: 44 } }),
);
expect(insets).toBe(systemInsets);
});
});
// Estimated, not measured: Duo inner portrait (669×951) with a ≈80pt rectangular top and a
// top-right status region 144pt wide (202pt with a Live Activity, from #7924's ActivityKit QA).
// The top bar's start (12pt) is a placeholder: the simulator boots folded and Xcode 27 ships
// no control to unfold it, so the inner screen's guide frame has not been measured.
const duo = {
systemTop: 80,
topBar: { x: 0, y: 12, width: 669, height: 44 },
statusRegion: { x: 525, y: 0, width: 144, height: 80 },
liveActivityRegion: { x: 467, y: 0, width: 202, height: 80 },
};
function duoLayout(occlusions: WindowLayoutRect[]): WindowLayout {
return layout({
bounds: { x: 0, y: 0, width: 669, height: 951 },
topBar: duo.topBar,
occlusions,
});
}
describe("resolveTopBand", () => {
it("publishes the top bar's start and the occlusions when the band is in use", () => {
expect(resolveTopBand(duo.systemTop, duoLayout([duo.statusRegion]))).toEqual({
top: 12,
occlusions: [duo.statusRegion],
});
});
it("ignores occlusions without a top bar guide (before iOS 27.1 or unresolved)", () => {
const withoutGuide = layout({ topBar: null, occlusions: [duo.statusRegion] });
expect(resolveTopBand(duo.systemTop, withoutGuide)).toBeNull();
expect(resolveTopBand(duo.systemTop, layout({ occlusions: [duo.statusRegion] }))).toBeNull();
});
it("ignores occlusions when the top bar guide sits below the status region", () => {
const below = layout({
topBar: { x: 0, y: 80, width: 669, height: 44 },
occlusions: [duo.statusRegion],
});
expect(resolveTopBand(duo.systemTop, below)).toBeNull();
});
// Measured: iPhone 17 Pro, iOS 27.2 simulator, portrait.
it("is null on a regular iPhone, whose top bar starts at the status bar's bottom", () => {
const iphone = layout({
topBar: { x: 18, y: 62, width: 366, height: 44 },
occlusions: [{ x: 0, y: 0, width: 402, height: 54 }],
});
expect(resolveTopBand(62, iphone)).toBeNull();
});
// Measured: iPhone Duo outer screen, iOS 27.1 simulator. The status region is a trailing
// column, which the vertical bar layout already owns.
it("is null on the Duo outer screen, whose top bar starts below a zero system top", () => {
const outer = layout({
bounds: { x: 0, y: 0, width: 466, height: 678 },
verticalBarEdge: "trailing",
topBar: { x: 7 / 3, y: 26, width: 1139 / 3, height: 44 },
occlusions: [
{ x: 1199 / 3, y: 88 / 3, width: 37, height: 37 },
{ x: 382, y: 0, width: 84, height: 174 },
],
});
expect(resolveTopBand(0, outer)).toBeNull();
});
it("is null before the native layout arrives", () => {
expect(resolveTopBand(duo.systemTop, null)).toBeNull();
});
});
describe("resolveTopBandClearance", () => {
// The full workspace header with the sidebar closed spans the window.
const fullHeader = { x: 0, y: duo.topBar.y, width: 669, height: 44 };
// The compact header beside a 320pt sidebar: 8pt top padding, 56pt row.
const compactHeader = { x: 320, y: duo.topBar.y + 8, width: 349, height: 56 };
const compactMinimum = 12 + 44 + 8 + 96 + 8 + 132;
it("trims the trailing side to the status region", () => {
expect(
resolveTopBandClearance({
row: fullHeader,
occlusions: [duo.statusRegion],
minimumWidth: 300,
}),
).toEqual({ trailing: 144, top: 0 });
});
it("trims further when a Live Activity widens the status region", () => {
expect(
resolveTopBandClearance({
row: fullHeader,
occlusions: [duo.liveActivityRegion],
minimumWidth: 300,
}),
).toEqual({ trailing: 202, top: 0 });
});
it("starts below the status region when the controls no longer fit beside it", () => {
expect(
resolveTopBandClearance({
row: compactHeader,
occlusions: [duo.statusRegion],
minimumWidth: compactMinimum,
}),
).toEqual({ trailing: 0, top: 60 });
});
it("starts below the lowest of several overlapping occlusions", () => {
expect(
resolveTopBandClearance({
row: compactHeader,
occlusions: [duo.statusRegion, { x: 600, y: 0, width: 69, height: 96 }],
minimumWidth: compactMinimum,
}),
).toEqual({ trailing: 0, top: 76 });
});
it("leaves rows alone that do not overlap an occlusion", () => {
const sidebarRow = { x: 0, y: duo.topBar.y + 8, width: 320, height: 44 };
const rowBelow = { x: 0, y: 80, width: 669, height: 44 };
for (const row of [sidebarRow, rowBelow]) {
expect(
resolveTopBandClearance({ row, occlusions: [duo.statusRegion], minimumWidth: 0 }),
).toEqual({ trailing: 0, top: 0 });
}
});
it("rounds a fractional clearance up so controls never touch the occlusion", () => {
expect(
resolveTopBandClearance({
row: { x: 0, y: 10, width: 669.5, height: 44 },
occlusions: [{ x: 525.25, y: 0, width: 144, height: 60.5 }],
minimumWidth: Number.POSITIVE_INFINITY,
}),
).toEqual({ trailing: 0, top: 51 });
expect(
resolveTopBandClearance({
row: { x: 0, y: 10, width: 669.5, height: 44 },
occlusions: [{ x: 525.25, y: 0, width: 144, height: 60.5 }],
minimumWidth: 0,
}).trailing,
).toBe(145);
});
});
+69 -5
View File
@@ -14,18 +14,82 @@ export interface WindowLayout {
* the system has no bottom bar guide. Zero is a measured value.
*/
bottomClearance?: number | null;
/** Where a top bar rests, or null until the system resolves its top bar guide. */
topBar?: WindowLayoutRect | null;
/** Active system occlusions (status region, camera, Live Activities), margins included. */
occlusions?: WindowLayoutRect[];
divisions: WindowLayoutRect[];
}
/** The window's measured bottom clearance replaces the rectangular safe-area bottom. */
export function resolveSafeAreaInsets<Insets extends { bottom: number }>(
/**
* The band above the rectangular safe area that a top bar may use because the system
* occupies only part of it. Content outside a top-edge row still keeps out of `occlusions`.
*/
export interface TopBand {
top: number;
occlusions: readonly WindowLayoutRect[];
}
/** Null unless the top bar guide starts above the system's rectangular safe area. */
export function resolveTopBand(systemTop: number, layout: WindowLayout | null): TopBand | null {
const bar = layout?.topBar;
if (!layout || !bar || bar.y >= systemTop) return null;
return { top: Math.max(0, bar.y), occlusions: layout.occlusions ?? [] };
}
/**
* The window's measured bars replace the rectangular safe area: the bottom bar's clearance,
* and the top bar's start when it sits above the system top.
*/
export function resolveSafeAreaInsets<Insets extends { top: number; bottom: number }>(
systemInsets: Insets,
layout: WindowLayout | null,
): Insets {
const clearance = layout?.bottomClearance;
return typeof clearance === "number" && clearance !== systemInsets.bottom
? { ...systemInsets, bottom: clearance }
: systemInsets;
const bottom = typeof clearance === "number" ? clearance : systemInsets.bottom;
const top = resolveTopBand(systemInsets.top, layout)?.top ?? systemInsets.top;
return top === systemInsets.top && bottom === systemInsets.bottom
? systemInsets
: { ...systemInsets, top, bottom };
}
export interface TopBandClearance {
/** Points the row's trailing controls keep from its trailing edge. */
trailing: number;
/** Points the row moves down when its controls cannot fit beside the occlusions. */
top: number;
}
export const NO_TOP_BAND_CLEARANCE: TopBandClearance = { trailing: 0, top: 0 };
/**
* How a top-edge row keeps its controls out of the system occlusions it overlaps: trim its
* trailing side to the leftmost occlusion, or, when `minimumWidth` no longer fits, start
* below the occlusions. `row` is the row's frame without any clearance, in window points.
*/
export function resolveTopBandClearance(input: {
row: WindowLayoutRect;
occlusions: readonly WindowLayoutRect[];
minimumWidth: number;
}): TopBandClearance {
const { row, occlusions, minimumWidth } = input;
const overlapping = occlusions.filter((frame) => overlaps(frame, row));
if (overlapping.length === 0) return NO_TOP_BAND_CLEARANCE;
const trailing = Math.ceil(row.x + row.width - Math.min(...overlapping.map((frame) => frame.x)));
if (row.width - trailing >= minimumWidth) return { trailing, top: 0 };
const bottom = Math.max(...overlapping.map((frame) => frame.y + frame.height));
return { trailing: 0, top: Math.ceil(bottom - row.y) };
}
function overlaps(a: WindowLayoutRect, b: WindowLayoutRect): boolean {
return (
a.width > 0 &&
a.height > 0 &&
a.x < b.x + b.width &&
a.x + a.width > b.x &&
a.y < b.y + b.height &&
a.y + a.height > b.y
);
}
export interface DesktopDivisionLayout {
@@ -0,0 +1,78 @@
import {
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type RefObject,
} from "react";
import type { View } from "react-native";
import { TopBandContext } from "./context";
import {
NO_TOP_BAND_CLEARANCE,
resolveTopBandClearance,
type TopBandClearance,
type WindowLayoutRect,
} from "./model";
export interface TopBandRow extends TopBandClearance {
/** Attach to the view whose top edge `offset` is measured from. */
ref: RefObject<View | null>;
onLayout: (() => void) | undefined;
}
/**
* Clearance for a row that can land in the window's top band. The measured view's frame
* must not depend on the clearance: apply `trailing` and `top` as padding inside it.
*
* - `offset`: points from the measured view's top to the row, without `top`.
* - `height`: the row's height.
* - `minimumWidth`: the narrowest row that still fits its controls.
* - `settled`: re-measures when it changes, for rows whose panel slides into place.
*/
export function useTopBandRow(input: {
offset: number;
height: number;
minimumWidth: number;
settled?: boolean;
}): TopBandRow {
const { offset, height, minimumWidth, settled } = input;
const band = useContext(TopBandContext);
const ref = useRef<View | null>(null);
const [frame, setFrame] = useState<WindowLayoutRect | null>(null);
const measure = useCallback(() => {
ref.current?.measureInWindow((x, y, width, measuredHeight) =>
setFrame((previous) =>
previous &&
previous.x === x &&
previous.y === y &&
previous.width === width &&
previous.height === measuredHeight
? previous
: { x, y, width, height: measuredHeight },
),
);
}, []);
const active = band !== null;
useEffect(() => {
if (active) measure();
}, [active, measure, settled]);
const clearance = useMemo(
() =>
band && frame
? resolveTopBandClearance({
row: { x: frame.x, y: frame.y + offset, width: frame.width, height },
occlusions: band.occlusions,
minimumWidth,
})
: NO_TOP_BAND_CLEARANCE,
[band, frame, offset, height, minimumWidth],
);
return {
ref,
onLayout: active ? measure : undefined,
trailing: clearance.trailing,
top: clearance.top,
};
}