UNPKG

16.2 kBTypeScriptView Raw
1import {
2 HeaderShownContext,
3 SafeAreaProviderCompat,
4} from '@react-navigation/elements';
5import {
6 ParamListBase,
7 Route,
8 StackActions,
9 StackNavigationState,
10} from '@react-navigation/native';
11import * as React from 'react';
12import { StyleSheet, View } from 'react-native';
13import {
14 EdgeInsets,
15 SafeAreaInsetsContext,
16} from 'react-native-safe-area-context';
17
18import type {
19 StackDescriptorMap,
20 StackNavigationConfig,
21 StackNavigationHelpers,
22} from '../../types';
23import ModalPresentationContext from '../../utils/ModalPresentationContext';
24import { GestureHandlerRootView } from '../GestureHandler';
25import HeaderContainer, {
26 Props as HeaderContainerProps,
27} from '../Header/HeaderContainer';
28import CardStack from './CardStack';
29
30type Props = StackNavigationConfig & {
31 state: StackNavigationState<ParamListBase>;
32 navigation: StackNavigationHelpers;
33 descriptors: StackDescriptorMap;
34};
35
36type State = {
37 // Local copy of the routes which are actually rendered
38 routes: Route<string>[];
39 // Previous routes, to compare whether routes have changed or not
40 previousRoutes: Route<string>[];
41 // Previous descriptors, to compare whether descriptors have changed or not
42 previousDescriptors: StackDescriptorMap;
43 // List of routes being opened, we need to animate pushing of these new routes
44 openingRouteKeys: string[];
45 // List of routes being closed, we need to animate popping of these routes
46 closingRouteKeys: string[];
47 // List of routes being replaced, we need to keep a copy until the new route animates in
48 replacingRouteKeys: string[];
49 // Since the local routes can vary from the routes from props, we need to keep the descriptors for old routes
50 // Otherwise we won't be able to access the options for routes that were removed
51 descriptors: StackDescriptorMap;
52};
53
54const GestureHandlerWrapper = GestureHandlerRootView ?? View;
55
56/**
57 * Compare two arrays with primitive values as the content.
58 * We need to make sure that both values and order match.
59 */
60const isArrayEqual = (a: any[], b: any[]) =>
61 a.length === b.length && a.every((it, index) => it === b[index]);
62
63export default class StackView extends React.Component<Props, State> {
64 static getDerivedStateFromProps(
65 props: Readonly<Props>,
66 state: Readonly<State>
67 ) {
68 // If there was no change in routes, we don't need to compute anything
69 if (
70 (props.state.routes === state.previousRoutes ||
71 isArrayEqual(
72 props.state.routes.map((r) => r.key),
73 state.previousRoutes.map((r) => r.key)
74 )) &&
75 state.routes.length
76 ) {
77 let routes = state.routes;
78 let previousRoutes = state.previousRoutes;
79 let descriptors = props.descriptors;
80 let previousDescriptors = state.previousDescriptors;
81
82 if (props.descriptors !== state.previousDescriptors) {
83 descriptors = state.routes.reduce<StackDescriptorMap>((acc, route) => {
84 acc[route.key] =
85 props.descriptors[route.key] || state.descriptors[route.key];
86
87 return acc;
88 }, {});
89
90 previousDescriptors = props.descriptors;
91 }
92
93 if (props.state.routes !== state.previousRoutes) {
94 // if any route objects have changed, we should update them
95 const map = props.state.routes.reduce<Record<string, Route<string>>>(
96 (acc, route) => {
97 acc[route.key] = route;
98 return acc;
99 },
100 {}
101 );
102
103 routes = state.routes.map((route) => map[route.key] || route);
104 previousRoutes = props.state.routes;
105 }
106
107 return {
108 routes,
109 previousRoutes,
110 descriptors,
111 previousDescriptors,
112 };
113 }
114
115 // Here we determine which routes were added or removed to animate them
116 // We keep a copy of the route being removed in local state to be able to animate it
117
118 let routes =
119 props.state.index < props.state.routes.length - 1
120 ? // Remove any extra routes from the state
121 // The last visible route should be the focused route, i.e. at current index
122 props.state.routes.slice(0, props.state.index + 1)
123 : props.state.routes;
124
125 // Now we need to determine which routes were added and removed
126 let {
127 openingRouteKeys,
128 closingRouteKeys,
129 replacingRouteKeys,
130 previousRoutes,
131 } = state;
132
133 const previousFocusedRoute = previousRoutes[previousRoutes.length - 1] as
134 | Route<string>
135 | undefined;
136 const nextFocusedRoute = routes[routes.length - 1];
137
138 const isAnimationEnabled = (key: string) => {
139 const descriptor = props.descriptors[key] || state.descriptors[key];
140
141 return descriptor ? descriptor.options.animationEnabled !== false : true;
142 };
143
144 const getAnimationTypeForReplace = (key: string) => {
145 const descriptor = props.descriptors[key] || state.descriptors[key];
146
147 return descriptor.options.animationTypeForReplace ?? 'push';
148 };
149
150 if (
151 previousFocusedRoute &&
152 previousFocusedRoute.key !== nextFocusedRoute.key
153 ) {
154 // We only need to animate routes if the focused route changed
155 // Animating previous routes won't be visible coz the focused route is on top of everything
156
157 if (!previousRoutes.some((r) => r.key === nextFocusedRoute.key)) {
158 // A new route has come to the focus, we treat this as a push
159 // A replace can also trigger this, the animation should look like push
160
161 if (
162 isAnimationEnabled(nextFocusedRoute.key) &&
163 !openingRouteKeys.includes(nextFocusedRoute.key)
164 ) {
165 // In this case, we need to animate pushing the focused route
166 // We don't care about animating any other added routes because they won't be visible
167 openingRouteKeys = [...openingRouteKeys, nextFocusedRoute.key];
168
169 closingRouteKeys = closingRouteKeys.filter(
170 (key) => key !== nextFocusedRoute.key
171 );
172 replacingRouteKeys = replacingRouteKeys.filter(
173 (key) => key !== nextFocusedRoute.key
174 );
175
176 if (!routes.some((r) => r.key === previousFocusedRoute.key)) {
177 // The previous focused route isn't present in state, we treat this as a replace
178
179 openingRouteKeys = openingRouteKeys.filter(
180 (key) => key !== previousFocusedRoute.key
181 );
182
183 if (getAnimationTypeForReplace(nextFocusedRoute.key) === 'pop') {
184 closingRouteKeys = [
185 ...closingRouteKeys,
186 previousFocusedRoute.key,
187 ];
188
189 // By default, new routes have a push animation, so we add it to `openingRouteKeys` before
190 // But since user configured it to animate the old screen like a pop, we need to add this without animation
191 // So remove it from `openingRouteKeys` which will remove the animation
192 openingRouteKeys = openingRouteKeys.filter(
193 (key) => key !== nextFocusedRoute.key
194 );
195
196 // Keep the route being removed at the end to animate it out
197 routes = [...routes, previousFocusedRoute];
198 } else {
199 replacingRouteKeys = [
200 ...replacingRouteKeys,
201 previousFocusedRoute.key,
202 ];
203
204 closingRouteKeys = closingRouteKeys.filter(
205 (key) => key !== previousFocusedRoute.key
206 );
207
208 // Keep the old route in the state because it's visible under the new route, and removing it will feel abrupt
209 // We need to insert it just before the focused one (the route being pushed)
210 // After the push animation is completed, routes being replaced will be removed completely
211 routes = routes.slice();
212 routes.splice(routes.length - 1, 0, previousFocusedRoute);
213 }
214 }
215 }
216 } else if (!routes.some((r) => r.key === previousFocusedRoute.key)) {
217 // The previously focused route was removed, we treat this as a pop
218
219 if (
220 isAnimationEnabled(previousFocusedRoute.key) &&
221 !closingRouteKeys.includes(previousFocusedRoute.key)
222 ) {
223 closingRouteKeys = [...closingRouteKeys, previousFocusedRoute.key];
224
225 // Sometimes a route can be closed before the opening animation finishes
226 // So we also need to remove it from the opening list
227 openingRouteKeys = openingRouteKeys.filter(
228 (key) => key !== previousFocusedRoute.key
229 );
230 replacingRouteKeys = replacingRouteKeys.filter(
231 (key) => key !== previousFocusedRoute.key
232 );
233
234 // Keep a copy of route being removed in the state to be able to animate it
235 routes = [...routes, previousFocusedRoute];
236 }
237 } else {
238 // Looks like some routes were re-arranged and no focused routes were added/removed
239 // i.e. the currently focused route already existed and the previously focused route still exists
240 // We don't know how to animate this
241 }
242 } else if (replacingRouteKeys.length || closingRouteKeys.length) {
243 // Keep the routes we are closing or replacing if animation is enabled for them
244 routes = routes.slice();
245 routes.splice(
246 routes.length - 1,
247 0,
248 ...state.routes.filter(({ key }) =>
249 isAnimationEnabled(key)
250 ? replacingRouteKeys.includes(key) || closingRouteKeys.includes(key)
251 : false
252 )
253 );
254 }
255
256 if (!routes.length) {
257 throw new Error(
258 'There should always be at least one route in the navigation state.'
259 );
260 }
261
262 const descriptors = routes.reduce<StackDescriptorMap>((acc, route) => {
263 acc[route.key] =
264 props.descriptors[route.key] || state.descriptors[route.key];
265
266 return acc;
267 }, {});
268
269 return {
270 routes,
271 previousRoutes: props.state.routes,
272 previousDescriptors: props.descriptors,
273 openingRouteKeys,
274 closingRouteKeys,
275 replacingRouteKeys,
276 descriptors,
277 };
278 }
279
280 state: State = {
281 routes: [],
282 previousRoutes: [],
283 previousDescriptors: {},
284 openingRouteKeys: [],
285 closingRouteKeys: [],
286 replacingRouteKeys: [],
287 descriptors: {},
288 };
289
290 private getPreviousRoute = ({ route }: { route: Route<string> }) => {
291 const { closingRouteKeys, replacingRouteKeys } = this.state;
292 const routes = this.state.routes.filter(
293 (r) =>
294 r.key === route.key ||
295 (!closingRouteKeys.includes(r.key) &&
296 !replacingRouteKeys.includes(r.key))
297 );
298
299 const index = routes.findIndex((r) => r.key === route.key);
300
301 return routes[index - 1];
302 };
303
304 private renderScene = ({ route }: { route: Route<string> }) => {
305 const descriptor =
306 this.state.descriptors[route.key] || this.props.descriptors[route.key];
307
308 if (!descriptor) {
309 return null;
310 }
311
312 return descriptor.render();
313 };
314
315 private renderHeader = (props: HeaderContainerProps) => {
316 return <HeaderContainer {...props} />;
317 };
318
319 private handleOpenRoute = ({ route }: { route: Route<string> }) => {
320 const { state, navigation } = this.props;
321 const { closingRouteKeys, replacingRouteKeys } = this.state;
322
323 if (
324 closingRouteKeys.some((key) => key === route.key) &&
325 replacingRouteKeys.every((key) => key !== route.key) &&
326 state.routeNames.includes(route.name) &&
327 !state.routes.some((r) => r.key === route.key)
328 ) {
329 // If route isn't present in current state, but was closing, assume that a close animation was cancelled
330 // So we need to add this route back to the state
331 navigation.navigate(route);
332 } else {
333 this.setState((state) => ({
334 routes: state.replacingRouteKeys.length
335 ? state.routes.filter(
336 (r) => !state.replacingRouteKeys.includes(r.key)
337 )
338 : state.routes,
339 openingRouteKeys: state.openingRouteKeys.filter(
340 (key) => key !== route.key
341 ),
342 closingRouteKeys: state.closingRouteKeys.filter(
343 (key) => key !== route.key
344 ),
345 replacingRouteKeys: [],
346 }));
347 }
348 };
349
350 private handleCloseRoute = ({ route }: { route: Route<string> }) => {
351 const { state, navigation } = this.props;
352
353 if (state.routes.some((r) => r.key === route.key)) {
354 // If a route exists in state, trigger a pop
355 // This will happen in when the route was closed from the card component
356 // e.g. When the close animation triggered from a gesture ends
357 navigation.dispatch({
358 ...StackActions.pop(),
359 source: route.key,
360 target: state.key,
361 });
362 } else {
363 // We need to clean up any state tracking the route and pop it immediately
364 this.setState((state) => ({
365 routes: state.routes.filter((r) => r.key !== route.key),
366 openingRouteKeys: state.openingRouteKeys.filter(
367 (key) => key !== route.key
368 ),
369 closingRouteKeys: state.closingRouteKeys.filter(
370 (key) => key !== route.key
371 ),
372 }));
373 }
374 };
375
376 private handleTransitionStart = (
377 { route }: { route: Route<string> },
378 closing: boolean
379 ) =>
380 this.props.navigation.emit({
381 type: 'transitionStart',
382 data: { closing },
383 target: route.key,
384 });
385
386 private handleTransitionEnd = (
387 { route }: { route: Route<string> },
388 closing: boolean
389 ) =>
390 this.props.navigation.emit({
391 type: 'transitionEnd',
392 data: { closing },
393 target: route.key,
394 });
395
396 private handleGestureStart = ({ route }: { route: Route<string> }) => {
397 this.props.navigation.emit({
398 type: 'gestureStart',
399 target: route.key,
400 });
401 };
402
403 private handleGestureEnd = ({ route }: { route: Route<string> }) => {
404 this.props.navigation.emit({
405 type: 'gestureEnd',
406 target: route.key,
407 });
408 };
409
410 private handleGestureCancel = ({ route }: { route: Route<string> }) => {
411 this.props.navigation.emit({
412 type: 'gestureCancel',
413 target: route.key,
414 });
415 };
416
417 render() {
418 const {
419 state,
420 // eslint-disable-next-line @typescript-eslint/no-unused-vars
421 descriptors: _,
422 ...rest
423 } = this.props;
424
425 const { routes, descriptors, openingRouteKeys, closingRouteKeys } =
426 this.state;
427
428 return (
429 <GestureHandlerWrapper style={styles.container}>
430 <SafeAreaProviderCompat>
431 <SafeAreaInsetsContext.Consumer>
432 {(insets) => (
433 <ModalPresentationContext.Consumer>
434 {(isParentModal) => (
435 <HeaderShownContext.Consumer>
436 {(isParentHeaderShown) => (
437 <CardStack
438 insets={insets as EdgeInsets}
439 isParentHeaderShown={isParentHeaderShown}
440 isParentModal={isParentModal}
441 getPreviousRoute={this.getPreviousRoute}
442 routes={routes}
443 openingRouteKeys={openingRouteKeys}
444 closingRouteKeys={closingRouteKeys}
445 onOpenRoute={this.handleOpenRoute}
446 onCloseRoute={this.handleCloseRoute}
447 onTransitionStart={this.handleTransitionStart}
448 onTransitionEnd={this.handleTransitionEnd}
449 renderHeader={this.renderHeader}
450 renderScene={this.renderScene}
451 state={state}
452 descriptors={descriptors}
453 onGestureStart={this.handleGestureStart}
454 onGestureEnd={this.handleGestureEnd}
455 onGestureCancel={this.handleGestureCancel}
456 {...rest}
457 />
458 )}
459 </HeaderShownContext.Consumer>
460 )}
461 </ModalPresentationContext.Consumer>
462 )}
463 </SafeAreaInsetsContext.Consumer>
464 </SafeAreaProviderCompat>
465 </GestureHandlerWrapper>
466 );
467 }
468}
469
470const styles = StyleSheet.create({
471 container: {
472 flex: 1,
473 },
474});