import { getDefaultHeaderHeight, getHeaderTitle, HeaderBackContext, HeaderHeightContext, HeaderShownContext, SafeAreaProviderCompat, useFrameSize, } from '@react-navigation/elements'; import { NavigationContext, NavigationRouteContext, type ParamListBase, type RouteProp, StackActions, type StackNavigationState, usePreventRemoveContext, useTheme, } from '@react-navigation/native'; import * as React from 'react'; import { Animated, Platform, StatusBar, StyleSheet, useAnimatedValue, View, } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { compatibilityFlags, type ScreenProps, ScreenStack, ScreenStackItem, } from 'react-native-screens'; import type { NativeStackDescriptor, NativeStackDescriptorMap, NativeStackNavigationHelpers, } from '../types'; import { debounce } from '../utils/debounce'; import { getModalRouteKeys } from '../utils/getModalRoutesKeys'; import { AnimatedHeaderHeightContext } from '../utils/useAnimatedHeaderHeight'; import { useDismissedRouteError } from '../utils/useDismissedRouteError'; import { useInvalidPreventRemoveError } from '../utils/useInvalidPreventRemoveError'; import { useHeaderConfigProps } from './useHeaderConfigProps'; const ANDROID_DEFAULT_HEADER_HEIGHT = 56; function isFabric() { return 'nativeFabricUIManager' in global; } type SceneViewProps = { index: number; focused: boolean; shouldFreeze: boolean; descriptor: NativeStackDescriptor; previousDescriptor?: NativeStackDescriptor; nextDescriptor?: NativeStackDescriptor; isPresentationModal?: boolean; isPreloaded?: boolean; onWillDisappear: () => void; onWillAppear: () => void; onAppear: () => void; onDisappear: () => void; onDismissed: ScreenProps['onDismissed']; onHeaderBackButtonClicked: ScreenProps['onHeaderBackButtonClicked']; onNativeDismissCancelled: ScreenProps['onDismissed']; onGestureCancel: ScreenProps['onGestureCancel']; onSheetDetentChanged: ScreenProps['onSheetDetentChanged']; }; const useNativeDriver = Platform.OS !== 'web'; const SceneView = ({ index, focused, shouldFreeze, descriptor, previousDescriptor, nextDescriptor, isPresentationModal, isPreloaded, onWillDisappear, onWillAppear, onAppear, onDisappear, onDismissed, onHeaderBackButtonClicked, onNativeDismissCancelled, onGestureCancel, onSheetDetentChanged, }: SceneViewProps) => { const { route, navigation, options, render } = descriptor; let { animation, animationMatchesGesture, presentation = isPresentationModal ? 'modal' : 'card', fullScreenGestureEnabled, } = options; const { animationDuration, animationTypeForReplace = 'push', fullScreenGestureShadowEnabled = true, gestureEnabled, gestureDirection = presentation === 'card' ? 'horizontal' : 'vertical', gestureResponseDistance, header, headerBackButtonMenuEnabled, headerShown, headerBackground, headerTransparent, autoHideHomeIndicator, keyboardHandlingEnabled, navigationBarColor, navigationBarTranslucent, navigationBarHidden, orientation, sheetAllowedDetents = [1.0], sheetLargestUndimmedDetentIndex = -1, sheetGrabberVisible = false, sheetCornerRadius = -1.0, sheetElevation = 24, sheetExpandsWhenScrolledToEdge = true, sheetInitialDetentIndex = 0, sheetShouldOverflowTopInset = false, statusBarAnimation, statusBarHidden, statusBarStyle, statusBarTranslucent, statusBarBackgroundColor, unstable_sheetFooter, scrollEdgeEffects, freezeOnBlur, contentStyle, } = options; if (gestureDirection === 'vertical' && Platform.OS === 'ios') { // for `vertical` direction to work, we need to set `fullScreenGestureEnabled` to `true` // so the screen can be dismissed from any point on screen. // `animationMatchesGesture` needs to be set to `true` so the `animation` set by user can be used, // otherwise `simple_push` will be used. // Also, the default animation for this direction seems to be `slide_from_bottom`. if (fullScreenGestureEnabled === undefined) { fullScreenGestureEnabled = true; } if (animationMatchesGesture === undefined) { animationMatchesGesture = true; } if (animation === undefined) { animation = 'slide_from_bottom'; } } // workaround for rn-screens where gestureDirection has to be set on both // current and previous screen - software-mansion/react-native-screens/pull/1509 const nextGestureDirection = nextDescriptor?.options.gestureDirection; const gestureDirectionOverride = nextGestureDirection != null ? nextGestureDirection : gestureDirection; if (index === 0) { // first screen should always be treated as `card`, it resolves problems with no header animation // for navigator with first screen as `modal` and the next as `card` presentation = 'card'; } const { colors } = useTheme(); const insets = useSafeAreaInsets(); // `modal`, `formSheet` and `pageSheet` presentations do not take whole screen, so should not take the inset. const isModal = presentation === 'modal' || presentation === 'formSheet' || presentation === 'pageSheet'; // Modals are fullscreen in landscape only on iPhone const isIPhone = Platform.OS === 'ios' && !(Platform.isPad || Platform.isTV); const isParentHeaderShown = React.useContext(HeaderShownContext); const parentHeaderHeight = React.useContext(HeaderHeightContext); const parentHeaderBack = React.useContext(HeaderBackContext); const isLandscape = useFrameSize((frame) => frame.width > frame.height); const topInset = isParentHeaderShown || (Platform.OS === 'ios' && isModal) || (isIPhone && isLandscape) ? 0 : insets.top; const defaultHeaderHeight = useFrameSize((frame) => Platform.select({ // FIXME: Currently screens isn't using Material 3 // So our `getDefaultHeaderHeight` doesn't return the correct value // So we hardcode the value here for now until screens is updated android: ANDROID_DEFAULT_HEADER_HEIGHT + topInset, default: getDefaultHeaderHeight(frame, isModal, topInset), }) ); const { preventedRoutes } = usePreventRemoveContext(); const [headerHeight, setHeaderHeight] = React.useState(defaultHeaderHeight); // eslint-disable-next-line react-hooks/exhaustive-deps const setHeaderHeightDebounced = React.useCallback( // Debounce the header height updates to avoid excessive re-renders debounce(setHeaderHeight, 100), [] ); const hasCustomHeader = header != null; const usesNewAndroidHeaderHeightImplementation = 'usesNewAndroidHeaderHeightImplementation' in compatibilityFlags && compatibilityFlags['usesNewAndroidHeaderHeightImplementation'] === true; let headerHeightCorrectionOffset = 0; if ( Platform.OS === 'android' && !hasCustomHeader && !usesNewAndroidHeaderHeightImplementation ) { const statusBarHeight = StatusBar.currentHeight ?? 0; // On Android, the native header height is not correctly calculated // It includes status bar height even if statusbar is not translucent // And the statusbar value itself doesn't match the actual status bar height // So we subtract the bogus status bar height and add the actual top inset headerHeightCorrectionOffset = -statusBarHeight + topInset; } const rawAnimatedHeaderHeight = useAnimatedValue(defaultHeaderHeight); const animatedHeaderHeight = React.useMemo( () => Animated.add( rawAnimatedHeaderHeight, headerHeightCorrectionOffset ), [headerHeightCorrectionOffset, rawAnimatedHeaderHeight] ); // During the very first render topInset is > 0 when running // in non edge-to-edge mode on Android, while on every consecutive render // topInset === 0, causing header content to jump, as we add padding on the first frame, // just to remove it in next one. To prevent this, when statusBarTranslucent is set, // we apply additional padding in header only if its true. // For more details see: https://github.com/react-navigation/react-navigation/pull/12014 const headerTopInsetEnabled = typeof statusBarTranslucent === 'boolean' ? statusBarTranslucent : topInset !== 0; const canGoBack = previousDescriptor != null || parentHeaderBack != null; const backTitle = previousDescriptor ? getHeaderTitle(previousDescriptor.options, previousDescriptor.route.name) : parentHeaderBack?.title; const headerBack = React.useMemo(() => { if (canGoBack) { return { href: undefined, // No href needed for native title: backTitle, }; } return undefined; }, [canGoBack, backTitle]); const isRemovePrevented = preventedRoutes[route.key]?.preventRemove; const headerConfig = useHeaderConfigProps({ ...options, route, headerBackButtonMenuEnabled: isRemovePrevented !== undefined ? !isRemovePrevented : headerBackButtonMenuEnabled, headerBackTitle: options.headerBackTitle !== undefined ? options.headerBackTitle : undefined, headerHeight, headerShown: header !== undefined ? false : headerShown, headerTopInsetEnabled, headerTransparent, headerBack, }); const onHeaderHeightChange = hasCustomHeader ? // If we have a custom header, don't use native header height undefined : // On Fabric, there's a bug where native event drivers for Animated objects // are created after the first notifications about the header height // from the native side, `onHeaderHeightChange` event does not notify // `animatedHeaderHeight` about initial values on appearing screens at the moment. Animated.event( [ { nativeEvent: { headerHeight: rawAnimatedHeaderHeight, }, }, ], { useNativeDriver, listener: (e) => { if ( e.nativeEvent && typeof e.nativeEvent === 'object' && 'headerHeight' in e.nativeEvent && typeof e.nativeEvent.headerHeight === 'number' ) { const headerHeight = e.nativeEvent.headerHeight; // Only debounce if header has large title or search bar // As it's the only case where the header height can change frequently const doesHeaderAnimate = Platform.OS === 'ios' && (options.headerLargeTitleEnabled || options.headerSearchBarOptions); if (doesHeaderAnimate) { setHeaderHeightDebounced(headerHeight); } else { if ( Platform.OS === 'android' && headerHeight !== 0 && headerHeight <= ANDROID_DEFAULT_HEADER_HEIGHT ) { // FIXME: On Android, events may get delivered out-of-order // https://github.com/facebook/react-native/issues/54636 // We seem to get header height without status bar height first, // and then the correct height with status bar height included // But due to out-of-order delivery, we may get the correct height first // and then the one without status bar height // This is hack to include status bar height if it's not already included // It only works because header height doesn't change dynamically on Android setHeaderHeight(headerHeight + insets.top); } else { setHeaderHeight(headerHeight); } } } }, } ); return ( {headerBackground != null ? ( /** * To show a custom header background, we render it at the top of the screen below the header * The header also needs to be positioned absolutely (with `translucent` style) */ {headerBackground()} ) : null} {header != null && headerShown !== false ? ( { const headerHeight = e.nativeEvent.layout.height; setHeaderHeight(headerHeight); rawAnimatedHeaderHeight.setValue(headerHeight); }} style={[ styles.header, headerTransparent ? styles.absolute : null, ]} > {header({ back: headerBack, options, route, navigation, })} ) : null} {render()} ); }; type Props = { state: StackNavigationState; navigation: NativeStackNavigationHelpers; descriptors: NativeStackDescriptorMap; describe: ( route: RouteProp, placeholder: boolean ) => NativeStackDescriptor; }; export function NativeStackView({ state, navigation, descriptors, describe, }: Props) { const { setNextDismissedKey } = useDismissedRouteError(state); useInvalidPreventRemoveError(descriptors); const modalRouteKeys = getModalRouteKeys(state.routes, descriptors); const preloadedDescriptors = state.preloadedRoutes.reduce((acc, route) => { acc[route.key] = acc[route.key] || describe(route, true); return acc; }, {}); return ( {state.routes.concat(state.preloadedRoutes).map((route, index) => { const descriptor = descriptors[route.key] ?? preloadedDescriptors[route.key]; const isFocused = state.index === index; const isBelowFocused = state.index - 1 === index; const previousKey = state.routes[index - 1]?.key; const nextKey = state.routes[index + 1]?.key; const previousDescriptor = previousKey ? descriptors[previousKey] : undefined; const nextDescriptor = nextKey ? descriptors[nextKey] : undefined; const isModal = modalRouteKeys.includes(route.key); const isModalOnIos = isModal && Platform.OS === 'ios'; const isPreloaded = preloadedDescriptors[route.key] !== undefined && descriptors[route.key] === undefined; // On Fabric, when screen is frozen, animated and reanimated values are not updated // due to component being unmounted. To avoid this, we don't freeze the previous screen there const shouldFreeze = isFabric() ? !isPreloaded && !isFocused && !isBelowFocused && !isModalOnIos : !isPreloaded && !isFocused && !isModalOnIos; return ( { navigation.emit({ type: 'transitionStart', data: { closing: true }, target: route.key, }); }} onWillAppear={() => { navigation.emit({ type: 'transitionStart', data: { closing: false }, target: route.key, }); }} onAppear={() => { navigation.emit({ type: 'transitionEnd', data: { closing: false }, target: route.key, }); }} onDisappear={() => { navigation.emit({ type: 'transitionEnd', data: { closing: true }, target: route.key, }); }} onDismissed={(event) => { navigation.dispatch({ ...StackActions.pop(event.nativeEvent.dismissCount), source: route.key, target: state.key, }); setNextDismissedKey(route.key); }} onHeaderBackButtonClicked={() => { navigation.dispatch({ ...StackActions.pop(), source: route.key, target: state.key, }); }} onNativeDismissCancelled={(event) => { navigation.dispatch({ ...StackActions.pop(event.nativeEvent.dismissCount), source: route.key, target: state.key, }); }} onGestureCancel={() => { navigation.emit({ type: 'gestureCancel', target: route.key, }); }} onSheetDetentChanged={(event) => { navigation.emit({ type: 'sheetDetentChange', target: route.key, data: { index: event.nativeEvent.index, stable: event.nativeEvent.isStable, }, }); }} /> ); })} ); } const styles = StyleSheet.create({ container: { flex: 1, }, header: { zIndex: 1, }, absolute: { position: 'absolute', top: 0, start: 0, end: 0, }, translucent: { position: 'absolute', top: 0, start: 0, end: 0, zIndex: 1, elevation: 1, }, background: { overflow: 'hidden', }, });