UNPKG

22.7 kBTypeScriptView Raw
1import {
2 Background,
3 getDefaultHeaderHeight,
4 SafeAreaProviderCompat,
5} from '@react-navigation/elements';
6import type {
7 ParamListBase,
8 Route,
9 StackNavigationState,
10} from '@react-navigation/native';
11import Color from 'color';
12import * as React from 'react';
13import {
14 Animated,
15 LayoutChangeEvent,
16 Platform,
17 StyleSheet,
18} from 'react-native';
19import type { EdgeInsets } from 'react-native-safe-area-context';
20
21import {
22 forModalPresentationIOS,
23 forNoAnimation as forNoAnimationCard,
24} from '../../TransitionConfigs/CardStyleInterpolators';
25import {
26 DefaultTransition,
27 ModalFadeTransition,
28 ModalTransition,
29} from '../../TransitionConfigs/TransitionPresets';
30import type {
31 Layout,
32 Scene,
33 StackDescriptor,
34 StackDescriptorMap,
35 StackHeaderMode,
36 StackNavigationOptions,
37} from '../../types';
38import findLastIndex from '../../utils/findLastIndex';
39import getDistanceForDirection from '../../utils/getDistanceForDirection';
40import type { Props as HeaderContainerProps } from '../Header/HeaderContainer';
41import { MaybeScreen, MaybeScreenContainer } from '../Screens';
42import { getIsModalPresentation } from './Card';
43import CardContainer from './CardContainer';
44
45type GestureValues = {
46 [key: string]: Animated.Value;
47};
48
49type Props = {
50 insets: EdgeInsets;
51 state: StackNavigationState<ParamListBase>;
52 descriptors: StackDescriptorMap;
53 routes: Route<string>[];
54 openingRouteKeys: string[];
55 closingRouteKeys: string[];
56 onOpenRoute: (props: { route: Route<string> }) => void;
57 onCloseRoute: (props: { route: Route<string> }) => void;
58 getPreviousRoute: (props: {
59 route: Route<string>;
60 }) => Route<string> | undefined;
61 renderHeader: (props: HeaderContainerProps) => React.ReactNode;
62 renderScene: (props: { route: Route<string> }) => React.ReactNode;
63 isParentHeaderShown: boolean;
64 isParentModal: boolean;
65 onTransitionStart: (
66 props: { route: Route<string> },
67 closing: boolean
68 ) => void;
69 onTransitionEnd: (props: { route: Route<string> }, closing: boolean) => void;
70 onGestureStart: (props: { route: Route<string> }) => void;
71 onGestureEnd: (props: { route: Route<string> }) => void;
72 onGestureCancel: (props: { route: Route<string> }) => void;
73 detachInactiveScreens?: boolean;
74};
75
76type State = {
77 routes: Route<string>[];
78 descriptors: StackDescriptorMap;
79 scenes: Scene[];
80 gestures: GestureValues;
81 layout: Layout;
82 headerHeights: Record<string, number>;
83};
84
85const EPSILON = 1e-5;
86
87const STATE_INACTIVE = 0;
88const STATE_TRANSITIONING_OR_BELOW_TOP = 1;
89const STATE_ON_TOP = 2;
90
91const FALLBACK_DESCRIPTOR = Object.freeze({ options: {} });
92
93const getInterpolationIndex = (scenes: Scene[], index: number) => {
94 const { cardStyleInterpolator } = scenes[index].descriptor.options;
95
96 // Start from current card and count backwards the number of cards with same interpolation
97 let interpolationIndex = 0;
98
99 for (let i = index - 1; i >= 0; i--) {
100 const cardStyleInterpolatorCurrent =
101 scenes[i]?.descriptor.options.cardStyleInterpolator;
102
103 if (cardStyleInterpolatorCurrent !== cardStyleInterpolator) {
104 break;
105 }
106
107 interpolationIndex++;
108 }
109
110 return interpolationIndex;
111};
112
113const getIsModal = (
114 scene: Scene,
115 interpolationIndex: number,
116 isParentModal: boolean
117) => {
118 if (isParentModal) {
119 return true;
120 }
121
122 const { cardStyleInterpolator } = scene.descriptor.options;
123 const isModalPresentation = getIsModalPresentation(cardStyleInterpolator);
124 const isModal = isModalPresentation && interpolationIndex !== 0;
125
126 return isModal;
127};
128
129const getHeaderHeights = (
130 scenes: Scene[],
131 insets: EdgeInsets,
132 isParentHeaderShown: boolean,
133 isParentModal: boolean,
134 layout: Layout,
135 previous: Record<string, number>
136) => {
137 return scenes.reduce<Record<string, number>>((acc, curr, index) => {
138 const {
139 headerStatusBarHeight = isParentHeaderShown ? 0 : insets.top,
140 headerStyle,
141 } = curr.descriptor.options;
142
143 const style = StyleSheet.flatten(headerStyle || {});
144
145 const height =
146 'height' in style && typeof style.height === 'number'
147 ? style.height
148 : previous[curr.route.key];
149
150 const interpolationIndex = getInterpolationIndex(scenes, index);
151 const isModal = getIsModal(curr, interpolationIndex, isParentModal);
152
153 acc[curr.route.key] =
154 typeof height === 'number'
155 ? height
156 : getDefaultHeaderHeight(layout, isModal, headerStatusBarHeight);
157
158 return acc;
159 }, {});
160};
161
162const getDistanceFromOptions = (
163 layout: Layout,
164 descriptor?: StackDescriptor
165) => {
166 const {
167 presentation,
168 gestureDirection = presentation === 'modal'
169 ? ModalTransition.gestureDirection
170 : DefaultTransition.gestureDirection,
171 } = (descriptor?.options || {}) as StackNavigationOptions;
172
173 return getDistanceForDirection(layout, gestureDirection);
174};
175
176const getProgressFromGesture = (
177 gesture: Animated.Value,
178 layout: Layout,
179 descriptor?: StackDescriptor
180) => {
181 const distance = getDistanceFromOptions(
182 {
183 // Make sure that we have a non-zero distance, otherwise there will be incorrect progress
184 // This causes blank screen on web if it was previously inside container with display: none
185 width: Math.max(1, layout.width),
186 height: Math.max(1, layout.height),
187 },
188 descriptor
189 );
190
191 if (distance > 0) {
192 return gesture.interpolate({
193 inputRange: [0, distance],
194 outputRange: [1, 0],
195 });
196 }
197
198 return gesture.interpolate({
199 inputRange: [distance, 0],
200 outputRange: [0, 1],
201 });
202};
203
204export default class CardStack extends React.Component<Props, State> {
205 static getDerivedStateFromProps(
206 props: Props,
207 state: State
208 ): Partial<State> | null {
209 if (
210 props.routes === state.routes &&
211 props.descriptors === state.descriptors
212 ) {
213 return null;
214 }
215
216 const gestures = props.routes.reduce<GestureValues>((acc, curr) => {
217 const descriptor = props.descriptors[curr.key];
218 const { animationEnabled } = descriptor?.options || {};
219
220 acc[curr.key] =
221 state.gestures[curr.key] ||
222 new Animated.Value(
223 props.openingRouteKeys.includes(curr.key) &&
224 animationEnabled !== false
225 ? getDistanceFromOptions(state.layout, descriptor)
226 : 0
227 );
228
229 return acc;
230 }, {});
231
232 const scenes = props.routes.map((route, index, self) => {
233 const previousRoute = self[index - 1];
234 const nextRoute = self[index + 1];
235
236 const oldScene = state.scenes[index];
237
238 const currentGesture = gestures[route.key];
239 const previousGesture = previousRoute
240 ? gestures[previousRoute.key]
241 : undefined;
242 const nextGesture = nextRoute ? gestures[nextRoute.key] : undefined;
243
244 const descriptor =
245 props.descriptors[route.key] ||
246 state.descriptors[route.key] ||
247 (oldScene ? oldScene.descriptor : FALLBACK_DESCRIPTOR);
248
249 const nextDescriptor =
250 props.descriptors[nextRoute?.key] || state.descriptors[nextRoute?.key];
251
252 const previousDescriptor =
253 props.descriptors[previousRoute?.key] ||
254 state.descriptors[previousRoute?.key];
255
256 // When a screen is not the last, it should use next screen's transition config
257 // Many transitions also animate the previous screen, so using 2 different transitions doesn't look right
258 // For example combining a slide and a modal transition would look wrong otherwise
259 // With this approach, combining different transition styles in the same navigator mostly looks right
260 // This will still be broken when 2 transitions have different idle state (e.g. modal presentation),
261 // but majority of the transitions look alright
262 const optionsForTransitionConfig =
263 index !== self.length - 1 &&
264 nextDescriptor &&
265 nextDescriptor.options.presentation !== 'transparentModal'
266 ? nextDescriptor.options
267 : descriptor.options;
268
269 let defaultTransitionPreset =
270 optionsForTransitionConfig.presentation === 'modal'
271 ? ModalTransition
272 : optionsForTransitionConfig.presentation === 'transparentModal'
273 ? ModalFadeTransition
274 : DefaultTransition;
275
276 const {
277 animationEnabled = Platform.OS !== 'web' &&
278 Platform.OS !== 'windows' &&
279 Platform.OS !== 'macos',
280 gestureEnabled = Platform.OS === 'ios' && animationEnabled,
281 gestureDirection = defaultTransitionPreset.gestureDirection,
282 transitionSpec = defaultTransitionPreset.transitionSpec,
283 cardStyleInterpolator = animationEnabled === false
284 ? forNoAnimationCard
285 : defaultTransitionPreset.cardStyleInterpolator,
286 headerStyleInterpolator = defaultTransitionPreset.headerStyleInterpolator,
287 cardOverlayEnabled = (Platform.OS !== 'ios' &&
288 optionsForTransitionConfig.presentation !== 'transparentModal') ||
289 getIsModalPresentation(cardStyleInterpolator),
290 } = optionsForTransitionConfig;
291
292 const headerMode: StackHeaderMode =
293 descriptor.options.headerMode ??
294 (!(
295 optionsForTransitionConfig.presentation === 'modal' ||
296 optionsForTransitionConfig.presentation === 'transparentModal' ||
297 nextDescriptor?.options.presentation === 'modal' ||
298 nextDescriptor?.options.presentation === 'transparentModal' ||
299 getIsModalPresentation(cardStyleInterpolator)
300 ) &&
301 Platform.OS === 'ios' &&
302 descriptor.options.header === undefined
303 ? 'float'
304 : 'screen');
305
306 const scene = {
307 route,
308 descriptor: {
309 ...descriptor,
310 options: {
311 ...descriptor.options,
312 animationEnabled,
313 cardOverlayEnabled,
314 cardStyleInterpolator,
315 gestureDirection,
316 gestureEnabled,
317 headerStyleInterpolator,
318 transitionSpec,
319 headerMode,
320 },
321 },
322 progress: {
323 current: getProgressFromGesture(
324 currentGesture,
325 state.layout,
326 descriptor
327 ),
328 next:
329 nextGesture &&
330 nextDescriptor?.options.presentation !== 'transparentModal'
331 ? getProgressFromGesture(
332 nextGesture,
333 state.layout,
334 nextDescriptor
335 )
336 : undefined,
337 previous: previousGesture
338 ? getProgressFromGesture(
339 previousGesture,
340 state.layout,
341 previousDescriptor
342 )
343 : undefined,
344 },
345 __memo: [
346 state.layout,
347 descriptor,
348 nextDescriptor,
349 previousDescriptor,
350 currentGesture,
351 nextGesture,
352 previousGesture,
353 ],
354 };
355
356 if (
357 oldScene &&
358 scene.__memo.every((it, i) => {
359 // @ts-expect-error: we haven't added __memo to the annotation to prevent usage elsewhere
360 return oldScene.__memo[i] === it;
361 })
362 ) {
363 return oldScene;
364 }
365
366 return scene;
367 });
368
369 return {
370 routes: props.routes,
371 scenes,
372 gestures,
373 descriptors: props.descriptors,
374 headerHeights: getHeaderHeights(
375 scenes,
376 props.insets,
377 props.isParentHeaderShown,
378 props.isParentModal,
379 state.layout,
380 state.headerHeights
381 ),
382 };
383 }
384
385 constructor(props: Props) {
386 super(props);
387
388 this.state = {
389 routes: [],
390 scenes: [],
391 gestures: {},
392 layout: SafeAreaProviderCompat.initialMetrics.frame,
393 descriptors: this.props.descriptors,
394 // Used when card's header is null and mode is float to make transition
395 // between screens with headers and those without headers smooth.
396 // This is not a great heuristic here. We don't know synchronously
397 // on mount what the header height is so we have just used the most
398 // common cases here.
399 headerHeights: {},
400 };
401 }
402
403 private handleLayout = (e: LayoutChangeEvent) => {
404 const { height, width } = e.nativeEvent.layout;
405
406 const layout = { width, height };
407
408 this.setState((state, props) => {
409 if (height === state.layout.height && width === state.layout.width) {
410 return null;
411 }
412
413 return {
414 layout,
415 headerHeights: getHeaderHeights(
416 state.scenes,
417 props.insets,
418 props.isParentHeaderShown,
419 props.isParentModal,
420 layout,
421 state.headerHeights
422 ),
423 };
424 });
425 };
426
427 private handleHeaderLayout = ({
428 route,
429 height,
430 }: {
431 route: Route<string>;
432 height: number;
433 }) => {
434 this.setState(({ headerHeights }) => {
435 const previousHeight = headerHeights[route.key];
436
437 if (previousHeight === height) {
438 return null;
439 }
440
441 return {
442 headerHeights: {
443 ...headerHeights,
444 [route.key]: height,
445 },
446 };
447 });
448 };
449
450 private getFocusedRoute = () => {
451 const { state } = this.props;
452
453 return state.routes[state.index];
454 };
455
456 private getPreviousScene = ({ route }: { route: Route<string> }) => {
457 const { getPreviousRoute } = this.props;
458 const { scenes } = this.state;
459
460 const previousRoute = getPreviousRoute({ route });
461
462 if (previousRoute) {
463 const previousScene = scenes.find(
464 (scene) => scene.descriptor.route.key === previousRoute.key
465 );
466
467 return previousScene;
468 }
469
470 return undefined;
471 };
472
473 render() {
474 const {
475 insets,
476 state,
477 routes,
478 closingRouteKeys,
479 onOpenRoute,
480 onCloseRoute,
481 renderHeader,
482 renderScene,
483 isParentHeaderShown,
484 isParentModal,
485 onTransitionStart,
486 onTransitionEnd,
487 onGestureStart,
488 onGestureEnd,
489 onGestureCancel,
490 detachInactiveScreens = Platform.OS === 'web' ||
491 Platform.OS === 'android' ||
492 Platform.OS === 'ios',
493 } = this.props;
494
495 const { scenes, layout, gestures, headerHeights } = this.state;
496
497 const focusedRoute = state.routes[state.index];
498 const focusedHeaderHeight = headerHeights[focusedRoute.key];
499
500 const isFloatHeaderAbsolute = this.state.scenes.slice(-2).some((scene) => {
501 const options = scene.descriptor.options ?? {};
502 const { headerMode, headerTransparent, headerShown = true } = options;
503
504 if (
505 headerTransparent ||
506 headerShown === false ||
507 headerMode === 'screen'
508 ) {
509 return true;
510 }
511
512 return false;
513 });
514
515 let activeScreensLimit = 1;
516
517 for (let i = scenes.length - 1; i >= 0; i--) {
518 const { options } = scenes[i].descriptor;
519 const {
520 // By default, we don't want to detach the previous screen of the active one for modals
521 detachPreviousScreen = options.presentation === 'transparentModal'
522 ? false
523 : getIsModalPresentation(options.cardStyleInterpolator)
524 ? i !==
525 findLastIndex(scenes, (scene) => {
526 const { cardStyleInterpolator } = scene.descriptor.options;
527
528 return (
529 cardStyleInterpolator === forModalPresentationIOS ||
530 cardStyleInterpolator?.name === 'forModalPresentationIOS'
531 );
532 })
533 : true,
534 } = options;
535
536 if (detachPreviousScreen === false) {
537 activeScreensLimit++;
538 } else {
539 // Check at least last 2 screens before stopping
540 // This will make sure that screen isn't detached when another screen is animating on top of the transparent one
541 // For example, (Opaque -> Transparent -> Opaque)
542 if (i <= scenes.length - 2) {
543 break;
544 }
545 }
546 }
547
548 const floatingHeader = (
549 <React.Fragment key="header">
550 {renderHeader({
551 mode: 'float',
552 layout,
553 scenes,
554 getPreviousScene: this.getPreviousScene,
555 getFocusedRoute: this.getFocusedRoute,
556 onContentHeightChange: this.handleHeaderLayout,
557 style: [
558 styles.floating,
559 isFloatHeaderAbsolute && [
560 // Without this, the header buttons won't be touchable on Android when headerTransparent: true
561 { height: focusedHeaderHeight },
562 styles.absolute,
563 ],
564 ],
565 })}
566 </React.Fragment>
567 );
568
569 return (
570 <Background>
571 {isFloatHeaderAbsolute ? null : floatingHeader}
572 <MaybeScreenContainer
573 enabled={detachInactiveScreens}
574 style={styles.container}
575 onLayout={this.handleLayout}
576 >
577 {routes.map((route, index, self) => {
578 const focused = focusedRoute.key === route.key;
579 const gesture = gestures[route.key];
580 const scene = scenes[index];
581
582 // For the screens that shouldn't be active, the value is 0
583 // For those that should be active, but are not the top screen, the value is 1
584 // For those on top of the stack and with interaction enabled, the value is 2
585 // For the old implementation, it stays the same it was
586 let isScreenActive: Animated.AnimatedInterpolation | 2 | 1 | 0 = 1;
587
588 if (index < self.length - activeScreensLimit - 1) {
589 // screen should be inactive because it is too deep in the stack
590 isScreenActive = STATE_INACTIVE;
591 } else {
592 const sceneForActivity = scenes[self.length - 1];
593 const outputValue =
594 index === self.length - 1
595 ? STATE_ON_TOP // the screen is on top after the transition
596 : index >= self.length - activeScreensLimit
597 ? STATE_TRANSITIONING_OR_BELOW_TOP // the screen should stay active after the transition, it is not on top but is in activeLimit
598 : STATE_INACTIVE; // the screen should be active only during the transition, it is at the edge of activeLimit
599 isScreenActive = sceneForActivity
600 ? sceneForActivity.progress.current.interpolate({
601 inputRange: [0, 1 - EPSILON, 1],
602 outputRange: [1, 1, outputValue],
603 extrapolate: 'clamp',
604 })
605 : STATE_TRANSITIONING_OR_BELOW_TOP;
606 }
607
608 const {
609 headerShown = true,
610 headerTransparent,
611 headerStyle,
612 headerTintColor,
613 freezeOnBlur,
614 } = scene.descriptor.options;
615
616 const safeAreaInsetTop = insets.top;
617 const safeAreaInsetRight = insets.right;
618 const safeAreaInsetBottom = insets.bottom;
619 const safeAreaInsetLeft = insets.left;
620
621 const headerHeight =
622 headerShown !== false ? headerHeights[route.key] : 0;
623
624 let headerDarkContent: boolean | undefined;
625
626 if (headerShown) {
627 if (typeof headerTintColor === 'string') {
628 headerDarkContent = Color(headerTintColor).isDark();
629 } else {
630 const flattenedHeaderStyle = StyleSheet.flatten(headerStyle);
631
632 if (
633 flattenedHeaderStyle &&
634 'backgroundColor' in flattenedHeaderStyle &&
635 typeof flattenedHeaderStyle.backgroundColor === 'string'
636 ) {
637 headerDarkContent = !Color(
638 flattenedHeaderStyle.backgroundColor
639 ).isDark();
640 }
641 }
642 }
643
644 // Start from current card and count backwards the number of cards with same interpolation
645 const interpolationIndex = getInterpolationIndex(scenes, index);
646 const isModal = getIsModal(
647 scene,
648 interpolationIndex,
649 isParentModal
650 );
651
652 const isNextScreenTransparent =
653 scenes[index + 1]?.descriptor.options.presentation ===
654 'transparentModal';
655
656 const detachCurrentScreen =
657 scenes[index + 1]?.descriptor.options.detachPreviousScreen !==
658 false;
659
660 return (
661 <MaybeScreen
662 key={route.key}
663 style={StyleSheet.absoluteFill}
664 enabled={detachInactiveScreens}
665 active={isScreenActive}
666 freezeOnBlur={freezeOnBlur}
667 pointerEvents="box-none"
668 >
669 <CardContainer
670 index={index}
671 interpolationIndex={interpolationIndex}
672 modal={isModal}
673 active={index === self.length - 1}
674 focused={focused}
675 closing={closingRouteKeys.includes(route.key)}
676 layout={layout}
677 gesture={gesture}
678 scene={scene}
679 safeAreaInsetTop={safeAreaInsetTop}
680 safeAreaInsetRight={safeAreaInsetRight}
681 safeAreaInsetBottom={safeAreaInsetBottom}
682 safeAreaInsetLeft={safeAreaInsetLeft}
683 onGestureStart={onGestureStart}
684 onGestureCancel={onGestureCancel}
685 onGestureEnd={onGestureEnd}
686 headerHeight={headerHeight}
687 isParentHeaderShown={isParentHeaderShown}
688 onHeaderHeightChange={this.handleHeaderLayout}
689 getPreviousScene={this.getPreviousScene}
690 getFocusedRoute={this.getFocusedRoute}
691 headerDarkContent={headerDarkContent}
692 hasAbsoluteFloatHeader={
693 isFloatHeaderAbsolute && !headerTransparent
694 }
695 renderHeader={renderHeader}
696 renderScene={renderScene}
697 onOpenRoute={onOpenRoute}
698 onCloseRoute={onCloseRoute}
699 onTransitionStart={onTransitionStart}
700 onTransitionEnd={onTransitionEnd}
701 isNextScreenTransparent={isNextScreenTransparent}
702 detachCurrentScreen={detachCurrentScreen}
703 />
704 </MaybeScreen>
705 );
706 })}
707 </MaybeScreenContainer>
708 {isFloatHeaderAbsolute ? floatingHeader : null}
709 </Background>
710 );
711 }
712}
713
714const styles = StyleSheet.create({
715 container: {
716 flex: 1,
717 },
718 absolute: {
719 position: 'absolute',
720 top: 0,
721 left: 0,
722 right: 0,
723 },
724 floating: {
725 zIndex: 1,
726 },
727});