Skip to content
Source
Sign inSign up
Open History to see the last commit

route-options.ts

158 lines6.3 KB

1import { Platform } from "react-native";
2​
3import { GLASS_HEADER_OPTIONS, type AppScreenOptions } from "../../native/glass-header-options";
4import { formSheetDetents } from "../../native/sheet-surface";
5​
6/**
7 * Route presets for the root stack. A route says what it is (a page, a sheet, a modal); the
8 * preset owns how each platform presents it, so screens never set presentation options.
9 *
10 * Android never shows the native stack header: every screen draws its bar in-tree with AppBar or
11 * SheetHeader, and the Android navigators hide the native one from the first frame.
12 */
13​
14type HeaderStyle = "glass" | "solid" | "none";
15​
16const SOLID_HEADER_OPTIONS: AppScreenOptions = {
17 headerBackButtonDisplayMode: "minimal",
18 headerBackTitle: "",
19 headerLargeTitle: false,
20 headerShadowVisible: false,
21 headerShown: true,
22 headerTitleStyle: { fontSize: 18, fontWeight: "800" },
23 headerTransparent: false,
24};
25​
26function headerOptions(header: HeaderStyle, itemStyle: "editor" | "standard"): AppScreenOptions {
27 if (header === "none") return { headerShown: false };
28 if (header === "glass") {
29 // The editor style needs liquid glass, which GLASS_HEADER_OPTIONS already gates it on.
30 return itemStyle === "editor"
31 ? GLASS_HEADER_OPTIONS
32 : { ...GLASS_HEADER_OPTIONS, unstable_navigationItemStyle: undefined };
33 }
34 return {
35 ...SOLID_HEADER_OPTIONS,
36 unstable_navigationItemStyle:
37 itemStyle === "editor" && Platform.OS === "ios" ? "editor" : undefined,
38 };
40​
41export interface PageRouteOptions {
42 readonly title?: string;
43 /** Glass bars let content scroll under them. Defaults to glass. */
44 readonly header?: HeaderStyle;
45 /** iOS 26 groups a page's bar items like an editor; settings pages use the standard look. */
46 readonly itemStyle?: "editor" | "standard";
47 /**
48 * How the Duo minimizes the bar while scrolling. "never" for lists that stutter against the
49 * default on hardware; "onScrollUp" for conversations read backwards, like Messages.
50 */
51 readonly duoMinimize?: "never" | "onScrollUp";
52 /** The page is the root of its column, so it never shows Back. */
53 readonly root?: boolean;
55​
56/** A page in the workspace: pushed on compact windows, a column page on wide ones. */
57export function pageRoute(options: PageRouteOptions = {}): AppScreenOptions {
58 return {
59 ...headerOptions(options.header ?? "glass", options.itemStyle ?? "editor"),
60 ...(options.title === undefined ? undefined : { title: options.title }),
61 ...(options.duoMinimize
62 ? { unstable_headerMinimizationBehavior: options.duoMinimize }
63 : undefined),
64 ...(options.root ? { headerBackVisible: false } : undefined),
65 };
67​
68export interface SheetRouteOptions {
69 readonly title?: string;
70 /** Defaults to glass. "none" when the sheet draws no bar at all. */
71 readonly header?: HeaderStyle;
72 /** The initial detent, or "full" for a sheet that always covers the window. */
73 readonly detent: number | "full";
74 /** The largest detent. Defaults to the full window on iOS, where the Duo needs it. */
75 readonly maxDetent?: number;
76 /** Android's expanded detent when `maxDetent` is unset. */
77 readonly androidMaxDetent?: number;
78 /** Android sizes the sheet to its content instead of using detents. */
79 readonly androidFit?: boolean;
80 /**
81 * Where an Android formSheet can't host the screen: "card" pushes it, "fullScreen" presents it
82 * full screen (Android formSheets can't host a keyboard-driven composer).
83 */
84 readonly android?: "sheet" | "card" | "fullScreen";
85 /** The sheet spans the Duo's rail, and its content insets itself. */
86 readonly spansRail?: boolean;
87 readonly itemStyle?: "editor" | "standard";
88 readonly duoMinimize?: "never" | "onScrollUp";
90​
91function sheetDetents(options: SheetRouteOptions): number[] {
92 if (options.detent === "full") return [1];
93 if (options.maxDetent !== undefined) return [options.detent, options.maxDetent];
94 return formSheetDetents(options.detent, options.androidMaxDetent);
96​
97/** A sheet over the workspace: a formSheet with a grabber that closes, never goes back. */
98export function sheetRoute(options: SheetRouteOptions): AppScreenOptions {
99 const header = headerOptions(options.header ?? "glass", options.itemStyle ?? "standard");
100 const shared = {
101 ...header,
102 ...(options.title === undefined ? undefined : { title: options.title }),
103 ...(options.duoMinimize
104 ? { unstable_headerMinimizationBehavior: options.duoMinimize }
105 : undefined),
106 headerBackVisible: false,
107 gestureEnabled: true,
108 } satisfies AppScreenOptions;
109 if (Platform.OS === "android" && options.android === "card") {
110 return { ...shared, presentation: "card" };
111 }
112 if (Platform.OS === "android" && options.android === "fullScreen") {
113 return { ...shared, presentation: "fullScreenModal" };
114 }
115 return {
116 ...shared,
117 presentation: "formSheet",
118 sheetPrefersHorizontalBars: true,
119 sheetGrabberVisible: true,
120 sheetAllowedDetents:
121 Platform.OS === "android" && options.androidFit ? "fitToContents" : sheetDetents(options),
122 ...(Platform.OS === "android" ? { sheetCornerRadius: 28 } : undefined),
123 ...(options.spansRail ? { nativeContentInsetHorizontally: false } : undefined),
124 } as AppScreenOptions;
126​
127export interface ModalRouteOptions {
128 readonly title?: string;
129 /** Defaults to "none": the screen draws its own SheetHeader or has no bar. */
130 readonly header?: HeaderStyle;
131 readonly itemStyle?: "editor" | "standard";
132 readonly duoMinimize?: "never" | "onScrollUp";
134​
135/** A full-screen modal over the workspace with a close button, not a back button. */
136export function modalRoute(options: ModalRouteOptions = {}): AppScreenOptions {
137 return {
138 ...headerOptions(options.header ?? "none", options.itemStyle ?? "standard"),
139 ...(options.title === undefined ? undefined : { title: options.title }),
140 ...(options.duoMinimize
141 ? { unstable_headerMinimizationBehavior: options.duoMinimize }
142 : undefined),
143 headerBackVisible: false,
144 presentation: "fullScreenModal",
145 };
147​
148/** Full-screen media (a device stream, a browser, an app) with no bar and no dismiss gesture. */
149export function immersiveRoute(options: { readonly hideSystemBars?: boolean } = {}) {
150 return {
151 presentation: "fullScreenModal",
152 headerShown: false,
153 gestureEnabled: false,
154 ...(options.hideSystemBars
155 ? { autoHideHomeIndicator: true, navigationBarHidden: true }
156 : undefined),
157 } satisfies AppScreenOptions;