1import type {
2 NavigationState,
3 ParamListBase,
4 StackNavigationState,
5} from "@react-navigation/native";
6
7type Route = StackNavigationState<ParamListBase>["routes"][number];
8
9/** Keep the presenting history mounted while modal routes cover the whole workspace. */
10export function projectStackPresentation(
11 state: StackNavigationState<ParamListBase>,
12 isOverlay: (route: Route) => boolean,
13) {
14 const firstOverlay = state.routes.findIndex(isOverlay);
15 return {
16 workspace: state.routes.slice(0, firstOverlay < 0 ? undefined : firstOverlay),
17 overlays: firstOverlay < 0 ? [] : state.routes.slice(firstOverlay),
18 };
19}
20
21/** Project one router history into native columns and a modal presentation stack. */
22export function projectWorkspaceStack(
23 state: StackNavigationState<ParamListBase>,
24 isOverlay: (route: Route) => boolean,
25) {
26 const { workspace: workspaceRoutes, overlays } = projectStackPresentation(state, isOverlay);
27 const primary = workspaceRoutes.find((route) => route.name === "Home");
28 // The Settings tab's route only selects what Home shows; its pages are the detail.
29 const detail = workspaceRoutes.filter(
30 (route) => route.name !== "Home" && route.name !== "Settings",
31 );
32 return { primary, detail, overlays };
33}
34
35/** Native callbacks can arrive after a JS pop or replace; those must not pop the next screen. */
36export function nativeWorkspacePopCount(
37 state: Pick<NavigationState, "index" | "routes">,
38 dismissedKey: string,
39): number {
40 const index = state.routes.findIndex((route) => route.key === dismissedKey);
41 return index <= 0 || index > state.index ? 0 : state.index - index + 1;
42}
43
44/** POP counts backwards from its source, so dismiss a group from the active descendant. */
45export function nativeWorkspacePopAction(
46 state: Pick<NavigationState, "key" | "index" | "routes">,
47 dismissedKey: string,
48) {
49 const count = nativeWorkspacePopCount(state, dismissedKey);
50 return count
51 ? {
52 type: "POP" as const,
53 payload: { count },
54 source: state.routes[state.index]!.key,
55 target: state.key,
56 }
57 : null;
58}
59
60/** A native card pop must preserve pushes made before its completion event reaches JS. */
61export function nativeStackPopAction(
62 state: Pick<NavigationState, "key" | "index" | "routes">,
63 dismissedKey: string,
64 destinationKey?: string,
65) {
66 const sourceIndex = state.routes.findIndex((route) => route.key === dismissedKey);
67 let destinationIndex =
68 destinationKey === undefined
69 ? sourceIndex - 1
70 : state.routes.findIndex((route) => route.key === destinationKey);
71 // Home's screen draws the Settings list, so going back to it from a Settings page stops there.
72 if (destinationIndex >= 0 && state.routes[destinationIndex + 1]?.name === "Settings")
73 destinationIndex += 1;
74 if (
75 sourceIndex <= 0 ||
76 sourceIndex > state.index ||
77 destinationIndex < 0 ||
78 destinationIndex >= sourceIndex
79 ) {
80 return null;
81 }
82 return {
83 type: "POP" as const,
84 payload: { count: sourceIndex - destinationIndex },
85 source: dismissedKey,
86 target: state.key,
87 };
88}
89
90/** The column UIKit shows while a split is collapsed, and the active detail it began removing. */
91export interface CollapsedSplitColumn {
92 readonly visible: "primary" | "secondary";
93 readonly leaving: string | null;
94}
95
96/**
97 * A collapsed split nests the detail column's navigation controller in the list column's. Back
98 * from the detail root pops that controller whole, so no screen leaves its own stack or reports a
99 * dismissal: the list appearing after the active detail began leaving is the only sign of it.
100 */
101export function collapsedSplitTransition(
102 column: CollapsedSplitColumn,
103 event: { readonly type: "willAppear" | "didAppear" | "willDisappear"; readonly key: string },
104 state: Pick<NavigationState, "key" | "index" | "routes">,
105 primaryKey: string,
106): {
107 readonly column: CollapsedSplitColumn;
108 readonly pop: ReturnType<typeof nativeStackPopAction>;
109} {
110 switch (event.type) {
111 case "willDisappear":
112 // JS pushes, pops, and presentations have already moved the router past this screen.
113 return {
114 column:
115 state.index > 0 && state.routes[state.index]?.key === event.key
116 ? { ...column, leaving: event.key }
117 : column,
118 pop: null,
119 };
120 case "willAppear":
121 // A cancelled swipe, or an expansion that keeps the detail on screen.
122 return {
123 column: column.leaving === event.key ? { ...column, leaving: null } : column,
124 pop: null,
125 };
126 case "didAppear":
127 return {
128 column: { visible: event.key === primaryKey ? "primary" : "secondary", leaving: null },
129 pop:
130 event.key === primaryKey && column.leaving
131 ? nativeStackPopAction(state, column.leaving, primaryKey)
132 : null,
133 };
134 }
135}
136
137export function partitionStackPresentations<T>(
138 routes: readonly T[],
139 isModal: (route: T) => boolean,
140): T[][] {
141 const groups: T[][] = [];
142 for (const route of routes) {
143 if (groups.length === 0 || isModal(route)) groups.push([]);
144 groups.at(-1)!.push(route);
145 }
146 return groups;
147}
148
149/** Keep outgoing screens in place until dismissal; Fabric moves enqueue native pop/push operations. */
150export function reconcileStackScreens<T extends { readonly key: string }>(
151 previous: readonly T[],
152 current: readonly T[],
153 completedNativeDismissals: ReadonlySet<string> = new Set(),
154): T[] {
155 const active = new Set(current.map((route) => route.key));
156 const screens = [...current];
157 previous.forEach((route, index) => {
158 if (!active.has(route.key) && !completedNativeDismissals.has(route.key)) {
159 screens.splice(Math.min(index, screens.length), 0, route);
160 }
161 });
162 return screens;
163}
164
165/** Whether a route is presented over the stack (a sheet or modal) rather than pushed. */
166export function isPresentedRoute(options: { readonly presentation?: string } | undefined) {
167 const presentation = options?.presentation;
168 return presentation !== undefined && presentation !== "card";
169}