chore: add mobile app

This commit is contained in:
2026-02-07 22:33:02 +01:00
parent db34640acc
commit e89384ad4e
29327 changed files with 3886944 additions and 12 deletions
@@ -0,0 +1,38 @@
/**
* Navigators
*/
export { createNativeStackNavigator } from './navigators/createNativeStackNavigator';
/**
* Views
*/
export { NativeStackView } from './views/NativeStackView';
/**
* Hooks
*/
export { useAnimatedHeaderHeight } from './utils/useAnimatedHeaderHeight';
/**
* Types
*/
export type {
NativeStackHeaderBackProps,
NativeStackHeaderItem,
NativeStackHeaderItemButton,
NativeStackHeaderItemCustom,
NativeStackHeaderItemMenu,
NativeStackHeaderItemMenuAction,
NativeStackHeaderItemMenuSubmenu,
NativeStackHeaderItemProps,
NativeStackHeaderItemSpacing,
NativeStackHeaderLeftProps,
NativeStackHeaderProps,
NativeStackHeaderRightProps,
NativeStackNavigationEventMap,
NativeStackNavigationOptions,
NativeStackNavigationProp,
NativeStackNavigatorProps,
NativeStackOptionsArgs,
NativeStackScreenProps,
} from './types';
@@ -0,0 +1,123 @@
import {
createNavigatorFactory,
type EventArg,
NavigationMetaContext,
type NavigatorTypeBagBase,
type ParamListBase,
type StackActionHelpers,
StackActions,
type StackNavigationState,
StackRouter,
type StackRouterOptions,
type StaticConfig,
type TypedNavigator,
useNavigationBuilder,
} from '@react-navigation/native';
import * as React from 'react';
import type {
NativeStackNavigationEventMap,
NativeStackNavigationOptions,
NativeStackNavigationProp,
NativeStackNavigatorProps,
} from '../types';
import { NativeStackView } from '../views/NativeStackView';
function NativeStackNavigator({
id,
initialRouteName,
UNSTABLE_routeNamesChangeBehavior,
children,
layout,
screenListeners,
screenOptions,
screenLayout,
UNSTABLE_router,
...rest
}: NativeStackNavigatorProps) {
const { state, describe, descriptors, navigation, NavigationContent } =
useNavigationBuilder<
StackNavigationState<ParamListBase>,
StackRouterOptions,
StackActionHelpers<ParamListBase>,
NativeStackNavigationOptions,
NativeStackNavigationEventMap
>(StackRouter, {
id,
initialRouteName,
UNSTABLE_routeNamesChangeBehavior,
children,
layout,
screenListeners,
screenOptions,
screenLayout,
UNSTABLE_router,
});
const meta = React.useContext(NavigationMetaContext);
React.useEffect(() => {
if (meta && 'type' in meta && meta.type === 'native-tabs') {
// If we're inside native tabs, we don't need to handle popToTop
// It's handled natively by native tabs
return;
}
// @ts-expect-error: there may not be a tab navigator in parent
return navigation?.addListener?.('tabPress', (e: any) => {
const isFocused = navigation.isFocused();
// Run the operation in the next frame so we're sure all listeners have been run
// This is necessary to know if preventDefault() has been called
requestAnimationFrame(() => {
if (
state.index > 0 &&
isFocused &&
!(e as EventArg<'tabPress', true>).defaultPrevented
) {
// When user taps on already focused tab and we're inside the tab,
// reset the stack to replicate native behaviour
navigation.dispatch({
...StackActions.popToTop(),
target: state.key,
});
}
});
});
}, [meta, navigation, state.index, state.key]);
return (
<NavigationContent>
<NativeStackView
{...rest}
state={state}
navigation={navigation}
descriptors={descriptors}
describe={describe}
/>
</NavigationContent>
);
}
export function createNativeStackNavigator<
const ParamList extends ParamListBase,
const NavigatorID extends string | undefined = string | undefined,
const TypeBag extends NavigatorTypeBagBase = {
ParamList: ParamList;
NavigatorID: NavigatorID;
State: StackNavigationState<ParamList>;
ScreenOptions: NativeStackNavigationOptions;
EventMap: NativeStackNavigationEventMap;
NavigationList: {
[RouteName in keyof ParamList]: NativeStackNavigationProp<
ParamList,
RouteName,
NavigatorID
>;
};
Navigator: typeof NativeStackNavigator;
},
const Config extends StaticConfig<TypeBag> = StaticConfig<TypeBag>,
>(config?: Config): TypedNavigator<TypeBag, Config> {
return createNavigatorFactory(NativeStackNavigator)(config);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,14 @@
export function debounce<T extends (...args: any[]) => void>(
func: T,
duration: number
): T {
let timeout: ReturnType<typeof setTimeout>;
return function (this: unknown, ...args) {
clearTimeout(timeout);
timeout = setTimeout(() => {
func.apply(this, args);
}, duration);
} as T;
}
@@ -0,0 +1,26 @@
import type { Route } from '@react-navigation/native';
import type { NativeStackDescriptorMap } from '../types';
export const getModalRouteKeys = (
routes: Route<string>[],
descriptors: NativeStackDescriptorMap
) =>
routes.reduce<string[]>((acc, route) => {
const { presentation } = descriptors[route.key]?.options ?? {};
if (
(acc.length && !presentation) ||
presentation === 'modal' ||
presentation === 'transparentModal' ||
presentation === 'containedModal' ||
presentation === 'containedTransparentModal' ||
presentation === 'fullScreenModal' ||
presentation === 'formSheet' ||
presentation === 'pageSheet'
) {
acc.push(route.key);
}
return acc;
}, []);
@@ -0,0 +1,18 @@
import * as React from 'react';
import type { Animated } from 'react-native';
export const AnimatedHeaderHeightContext = React.createContext<
Animated.AnimatedInterpolation<number> | undefined
>(undefined);
export function useAnimatedHeaderHeight() {
const animatedValue = React.useContext(AnimatedHeaderHeightContext);
if (animatedValue === undefined) {
throw new Error(
"Couldn't find the header height. Are you inside a screen in a native stack navigator?"
);
}
return animatedValue;
}
@@ -0,0 +1,30 @@
import type {
ParamListBase,
StackNavigationState,
} from '@react-navigation/native';
import * as React from 'react';
export function useDismissedRouteError(
state: StackNavigationState<ParamListBase>
) {
const [nextDismissedKey, setNextDismissedKey] = React.useState<string | null>(
null
);
const dismissedRouteName = nextDismissedKey
? state.routes.find((route) => route.key === nextDismissedKey)?.name
: null;
React.useEffect(() => {
if (dismissedRouteName) {
const message =
`The screen '${dismissedRouteName}' was removed natively but didn't get removed from JS state. ` +
`This can happen if the action was prevented in a 'beforeRemove' listener, which is not fully supported in native-stack.\n\n` +
`Consider using a 'usePreventRemove' hook with 'headerBackButtonMenuEnabled: false' to prevent users from natively going back multiple screens.`;
console.error(message);
}
}, [dismissedRouteName]);
return { setNextDismissedKey };
}
@@ -0,0 +1,31 @@
import { usePreventRemoveContext } from '@react-navigation/native';
import * as React from 'react';
import type { NativeStackDescriptorMap } from '../types';
export function useInvalidPreventRemoveError(
descriptors: NativeStackDescriptorMap
) {
const { preventedRoutes } = usePreventRemoveContext();
const preventedRouteKey = Object.keys(preventedRoutes)[0];
const preventedDescriptor = descriptors[preventedRouteKey];
const isHeaderBackButtonMenuEnabledOnPreventedScreen =
preventedDescriptor?.options?.headerBackButtonMenuEnabled;
const preventedRouteName = preventedDescriptor?.route?.name;
React.useEffect(() => {
if (
preventedRouteKey != null &&
isHeaderBackButtonMenuEnabledOnPreventedScreen
) {
const message =
`The screen ${preventedRouteName} uses 'usePreventRemove' hook alongside 'headerBackButtonMenuEnabled: true', which is not supported. \n\n` +
`Consider removing 'headerBackButtonMenuEnabled: true' from ${preventedRouteName} screen to get rid of this error.`;
console.error(message);
}
}, [
preventedRouteKey,
isHeaderBackButtonMenuEnabledOnPreventedScreen,
preventedRouteName,
]);
}
@@ -0,0 +1,12 @@
// @ts-expect-error importing private module
import ReactNativeStyleAttributes from 'react-native/Libraries/Components/View/ReactNativeStyleAttributes';
export function processFonts(
fontFamilies: (string | undefined)[]
): (string | undefined)[] {
const fontFamilyProcessor = ReactNativeStyleAttributes.fontFamily?.process;
if (typeof fontFamilyProcessor === 'function') {
return fontFamilies.map(fontFamilyProcessor);
}
return fontFamilies;
}
@@ -0,0 +1,5 @@
export function processFonts(
_: (string | undefined)[]
): (string | undefined)[] {
throw new Error('Not supported on Web');
}
@@ -0,0 +1,10 @@
import React from 'react';
import { ScreenFooter } from 'react-native-screens';
type FooterProps = {
children?: React.ReactNode;
};
export function FooterComponent({ children }: FooterProps) {
return <ScreenFooter collapsable={false}>{children}</ScreenFooter>;
}
@@ -0,0 +1,659 @@
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,
sheetResizeAnimationEnabled = true,
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<number>(
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 (
<NavigationContext.Provider value={navigation}>
<NavigationRouteContext.Provider value={route}>
<ScreenStackItem
key={route.key}
screenId={route.key}
activityState={isPreloaded ? 0 : 2}
style={StyleSheet.absoluteFill}
aria-hidden={!focused}
customAnimationOnSwipe={animationMatchesGesture}
fullScreenSwipeEnabled={fullScreenGestureEnabled}
fullScreenSwipeShadowEnabled={fullScreenGestureShadowEnabled}
freezeOnBlur={freezeOnBlur}
gestureEnabled={
Platform.OS === 'android'
? // This prop enables handling of system back gestures on Android
// Since we handle them in JS side, we disable this
false
: gestureEnabled
}
homeIndicatorHidden={autoHideHomeIndicator}
hideKeyboardOnSwipe={keyboardHandlingEnabled}
navigationBarColor={navigationBarColor}
navigationBarTranslucent={navigationBarTranslucent}
navigationBarHidden={navigationBarHidden}
replaceAnimation={animationTypeForReplace}
stackPresentation={presentation === 'card' ? 'push' : presentation}
stackAnimation={animation}
screenOrientation={orientation}
sheetAllowedDetents={sheetAllowedDetents}
sheetLargestUndimmedDetentIndex={sheetLargestUndimmedDetentIndex}
sheetGrabberVisible={sheetGrabberVisible}
sheetInitialDetentIndex={sheetInitialDetentIndex}
sheetCornerRadius={sheetCornerRadius}
sheetElevation={sheetElevation}
sheetExpandsWhenScrolledToEdge={sheetExpandsWhenScrolledToEdge}
sheetShouldOverflowTopInset={sheetShouldOverflowTopInset}
sheetDefaultResizeAnimationEnabled={sheetResizeAnimationEnabled}
statusBarAnimation={statusBarAnimation}
statusBarHidden={statusBarHidden}
statusBarStyle={statusBarStyle}
statusBarColor={statusBarBackgroundColor}
statusBarTranslucent={statusBarTranslucent}
swipeDirection={gestureDirectionOverride}
transitionDuration={animationDuration}
onWillAppear={onWillAppear}
onWillDisappear={onWillDisappear}
onAppear={onAppear}
onDisappear={onDisappear}
onDismissed={onDismissed}
onGestureCancel={onGestureCancel}
onSheetDetentChanged={onSheetDetentChanged}
gestureResponseDistance={gestureResponseDistance}
nativeBackButtonDismissalEnabled={false} // on Android
onHeaderBackButtonClicked={onHeaderBackButtonClicked}
preventNativeDismiss={isRemovePrevented} // on iOS
scrollEdgeEffects={{
bottom: scrollEdgeEffects?.bottom ?? 'automatic',
top: scrollEdgeEffects?.top ?? 'automatic',
left: scrollEdgeEffects?.left ?? 'automatic',
right: scrollEdgeEffects?.right ?? 'automatic',
}}
onNativeDismissCancelled={onNativeDismissCancelled}
onHeaderHeightChange={onHeaderHeightChange}
contentStyle={[
presentation !== 'transparentModal' &&
presentation !== 'containedTransparentModal' && {
backgroundColor: colors.background,
},
contentStyle,
]}
headerConfig={headerConfig}
unstable_sheetFooter={unstable_sheetFooter}
// When ts-expect-error is added, it affects all the props below it
// So we keep any props that need it at the end
// Otherwise invalid props may not be caught by TypeScript
shouldFreeze={shouldFreeze}
>
<AnimatedHeaderHeightContext.Provider value={animatedHeaderHeight}>
<HeaderHeightContext.Provider
value={
headerShown !== false ? headerHeight : (parentHeaderHeight ?? 0)
}
>
{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)
*/
<View
style={[
styles.background,
headerTransparent ? styles.translucent : null,
{ height: headerHeight },
]}
>
{headerBackground()}
</View>
) : null}
{header != null && headerShown !== false ? (
<View
onLayout={(e) => {
const headerHeight = e.nativeEvent.layout.height;
setHeaderHeight(headerHeight);
rawAnimatedHeaderHeight.setValue(headerHeight);
}}
style={[
styles.header,
headerTransparent ? styles.absolute : null,
]}
>
{header({
back: headerBack,
options,
route,
navigation,
})}
</View>
) : null}
<HeaderShownContext.Provider
value={isParentHeaderShown || headerShown !== false}
>
<HeaderBackContext.Provider value={headerBack}>
{render()}
</HeaderBackContext.Provider>
</HeaderShownContext.Provider>
</HeaderHeightContext.Provider>
</AnimatedHeaderHeightContext.Provider>
</ScreenStackItem>
</NavigationRouteContext.Provider>
</NavigationContext.Provider>
);
};
type Props = {
state: StackNavigationState<ParamListBase>;
navigation: NativeStackNavigationHelpers;
descriptors: NativeStackDescriptorMap;
describe: (
route: RouteProp<ParamListBase>,
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<NativeStackDescriptorMap>((acc, route) => {
acc[route.key] = acc[route.key] || describe(route, true);
return acc;
}, {});
return (
<SafeAreaProviderCompat>
<ScreenStack style={styles.container}>
{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 (
<SceneView
key={route.key}
index={index}
focused={isFocused}
shouldFreeze={shouldFreeze}
descriptor={descriptor}
previousDescriptor={previousDescriptor}
nextDescriptor={nextDescriptor}
isPresentationModal={isModal}
isPreloaded={isPreloaded}
onWillDisappear={() => {
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,
},
});
}}
/>
);
})}
</ScreenStack>
</SafeAreaProviderCompat>
);
}
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',
},
});
@@ -0,0 +1,219 @@
import {
getHeaderTitle,
Header,
HeaderBackButton,
HeaderBackContext,
SafeAreaProviderCompat,
Screen,
useHeaderHeight,
} from '@react-navigation/elements';
import {
type ParamListBase,
type RouteProp,
type StackNavigationState,
useLinkBuilder,
} from '@react-navigation/native';
import * as React from 'react';
import { Animated, Image, StyleSheet, View } from 'react-native';
import type {
NativeStackDescriptor,
NativeStackDescriptorMap,
NativeStackNavigationHelpers,
} from '../types';
import { AnimatedHeaderHeightContext } from '../utils/useAnimatedHeaderHeight';
type Props = {
state: StackNavigationState<ParamListBase>;
// This is used for the native implementation of the stack.
navigation: NativeStackNavigationHelpers;
descriptors: NativeStackDescriptorMap;
describe: (
route: RouteProp<ParamListBase>,
placeholder: boolean
) => NativeStackDescriptor;
};
const TRANSPARENT_PRESENTATIONS = [
'transparentModal',
'containedTransparentModal',
];
export function NativeStackView({ state, descriptors, describe }: Props) {
const parentHeaderBack = React.useContext(HeaderBackContext);
const { buildHref } = useLinkBuilder();
const preloadedDescriptors =
state.preloadedRoutes.reduce<NativeStackDescriptorMap>((acc, route) => {
acc[route.key] = acc[route.key] || describe(route, true);
return acc;
}, {});
return (
<SafeAreaProviderCompat>
{state.routes.concat(state.preloadedRoutes).map((route, i) => {
const isFocused = state.index === i;
const previousKey = state.routes[i - 1]?.key;
const nextKey = state.routes[i + 1]?.key;
const previousDescriptor = previousKey
? descriptors[previousKey]
: undefined;
const nextDescriptor = nextKey ? descriptors[nextKey] : undefined;
const { options, navigation, render } =
descriptors[route.key] ?? preloadedDescriptors[route.key];
const headerBack = previousDescriptor
? {
title: getHeaderTitle(
previousDescriptor.options,
previousDescriptor.route.name
),
href: buildHref(
previousDescriptor.route.name,
previousDescriptor.route.params
),
}
: parentHeaderBack;
const canGoBack = headerBack != null;
const {
header,
headerShown,
headerBackIcon,
headerBackImageSource,
headerLeft,
headerTransparent,
headerBackTitle,
presentation,
contentStyle,
...rest
} = options;
const nextPresentation = nextDescriptor?.options.presentation;
const isPreloaded =
preloadedDescriptors[route.key] !== undefined &&
descriptors[route.key] === undefined;
return (
<Screen
key={route.key}
focused={isFocused}
route={route}
navigation={navigation}
headerShown={headerShown}
headerTransparent={headerTransparent}
header={
header !== undefined ? (
header({
back: headerBack,
options,
route,
navigation,
})
) : (
<Header
{...rest}
back={headerBack}
title={getHeaderTitle(options, route.name)}
headerLeft={
typeof headerLeft === 'function'
? ({ label, ...rest }) =>
headerLeft({
...rest,
label: headerBackTitle ?? label,
})
: headerLeft === undefined && canGoBack
? ({ tintColor, label, ...rest }) => (
<HeaderBackButton
{...rest}
label={headerBackTitle ?? label}
tintColor={tintColor}
backImage={
headerBackIcon !== undefined ||
headerBackImageSource !== undefined
? () => (
<Image
source={
headerBackIcon?.source ??
headerBackImageSource
}
resizeMode="contain"
tintColor={tintColor}
style={styles.backImage}
/>
)
: undefined
}
onPress={navigation.goBack}
/>
)
: headerLeft
}
headerTransparent={headerTransparent}
/>
)
}
style={[
StyleSheet.absoluteFill,
{
display:
(isFocused ||
(nextPresentation != null &&
TRANSPARENT_PRESENTATIONS.includes(nextPresentation))) &&
!isPreloaded
? 'flex'
: 'none',
},
presentation != null &&
TRANSPARENT_PRESENTATIONS.includes(presentation)
? { backgroundColor: 'transparent' }
: null,
]}
>
<HeaderBackContext.Provider value={headerBack}>
<AnimatedHeaderHeightProvider>
<View style={[styles.contentContainer, contentStyle]}>
{render()}
</View>
</AnimatedHeaderHeightProvider>
</HeaderBackContext.Provider>
</Screen>
);
})}
</SafeAreaProviderCompat>
);
}
const AnimatedHeaderHeightProvider = ({
children,
}: {
children: React.ReactNode;
}) => {
const headerHeight = useHeaderHeight();
const [animatedHeaderHeight] = React.useState(
() => new Animated.Value(headerHeight)
);
React.useEffect(() => {
animatedHeaderHeight.setValue(headerHeight);
}, [animatedHeaderHeight, headerHeight]);
return (
<AnimatedHeaderHeightContext.Provider value={animatedHeaderHeight}>
{children}
</AnimatedHeaderHeightContext.Provider>
);
};
const styles = StyleSheet.create({
contentContainer: {
flex: 1,
},
backImage: {
height: 24,
width: 24,
margin: 3,
},
});
@@ -0,0 +1,504 @@
import { getHeaderTitle, HeaderTitle } from '@react-navigation/elements';
import {
type Route,
type Theme,
useLocale,
useTheme,
} from '@react-navigation/native';
import color from 'color';
import { Platform, StyleSheet, type TextStyle, View } from 'react-native';
import {
type HeaderBarButtonItem,
type HeaderBarButtonItemMenuAction,
type HeaderBarButtonItemSubmenu,
isSearchBarAvailableForCurrentPlatform,
ScreenStackHeaderBackButtonImage,
ScreenStackHeaderCenterView,
type ScreenStackHeaderConfigProps,
ScreenStackHeaderLeftView,
ScreenStackHeaderRightView,
ScreenStackHeaderSearchBarView,
SearchBar,
} from 'react-native-screens';
import type {
NativeStackHeaderItem,
NativeStackHeaderItemMenuAction,
NativeStackHeaderItemMenuSubmenu,
NativeStackNavigationOptions,
} from '../types';
import { processFonts } from './FontProcessor';
type Props = NativeStackNavigationOptions & {
headerTopInsetEnabled: boolean;
headerHeight: number;
headerBack: { title?: string | undefined; href: undefined } | undefined;
route: Route<string>;
};
const processBarButtonItems = (
items: NativeStackHeaderItem[] | undefined,
colors: Theme['colors'],
fonts: Theme['fonts']
) => {
return items
?.map((item, index) => {
if (item.type === 'custom') {
// Handled with `ScreenStackHeaderLeftView` or `ScreenStackHeaderRightView`
return null;
}
if (item.type === 'spacing') {
if (item.spacing == null) {
throw new Error(
`Spacing item must have a 'spacing' property defined: ${JSON.stringify(
item
)}`
);
}
return item;
}
if (item.type === 'button' || item.type === 'menu') {
if (item.type === 'menu' && item.menu == null) {
throw new Error(
`Menu item must have a 'menu' property defined: ${JSON.stringify(
item
)}`
);
}
const { badge, label, labelStyle, icon, ...rest } = item;
let processedItem: HeaderBarButtonItem = {
...rest,
index,
title: label,
titleStyle: {
...fonts.regular,
...labelStyle,
},
icon:
icon?.type === 'image'
? icon.tinted === false
? {
type: 'imageSource',
imageSource: icon.source,
}
: {
type: 'templateSource',
templateSource: icon.source,
}
: icon,
};
if (processedItem.type === 'menu' && item.type === 'menu') {
const { multiselectable, layout } = item.menu;
processedItem = {
...processedItem,
menu: {
...processedItem.menu,
singleSelection: !multiselectable,
displayAsPalette: layout === 'palette',
items: item.menu.items.map(getMenuItem),
},
};
}
if (badge) {
const badgeBackgroundColor =
badge.style?.backgroundColor ?? colors.notification;
const badgeTextColor = color(badgeBackgroundColor).isLight()
? 'black'
: 'white';
processedItem = {
...processedItem,
badge: {
...badge,
value: String(badge.value),
style: {
backgroundColor: badgeBackgroundColor,
color: badgeTextColor,
...fonts.regular,
...badge.style,
},
},
};
}
return processedItem;
}
throw new Error(
`Invalid item type: ${JSON.stringify(item)}. Valid types are 'button', 'menu', 'custom' and 'spacing'.`
);
})
.filter((item) => item != null);
};
const getMenuItem = (
item: NativeStackHeaderItemMenuAction | NativeStackHeaderItemMenuSubmenu
): HeaderBarButtonItemMenuAction | HeaderBarButtonItemSubmenu => {
if (item.type === 'submenu') {
const { label, inline, layout, items, multiselectable, ...rest } = item;
return {
...rest,
title: label,
displayAsPalette: layout === 'palette',
displayInline: inline,
singleSelection: !multiselectable,
items: items.map(getMenuItem),
};
}
const { label, description, ...rest } = item;
return {
...rest,
title: label,
subtitle: description,
};
};
export function useHeaderConfigProps({
headerBackIcon,
headerBackImageSource,
headerBackButtonDisplayMode,
headerBackButtonMenuEnabled,
headerBackTitle,
headerBackTitleStyle,
headerBackVisible,
headerShadowVisible,
headerLargeStyle,
headerLargeTitle: headerLargeTitleDeprecated,
headerLargeTitleEnabled = headerLargeTitleDeprecated,
headerLargeTitleShadowVisible,
headerLargeTitleStyle,
headerBackground,
headerLeft,
headerRight,
headerShown,
headerStyle,
headerBlurEffect,
headerTintColor,
headerTitle,
headerTitleAlign,
headerTitleStyle,
headerTransparent,
headerSearchBarOptions,
headerTopInsetEnabled,
headerBack,
route,
title,
unstable_headerLeftItems: headerLeftItems,
unstable_headerRightItems: headerRightItems,
}: Props): ScreenStackHeaderConfigProps {
const { direction } = useLocale();
const { colors, fonts, dark } = useTheme();
const tintColor =
headerTintColor ?? (Platform.OS === 'ios' ? colors.primary : colors.text);
const headerBackTitleStyleFlattened =
StyleSheet.flatten([fonts.regular, headerBackTitleStyle]) || {};
const headerLargeTitleStyleFlattened =
StyleSheet.flatten([
Platform.select({ ios: fonts.heavy, default: fonts.medium }),
headerLargeTitleStyle,
]) || {};
const headerTitleStyleFlattened =
StyleSheet.flatten([
Platform.select({ ios: fonts.bold, default: fonts.medium }),
headerTitleStyle,
]) || {};
const headerStyleFlattened = StyleSheet.flatten(headerStyle) || {};
const headerLargeStyleFlattened = StyleSheet.flatten(headerLargeStyle) || {};
const [backTitleFontFamily, largeTitleFontFamily, titleFontFamily] =
processFonts([
headerBackTitleStyleFlattened.fontFamily,
headerLargeTitleStyleFlattened.fontFamily,
headerTitleStyleFlattened.fontFamily,
]);
const backTitleFontSize =
'fontSize' in headerBackTitleStyleFlattened
? headerBackTitleStyleFlattened.fontSize
: undefined;
const titleText = getHeaderTitle({ title, headerTitle }, route.name);
const titleColor =
'color' in headerTitleStyleFlattened
? headerTitleStyleFlattened.color
: (headerTintColor ?? colors.text);
const titleFontSize =
'fontSize' in headerTitleStyleFlattened
? headerTitleStyleFlattened.fontSize
: undefined;
const titleFontWeight = headerTitleStyleFlattened.fontWeight;
const largeTitleBackgroundColor = headerLargeStyleFlattened.backgroundColor;
const largeTitleColor =
'color' in headerLargeTitleStyleFlattened
? headerLargeTitleStyleFlattened.color
: undefined;
const largeTitleFontSize =
'fontSize' in headerLargeTitleStyleFlattened
? headerLargeTitleStyleFlattened.fontSize
: undefined;
const largeTitleFontWeight = headerLargeTitleStyleFlattened.fontWeight;
const headerTitleStyleSupported: TextStyle = { color: titleColor };
if (headerTitleStyleFlattened.fontFamily != null) {
headerTitleStyleSupported.fontFamily = headerTitleStyleFlattened.fontFamily;
}
if (titleFontSize != null) {
headerTitleStyleSupported.fontSize = titleFontSize;
}
if (titleFontWeight != null) {
headerTitleStyleSupported.fontWeight = titleFontWeight;
}
const headerBackgroundColor =
headerStyleFlattened.backgroundColor ??
(headerBackground != null ||
headerTransparent ||
// The title becomes invisible if background color is set with large title on iOS 26
(Platform.OS === 'ios' && headerLargeTitleEnabled)
? 'transparent'
: colors.card);
const canGoBack = headerBack != null;
const headerLeftElement = headerLeft?.({
tintColor,
canGoBack,
label: headerBackTitle ?? headerBack?.title,
// `href` is only applicable to web
href: undefined,
});
const headerRightElement = headerRight?.({
tintColor,
canGoBack,
});
const headerTitleElement =
typeof headerTitle === 'function'
? headerTitle({
tintColor,
children: titleText,
})
: null;
const supportsHeaderSearchBar =
typeof isSearchBarAvailableForCurrentPlatform === 'boolean'
? isSearchBarAvailableForCurrentPlatform
: // Fallback for older versions of react-native-screens
Platform.OS === 'ios' && SearchBar != null;
const hasHeaderSearchBar =
supportsHeaderSearchBar && headerSearchBarOptions != null;
/**
* We need to set this in if:
* - Back button should stay visible when `headerLeft` is specified
* - If `headerTitle` for Android is specified, so we only need to remove the title and keep the back button
*/
const backButtonInCustomView =
headerBackVisible ||
(Platform.OS === 'android' &&
headerTitleElement != null &&
headerLeftElement == null);
const translucent =
headerBackground != null ||
headerTransparent ||
// When using a SearchBar or large title, the header needs to be translucent for it to work on iOS
((hasHeaderSearchBar || headerLargeTitleEnabled) &&
Platform.OS === 'ios' &&
headerTransparent !== false);
const isBackButtonDisplayModeAvailable =
// On iOS 14+
Platform.OS === 'ios' &&
parseInt(Platform.Version, 10) >= 14 &&
// Doesn't have custom styling, by default System, see: https://github.com/software-mansion/react-native-screens/pull/2105#discussion_r1565222738
(backTitleFontFamily == null || backTitleFontFamily === 'System') &&
backTitleFontSize == null &&
// Back button menu is not disabled
headerBackButtonMenuEnabled !== false;
const isCenterViewRenderedAndroid = headerTitleAlign === 'center';
const leftItems = headerLeftItems?.({
tintColor,
canGoBack,
});
let rightItems = headerRightItems?.({
tintColor,
canGoBack,
});
if (rightItems) {
// iOS renders right items in reverse order
// So we need to reverse them here to match the order
rightItems = [...rightItems].reverse();
}
const children = (
<>
{Platform.OS === 'ios' ? (
<>
{leftItems ? (
leftItems.map((item, index) => {
if (item.type === 'custom') {
return (
<ScreenStackHeaderLeftView
// eslint-disable-next-line @eslint-react/no-array-index-key
key={index}
hidesSharedBackground={item.hidesSharedBackground}
>
{item.element}
</ScreenStackHeaderLeftView>
);
}
return null;
})
) : headerLeftElement != null ? (
<ScreenStackHeaderLeftView>
{headerLeftElement}
</ScreenStackHeaderLeftView>
) : null}
{headerTitleElement != null ? (
<ScreenStackHeaderCenterView>
{headerTitleElement}
</ScreenStackHeaderCenterView>
) : null}
</>
) : (
<>
{headerLeftElement != null || typeof headerTitle === 'function' ? (
// The style passed to header left, together with title element being wrapped
// in flex view is reqruied for proper header layout, in particular,
// for the text truncation to work.
<ScreenStackHeaderLeftView
style={!isCenterViewRenderedAndroid ? { flex: 1 } : null}
>
{headerLeftElement}
{headerTitleAlign !== 'center' ? (
typeof headerTitle === 'function' ? (
<View style={{ flex: 1 }}>{headerTitleElement}</View>
) : (
<View style={{ flex: 1 }}>
<HeaderTitle
tintColor={tintColor}
style={headerTitleStyleSupported}
>
{titleText}
</HeaderTitle>
</View>
)
) : null}
</ScreenStackHeaderLeftView>
) : null}
{isCenterViewRenderedAndroid ? (
<ScreenStackHeaderCenterView>
{typeof headerTitle === 'function' ? (
headerTitleElement
) : (
<HeaderTitle
tintColor={tintColor}
style={headerTitleStyleSupported}
>
{titleText}
</HeaderTitle>
)}
</ScreenStackHeaderCenterView>
) : null}
</>
)}
{headerBackIcon !== undefined || headerBackImageSource !== undefined ? (
<ScreenStackHeaderBackButtonImage
source={headerBackIcon?.source ?? headerBackImageSource}
/>
) : null}
{Platform.OS === 'ios' && rightItems ? (
rightItems.map((item, index) => {
if (item.type === 'custom') {
return (
<ScreenStackHeaderRightView
// eslint-disable-next-line @eslint-react/no-array-index-key
key={index}
hidesSharedBackground={item.hidesSharedBackground}
>
{item.element}
</ScreenStackHeaderRightView>
);
}
return null;
})
) : headerRightElement != null ? (
<ScreenStackHeaderRightView>
{headerRightElement}
</ScreenStackHeaderRightView>
) : null}
{hasHeaderSearchBar ? (
<ScreenStackHeaderSearchBarView>
<SearchBar {...headerSearchBarOptions} />
</ScreenStackHeaderSearchBarView>
) : null}
</>
);
return {
backButtonInCustomView,
backgroundColor: headerBackgroundColor,
backTitle: headerBackTitle,
backTitleVisible: isBackButtonDisplayModeAvailable
? undefined
: headerBackButtonDisplayMode !== 'minimal',
backButtonDisplayMode: isBackButtonDisplayModeAvailable
? headerBackButtonDisplayMode
: undefined,
backTitleFontFamily,
backTitleFontSize,
blurEffect: headerBlurEffect,
color: tintColor,
direction,
disableBackButtonMenu: headerBackButtonMenuEnabled === false,
hidden: headerShown === false,
hideBackButton: headerBackVisible === false,
hideShadow:
headerShadowVisible === false ||
headerBackground != null ||
(headerTransparent && headerShadowVisible !== true),
largeTitle: headerLargeTitleEnabled,
largeTitleBackgroundColor,
largeTitleColor,
largeTitleFontFamily,
largeTitleFontSize,
largeTitleFontWeight,
largeTitleHideShadow: headerLargeTitleShadowVisible === false,
title: titleText,
titleColor,
titleFontFamily,
titleFontSize,
titleFontWeight: String(titleFontWeight),
topInsetEnabled: headerTopInsetEnabled,
translucent: translucent === true,
children,
headerLeftBarButtonItems: processBarButtonItems(leftItems, colors, fonts),
headerRightBarButtonItems: processBarButtonItems(rightItems, colors, fonts),
experimental_userInterfaceStyle: dark ? 'dark' : 'light',
} as const;
}