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

createV5StackNavigator.ios.tsx

366 lines13.0 KB

1import {
2 createNavigatorFactory,
3 NavigationContext,
4 NavigationRouteContext,
5 StackRouter,
6 useNavigationBuilder,
7 usePreventRemoveContext,
8 type NavigatorTypeBagBase,
9 type ParamListBase,
10 type StackActionHelpers,
11 type StackNavigationState,
12 type StackRouterOptions,
13 type StaticConfig,
14 type TypedNavigator,
15} from "@react-navigation/native";
16import {
17 NativeStackView,
18 type NativeStackNavigationEventMap,
19 type NativeStackNavigationOptions,
20 type NativeStackNavigatorProps,
21 type NativeStackTypeBag,
22} from "@react-navigation/native-stack";
23import { use, useCallback, useRef, useState, type ComponentProps } from "react";
24import { WorkspaceSlotOptionsContext, slotScreenOptions } from "./v5-workspace-context";
25import { StyleSheet, View } from "react-native";
26import { Stack } from "react-native-screens";
27import { NativeScrollEdgeEffectsContext } from "./NativeScrollViewMarker";
28import { V5StackHeader } from "./V5StackHeader.ios";
29import type { AppNativeStackNavigationOptions } from "./StackHeader";
30import { NativeColumnContent } from "./NativeColumnContent.ios";
31import {
32 isPresentedRoute,
33 nativeStackPopAction,
34 partitionStackPresentations,
35 reconcileStackScreens,
36} from "./workspace-stack-projection";
37​
38export type V5StackViewProps = ComponentProps<typeof NativeStackView>;
39​
40export function modalEnvelopeOptions(options: NativeStackNavigationOptions) {
41 return {
42 ...Object.fromEntries(
43 Object.entries(options).filter(
44 ([key]) =>
45 !key.startsWith("header") &&
46 !key.startsWith("unstable_header") &&
47 key !== "unstable_navigationItemStyle",
48 ),
49 ),
50 headerShown: false,
51 };
53​
54/** Keep outgoing screens until UIKit completes its pop, as required by v5. */
55export function V5CardStackView(
56 props: V5StackViewProps & {
57 readonly onRootVisibilityChange?: (visible: boolean) => void;
58 /** The card native Back is popping, which the router still shows until the pop finishes. */
59 readonly onNativePopChange?: (key: string | null) => void;
60 },
61) {
62 const { preventedRoutes } = usePreventRemoveContext();
63 // Options a screen set inside this workspace tab slot, over its route's.
64 const slot = use(WorkspaceSlotOptionsContext);
65 const nativePopSource = useRef<string | null>(null);
66 const { onNativePopChange } = props;
67 const setNativePopSource = useCallback(
68 (key: string | null) => {
69 nativePopSource.current = key;
70 onNativePopChange?.(key);
71 },
72 [onNativePopChange],
73 );
74 const [screens, setScreens] = useState({
75 completedNativeDismissals: new Set<string>(),
76 nativeCompletingPops: new Set<string>(),
77 routes: props.state.routes,
78 descriptors: props.descriptors,
79 observedRoutes: props.state.routes,
80 observedDescriptors: props.descriptors,
81 });
82 if (
83 screens.observedRoutes !== props.state.routes ||
84 screens.observedDescriptors !== props.descriptors
85 ) {
86 const routes = reconcileStackScreens(
87 screens.routes,
88 props.state.routes,
89 screens.completedNativeDismissals,
90 );
91 const active = new Set(props.state.routes.map((route) => route.key));
92 const retainedKeys = new Set(routes.map((route) => route.key));
93 setScreens({
94 nativeCompletingPops: new Set(
95 [...screens.nativeCompletingPops].filter((key) => retainedKeys.has(key)),
96 ),
97 completedNativeDismissals: new Set(
98 [...screens.completedNativeDismissals].filter((key) => active.has(key)),
99 ),
100 routes,
101 descriptors: Object.fromEntries(
102 Object.entries({ ...screens.descriptors, ...props.descriptors }).filter(([key]) =>
103 retainedKeys.has(key),
104 ),
105 ),
106 observedRoutes: props.state.routes,
107 observedDescriptors: props.descriptors,
108 });
109 }
110 // Retain the real descriptor through dismissal; placeholders reject setOptions and dispatch.
111 const nativeDismiss = useCallback(
112 (key: string) => {
113 const state = props.navigation.getState();
114 const attached = state.routes.some((route) => route.key === key);
115 setScreens((current) => {
116 const completedNativeDismissals = new Set(current.completedNativeDismissals);
117 const nativeCompletingPops = new Set(current.nativeCompletingPops);
118 nativeCompletingPops.delete(key);
119 if (attached) {
120 completedNativeDismissals.add(key);
121 return { ...current, completedNativeDismissals, nativeCompletingPops };
122 }
123 // Clean delayed callbacks immediately; the router already removed this route and will not update again.
124 completedNativeDismissals.delete(key);
125 const descriptors = { ...current.descriptors };
126 delete descriptors[key];
127 return {
128 ...current,
129 completedNativeDismissals,
130 nativeCompletingPops,
131 routes: current.routes.filter((route) => route.key !== key),
132 descriptors,
133 };
134 });
135 if (nativePopSource.current === key) setNativePopSource(null);
136 const action = nativeStackPopAction(state, key);
137 if (action) props.navigation.dispatch(action);
138 },
139 [props.navigation, setNativePopSource, setScreens],
140 );
141 const removeDismissed = (key: string) => {
142 if (!props.navigation.getState().routes.some((route) => route.key === key)) {
143 setScreens((current) => {
144 const descriptors = { ...current.descriptors };
145 delete descriptors[key];
146 return {
147 ...current,
148 routes: current.routes.filter((route) => route.key !== key),
149 descriptors,
150 };
151 });
152 }
153 };
154 return (
155 <View className="flex-1 bg-screen">
156 <Stack.Host>
157 {screens.routes.map((route, index) => {
158 const descriptor = props.descriptors[route.key] ?? screens.descriptors[route.key];
159 if (!descriptor) return null;
160 const options = slotScreenOptions(slot, route.key, descriptor.options);
161 const attached = props.state.routes.some((current) => current.key === route.key);
162 return (
163 <Stack.Screen
164 key={route.key}
165 screenKey={route.key}
166 activityMode={
167 attached || screens.nativeCompletingPops.has(route.key) ? "attached" : "detached"
168 }
169 preventNativeDismiss={
170 preventedRoutes[route.key]?.preventRemove || options.gestureEnabled === false
171 }
172 onDismiss={removeDismissed}
173 onNativeDismiss={nativeDismiss}
174 onNativeDismissPrevented={() => {
175 if (preventedRoutes[route.key]?.preventRemove) descriptor.navigation.goBack();
176 }}
177 onWillAppear={() => {
178 if (index === 0) props.onRootVisibilityChange?.(true);
179 props.navigation.emit({
180 type: "transitionStart",
181 target: route.key,
182 data: { closing: false },
183 });
184 }}
185 onDidAppear={() => {
186 const source = nativePopSource.current;
187 if (source === route.key) {
188 setNativePopSource(null);
189 } else if (source) {
190 const state = props.navigation.getState();
191 const action = nativeStackPopAction(state, source, route.key);
192 if (action) {
193 const sourceIndex = state.routes.findIndex((current) => current.key === source);
194 // Keep children until UIKit's dismissal callback; early router removal would enqueue a second pop.
195 const poppedKeys = state.routes
196 .slice(sourceIndex - action.payload.count + 1, sourceIndex + 1)
197 .map((popped) => popped.key);
198 setScreens((current) => ({
199 ...current,
200 nativeCompletingPops: new Set([
201 ...current.nativeCompletingPops,
202 ...poppedKeys,
203 ]),
204 }));
205 setNativePopSource(null);
206 props.navigation.dispatch(action);
207 }
208 }
209 props.navigation.emit({
210 type: "transitionEnd",
211 target: route.key,
212 data: { closing: false },
213 });
214 }}
215 onWillDisappear={() => {
216 if (index === 0) props.onRootVisibilityChange?.(false);
217 const state = props.navigation.getState();
218 // Only native Back leaves the disappearing card active; JS navigation already changed history.
219 if (state.index > 0 && state.routes[state.index]?.key === route.key) {
220 setNativePopSource(route.key);
221 }
222 props.navigation.emit({
223 type: "transitionStart",
224 target: route.key,
225 data: { closing: true },
226 });
227 }}
228 onDidDisappear={() =>
229 props.navigation.emit({
230 type: "transitionEnd",
231 target: route.key,
232 data: { closing: true },
233 })
234 }
235 >
236 <NavigationContext value={descriptor.navigation}>
237 <NavigationRouteContext value={descriptor.route}>
238 <V5StackHeader
239 options={options}
240 canGoBack={
241 attached
242 ? props.state.routes.findIndex((current) => current.key === route.key) > 0
243 : index > 0
244 }
245 />
246 <NativeColumnContent
247 backgroundColor={StyleSheet.flatten(options.contentStyle)?.backgroundColor}
248 insetHorizontally={
249 (options as AppNativeStackNavigationOptions).nativeContentInsetHorizontally
250 }
251 >
252 <NativeScrollEdgeEffectsContext value={options.scrollEdgeEffects}>
253 {descriptor.render()}
254 </NativeScrollEdgeEffectsContext>
255 </NativeColumnContent>
256 </NavigationRouteContext>
257 </NavigationContext>
258 </Stack.Screen>
259 );
260 })}
261 </Stack.Host>
262 </View>
263 );
265​
266export function V5StackView(props: V5StackViewProps) {
267 const groups = partitionStackPresentations(props.state.routes, (route) => {
268 return isPresentedRoute(props.descriptors[route.key]?.options);
269 });
270 const routes = groups.map((group) => group[0]!);
271 const descriptors = Object.fromEntries(
272 groups.map((group) => {
273 const first = group[0]!;
274 const descriptor = props.descriptors[first.key]!;
275 const visibleRoute = group.at(-1)!;
276 return [
277 first.key,
278 {
279 ...descriptor,
280 navigation: props.descriptors[visibleRoute.key]!.navigation,
281 route: visibleRoute,
282 options: modalEnvelopeOptions(descriptor.options),
283 render: () => (
284 <V5CardStackView
285 {...props}
286 state={{
287 ...props.state,
288 routes: group,
289 index: group.length - 1,
290 preloadedRoutes: [],
291 }}
292 />
293 ),
294 },
295 ];
296 }),
297 );
298 return (
299 <NativeStackView
300 presentationEnvelope
301 {...props}
302 descriptors={descriptors}
303 state={{ ...props.state, routes, index: routes.length - 1, preloadedRoutes: [] }}
304 />
305 );
307​
308function V5Navigator({
309 id,
310 initialRouteName,
311 UNSTABLE_routeNamesChangeBehavior,
312 children,
313 layout,
314 screenListeners,
315 screenOptions,
316 screenLayout,
317 UNSTABLE_router,
318 ...rest
319}: NativeStackNavigatorProps) {
320 const { state, describe, descriptors, navigation, NavigationContent } = useNavigationBuilder<
321 StackNavigationState<ParamListBase>,
322 StackRouterOptions,
323 StackActionHelpers<ParamListBase>,
324 NativeStackNavigationOptions,
325 NativeStackNavigationEventMap
326 >(StackRouter, {
327 id,
328 initialRouteName,
329 UNSTABLE_routeNamesChangeBehavior,
330 children,
331 layout,
332 screenListeners,
333 screenOptions,
334 screenLayout,
335 UNSTABLE_router,
336 });
337 const StackView = V5StackView;
338 return (
339 <NavigationContent>
340 <StackView
341 {...rest}
342 state={state}
343 describe={describe}
344 descriptors={descriptors}
345 navigation={navigation}
346 />
347 </NavigationContent>
348 );
350​
351function V5StackNavigator(props: NativeStackNavigatorProps) {
352 return <V5Navigator {...props} />;
354​
355type V5TypeBag<ParamList extends ParamListBase, NavigatorID extends string | undefined> = Omit<
356 NativeStackTypeBag<ParamList, NavigatorID>,
357 "Navigator"
358> & { Navigator: typeof V5StackNavigator };
359export function createV5StackNavigator<
360 const ParamList extends ParamListBase,
361 const NavigatorID extends string | undefined = string | undefined,
362 const TypeBag extends NavigatorTypeBagBase = V5TypeBag<ParamList, NavigatorID>,
363 const Config extends StaticConfig<TypeBag> = StaticConfig<TypeBag>,
364>(config?: Config): TypedNavigator<TypeBag, Config> {
365 return createNavigatorFactory(V5StackNavigator)(config);