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,44 @@
import type {
BottomTabSceneInterpolatedStyle,
BottomTabSceneInterpolationProps,
} from '../types';
/**
* Simple cross fade animation
*/
export function forFade({
current,
}: BottomTabSceneInterpolationProps): BottomTabSceneInterpolatedStyle {
return {
sceneStyle: {
opacity: current.progress.interpolate({
inputRange: [-1, 0, 1],
outputRange: [0, 1, 0],
}),
},
};
}
/**
* Animation where the screens slightly shift to left/right
*/
export function forShift({
current,
}: BottomTabSceneInterpolationProps): BottomTabSceneInterpolatedStyle {
return {
sceneStyle: {
opacity: current.progress.interpolate({
inputRange: [-1, 0, 1],
outputRange: [0, 1, 0],
}),
transform: [
{
translateX: current.progress.interpolate({
inputRange: [-1, 0, 1],
outputRange: [-50, 0, 50],
}),
},
],
},
};
}
@@ -0,0 +1,13 @@
import type { BottomTabTransitionPreset } from '../types';
import { forFade, forShift } from './SceneStyleInterpolators';
import { FadeSpec, ShiftSpec } from './TransitionSpecs';
export const FadeTransition: BottomTabTransitionPreset = {
transitionSpec: FadeSpec,
sceneStyleInterpolator: forFade,
};
export const ShiftTransition: BottomTabTransitionPreset = {
transitionSpec: ShiftSpec,
sceneStyleInterpolator: forShift,
};
@@ -0,0 +1,19 @@
import { Easing } from 'react-native';
import type { TransitionSpec } from '../types';
export const FadeSpec: TransitionSpec = {
animation: 'timing',
config: {
duration: 150,
easing: Easing.in(Easing.linear),
},
};
export const ShiftSpec: TransitionSpec = {
animation: 'timing',
config: {
duration: 150,
easing: Easing.inOut(Easing.ease),
},
};
@@ -0,0 +1,41 @@
import * as SceneStyleInterpolators from './TransitionConfigs/SceneStyleInterpolators';
import * as TransitionPresets from './TransitionConfigs/TransitionPresets';
import * as TransitionSpecs from './TransitionConfigs/TransitionSpecs';
/**
* Transition Presets
*/
export { SceneStyleInterpolators, TransitionPresets, TransitionSpecs };
/**
* Navigators
*/
export { createBottomTabNavigator } from './navigators/createBottomTabNavigator';
/**
* Views
*/
export { BottomTabBar } from './views/BottomTabBar';
export { BottomTabView } from './views/BottomTabView';
/**
* Utilities
*/
export { BottomTabBarHeightCallbackContext } from './utils/BottomTabBarHeightCallbackContext';
export { BottomTabBarHeightContext } from './utils/BottomTabBarHeightContext';
export { useBottomTabBarHeight } from './utils/useBottomTabBarHeight';
/**
* Types
*/
export type {
BottomTabBarButtonProps,
BottomTabBarProps,
BottomTabHeaderProps,
BottomTabNavigationEventMap,
BottomTabNavigationOptions,
BottomTabNavigationProp,
BottomTabNavigatorProps,
BottomTabOptionsArgs,
BottomTabScreenProps,
} from './types';
@@ -0,0 +1,88 @@
import {
createNavigatorFactory,
type NavigatorTypeBagBase,
type ParamListBase,
type StaticConfig,
type TabActionHelpers,
type TabNavigationState,
TabRouter,
type TabRouterOptions,
type TypedNavigator,
useNavigationBuilder,
} from '@react-navigation/native';
import type {
BottomTabNavigationEventMap,
BottomTabNavigationOptions,
BottomTabNavigationProp,
BottomTabNavigatorProps,
} from '../types';
import { BottomTabView } from '../views/BottomTabView';
function BottomTabNavigator({
id,
initialRouteName,
backBehavior,
UNSTABLE_routeNamesChangeBehavior,
children,
layout,
screenListeners,
screenOptions,
screenLayout,
UNSTABLE_router,
...rest
}: BottomTabNavigatorProps) {
const { state, descriptors, navigation, NavigationContent } =
useNavigationBuilder<
TabNavigationState<ParamListBase>,
TabRouterOptions,
TabActionHelpers<ParamListBase>,
BottomTabNavigationOptions,
BottomTabNavigationEventMap
>(TabRouter, {
id,
initialRouteName,
backBehavior,
UNSTABLE_routeNamesChangeBehavior,
children,
layout,
screenListeners,
screenOptions,
screenLayout,
UNSTABLE_router,
});
return (
<NavigationContent>
<BottomTabView
{...rest}
state={state}
navigation={navigation}
descriptors={descriptors}
/>
</NavigationContent>
);
}
export function createBottomTabNavigator<
const ParamList extends ParamListBase,
const NavigatorID extends string | undefined = string | undefined,
const TypeBag extends NavigatorTypeBagBase = {
ParamList: ParamList;
NavigatorID: NavigatorID;
State: TabNavigationState<ParamList>;
ScreenOptions: BottomTabNavigationOptions;
EventMap: BottomTabNavigationEventMap;
NavigationList: {
[RouteName in keyof ParamList]: BottomTabNavigationProp<
ParamList,
RouteName,
NavigatorID
>;
};
Navigator: typeof BottomTabNavigator;
},
const Config extends StaticConfig<TypeBag> = StaticConfig<TypeBag>,
>(config?: Config): TypedNavigator<TypeBag, Config> {
return createNavigatorFactory(BottomTabNavigator)(config);
}
+456
View File
@@ -0,0 +1,456 @@
import type {
HeaderOptions,
PlatformPressable,
} from '@react-navigation/elements';
import type {
DefaultNavigatorOptions,
Descriptor,
NavigationHelpers,
NavigationProp,
ParamListBase,
RouteProp,
TabActionHelpers,
TabNavigationState,
TabRouterOptions,
Theme,
} from '@react-navigation/native';
import type * as React from 'react';
import type {
Animated,
GestureResponderEvent,
StyleProp,
TextStyle,
ViewStyle,
} from 'react-native';
import type { EdgeInsets } from 'react-native-safe-area-context';
export type Layout = { width: number; height: number };
export type Variant = 'uikit' | 'material';
export type BottomTabNavigationEventMap = {
/**
* Event which fires on tapping on the tab in the tab bar.
*/
tabPress: { data: undefined; canPreventDefault: true };
/**
* Event which fires on long press on the tab in the tab bar.
*/
tabLongPress: { data: undefined };
/**
* Event which fires when a transition animation starts.
*/
transitionStart: { data: undefined };
/**
* Event which fires when a transition animation ends.
*/
transitionEnd: { data: undefined };
};
export type LabelPosition = 'beside-icon' | 'below-icon';
export type BottomTabNavigationHelpers = NavigationHelpers<
ParamListBase,
BottomTabNavigationEventMap
> &
TabActionHelpers<ParamListBase>;
export type BottomTabNavigationProp<
ParamList extends ParamListBase,
RouteName extends keyof ParamList = keyof ParamList,
NavigatorID extends string | undefined = undefined,
> = NavigationProp<
ParamList,
RouteName,
NavigatorID,
TabNavigationState<ParamList>,
BottomTabNavigationOptions,
BottomTabNavigationEventMap
> &
TabActionHelpers<ParamList>;
export type BottomTabScreenProps<
ParamList extends ParamListBase,
RouteName extends keyof ParamList = keyof ParamList,
NavigatorID extends string | undefined = undefined,
> = {
navigation: BottomTabNavigationProp<ParamList, RouteName, NavigatorID>;
route: RouteProp<ParamList, RouteName>;
};
export type BottomTabOptionsArgs<
ParamList extends ParamListBase,
RouteName extends keyof ParamList = keyof ParamList,
NavigatorID extends string | undefined = undefined,
> = BottomTabScreenProps<ParamList, RouteName, NavigatorID> & {
theme: Theme;
};
export type TimingKeyboardAnimationConfig = {
animation: 'timing';
config?: Omit<
Partial<Animated.TimingAnimationConfig>,
'toValue' | 'useNativeDriver'
>;
};
export type SpringKeyboardAnimationConfig = {
animation: 'spring';
config?: Omit<
Partial<Animated.SpringAnimationConfig>,
'toValue' | 'useNativeDriver'
>;
};
export type TabBarVisibilityAnimationConfig =
| TimingKeyboardAnimationConfig
| SpringKeyboardAnimationConfig;
export type TabAnimationName = 'none' | 'fade' | 'shift';
export type BottomTabNavigationOptions = HeaderOptions & {
/**
* Title text for the screen.
*/
title?: string;
/**
* Title string of a tab displayed in the tab bar
* or a function that given { focused: boolean, color: string, position: 'below-icon' | 'beside-icon', children: string } returns a React.Node to display in tab bar.
*
* When undefined, scene title is used. Use `tabBarShowLabel` to hide the label.
*/
tabBarLabel?:
| string
| ((props: {
focused: boolean;
color: string;
position: LabelPosition;
children: string;
}) => React.ReactNode);
/**
* Whether the tab label should be visible. Defaults to `true`.
*/
tabBarShowLabel?: boolean;
/**
* Whether the label is shown below the icon or beside the icon.
*
* - `below-icon`: the label is shown below the icon (typical for iPhones)
* - `beside-icon` the label is shown next to the icon (typical for iPad)
*
* By default, the position is chosen automatically based on device width.
*/
tabBarLabelPosition?: LabelPosition;
/**
* Style object for the tab label.
*/
tabBarLabelStyle?: StyleProp<TextStyle>;
/**
* Whether label font should scale to respect Text Size accessibility settings.
*/
tabBarAllowFontScaling?: boolean;
/**
* A function that given { focused: boolean, color: string } returns a React.Node to display in the tab bar.
*/
tabBarIcon?: (props: {
focused: boolean;
color: string;
size: number;
}) => React.ReactNode;
/**
* Style object for the tab icon.
*/
tabBarIconStyle?: StyleProp<TextStyle>;
/**
* Text to show in a badge on the tab icon.
*/
tabBarBadge?: number | string;
/**
* Custom style for the tab bar badge.
* You can specify a background color or text color here.
*/
tabBarBadgeStyle?: StyleProp<TextStyle>;
/**
* Accessibility label for the tab button. This is read by the screen reader when the user taps the tab.
* It's recommended to set this if you don't have a label for the tab.
*/
tabBarAccessibilityLabel?: string;
/**
* ID to locate this tab button in tests.
*/
tabBarButtonTestID?: string;
/**
* Function which returns a React element to render as the tab bar button.
* Renders `PlatformPressable` by default.
*/
tabBarButton?: (props: BottomTabBarButtonProps) => React.ReactNode;
/**
* Color for the icon and label in the active tab.
*/
tabBarActiveTintColor?: string;
/**
* Color for the icon and label in the inactive tabs.
*/
tabBarInactiveTintColor?: string;
/**
* Background color for the active tab.
*/
tabBarActiveBackgroundColor?: string;
/**
* Background color for the inactive tabs.
*/
tabBarInactiveBackgroundColor?: string;
/**
* Style object for the tab item container.
*/
tabBarItemStyle?: StyleProp<ViewStyle>;
/**
* Whether the tab bar gets hidden when the keyboard is shown. Defaults to `false`.
*/
tabBarHideOnKeyboard?: boolean;
/**
* Animation config for showing and hiding the tab bar when the keyboard is shown/hidden.
*/
tabBarVisibilityAnimationConfig?: {
show?: TabBarVisibilityAnimationConfig;
hide?: TabBarVisibilityAnimationConfig;
};
/**
* Variant of the tab bar. Defaults to `uikit`.
*/
tabBarVariant?: Variant;
/**
* Style object for the tab bar container.
*/
tabBarStyle?: Animated.WithAnimatedValue<StyleProp<ViewStyle>>;
/**
* Function which returns a React Element to use as background for the tab bar.
* You could render an image, a gradient, blur view etc.
*
* When using `BlurView`, make sure to set `position: 'absolute'` in `tabBarStyle` as well.
* You'd also need to use `useBottomTabBarHeight()` to add a bottom padding to your content.
*/
tabBarBackground?: () => React.ReactNode;
/**
* Position of the tab bar on the screen. Defaults to `bottom`.
*/
tabBarPosition?: 'bottom' | 'left' | 'right' | 'top';
/**
* Whether this screens should render the first time it's accessed. Defaults to `true`.
* Set it to `false` if you want to render the screen on initial render.
*/
lazy?: boolean;
/**
* Function that given returns a React Element to display as a header.
*/
header?: (props: BottomTabHeaderProps) => React.ReactNode;
/**
* Whether to show the header. Setting this to `false` hides the header.
* Defaults to `true`.
*/
headerShown?: boolean;
/**
* Whether any nested stack should be popped to top when navigating away from the tab.
* Defaults to `false`.
*/
popToTopOnBlur?: boolean;
/**
* Whether inactive screens should be suspended from re-rendering. Defaults to `false`.
* Defaults to `true` when `enableFreeze()` is run at the top of the application.
* Requires `react-native-screens` version >=3.16.0.
*
* Only supported on iOS and Android.
*/
freezeOnBlur?: boolean;
/**
* Style object for the component wrapping the screen content.
*/
sceneStyle?: StyleProp<ViewStyle>;
/**
* How the screen should animate when switching tabs.
*
* Supported values:
* - 'none': don't animate the screen (default)
* - 'fade': cross-fade the screens.
* - 'shift': shift the screens slightly shift to left/right.
*/
animation?: TabAnimationName;
/**
* Function which specifies interpolated styles for bottom-tab scenes.
*/
sceneStyleInterpolator?: BottomTabSceneStyleInterpolator;
/**
* Object which specifies the animation type (timing or spring) and their options (such as duration for timing).
*/
transitionSpec?: TransitionSpec;
};
export type BottomTabDescriptor = Descriptor<
BottomTabNavigationOptions,
BottomTabNavigationProp<ParamListBase>,
RouteProp<ParamListBase>
>;
export type BottomTabDescriptorMap = Record<string, BottomTabDescriptor>;
export type BottomTabSceneInterpolationProps = {
/**
* Values for the current screen.
*/
current: {
/**
* Animated value for the current screen:
* - -1 if the index is lower than active tab,
* - 0 if they're active,
* - 1 if the index is higher than active tab
*/
progress: Animated.Value;
};
};
export type BottomTabSceneInterpolatedStyle = {
/**
* Interpolated style for the view representing the scene containing screen content.
*/
sceneStyle: Animated.WithAnimatedValue<StyleProp<ViewStyle>>;
};
export type BottomTabSceneStyleInterpolator = (
props: BottomTabSceneInterpolationProps
) => BottomTabSceneInterpolatedStyle;
export type TransitionSpec =
| {
animation: 'timing';
config: Omit<
Animated.TimingAnimationConfig,
'toValue' | keyof Animated.AnimationConfig
>;
}
| {
animation: 'spring';
config: Omit<
Animated.SpringAnimationConfig,
'toValue' | keyof Animated.AnimationConfig
>;
};
export type BottomTabTransitionPreset = {
/**
* Whether transition animations should be enabled when switching tabs.
*/
animationEnabled?: boolean;
/**
* Function which specifies interpolated styles for bottom-tab scenes.
*/
sceneStyleInterpolator?: BottomTabSceneStyleInterpolator;
/**
* Object which specifies the animation type (timing or spring) and their options (such as duration for timing).
*/
transitionSpec?: TransitionSpec;
};
export type BottomTabNavigationConfig = {
/**
* Function that returns a React element to display as the tab bar.
*/
tabBar?: (props: BottomTabBarProps) => React.ReactNode;
/**
* Safe area insets for the tab bar. This is used to avoid elements like the navigation bar on Android and bottom safe area on iOS.
* By default, the device's safe area insets are automatically detected. You can override the behavior with this option.
*/
safeAreaInsets?: {
top?: number;
right?: number;
bottom?: number;
left?: number;
};
/**
* Whether inactive screens should be detached from the view hierarchy to save memory.
* Make sure to call `enableScreens` from `react-native-screens` to make it work.
* Defaults to `true` on Android.
*/
detachInactiveScreens?: boolean;
};
export type BottomTabHeaderProps = {
/**
* Layout of the screen.
*/
layout: Layout;
/**
* Options for the current screen.
*/
options: BottomTabNavigationOptions;
/**
* Route object for the current screen.
*/
route: RouteProp<ParamListBase>;
/**
* Navigation prop for the header.
*/
navigation: BottomTabNavigationProp<ParamListBase>;
};
export type BottomTabBarProps = {
state: TabNavigationState<ParamListBase>;
descriptors: BottomTabDescriptorMap;
navigation: NavigationHelpers<ParamListBase, BottomTabNavigationEventMap>;
insets: EdgeInsets;
};
export type BottomTabBarButtonProps = Omit<
React.ComponentProps<typeof PlatformPressable>,
'style'
> & {
href?: string;
children: React.ReactNode;
style?: StyleProp<ViewStyle>;
onPress?: (
e: React.MouseEvent<HTMLAnchorElement, MouseEvent> | GestureResponderEvent
) => void;
};
export type BottomTabNavigatorProps = DefaultNavigatorOptions<
ParamListBase,
string | undefined,
TabNavigationState<ParamListBase>,
BottomTabNavigationOptions,
BottomTabNavigationEventMap,
BottomTabNavigationProp<ParamListBase>
> &
TabRouterOptions &
BottomTabNavigationConfig;
@@ -0,0 +1,396 @@
import {
getLabel,
Lazy,
SafeAreaProviderCompat,
Screen as ScreenContent,
} from '@react-navigation/elements';
import {
CommonActions,
NavigationMetaContext,
type ParamListBase,
type Route,
StackActions,
type TabNavigationState,
useTheme,
} from '@react-navigation/native';
import Color from 'color';
import * as React from 'react';
import { type ColorValue, Platform, PlatformColor } from 'react-native';
import {
type PlatformIcon,
Tabs,
type TabsScreenItemStateAppearance,
} from 'react-native-screens';
import { NativeScreen } from './NativeScreen/NativeScreen';
import type {
NativeBottomTabDescriptorMap,
NativeBottomTabIcon,
NativeBottomTabNavigationConfig,
NativeBottomTabNavigationHelpers,
NativeBottomTabNavigationOptions,
NativeBottomTabNavigationProp,
} from './types';
type Props = NativeBottomTabNavigationConfig & {
state: TabNavigationState<ParamListBase>;
navigation: NativeBottomTabNavigationHelpers;
descriptors: NativeBottomTabDescriptorMap;
};
const meta = {
type: 'native-tabs',
};
export function NativeBottomTabView({ state, navigation, descriptors }: Props) {
const { dark, colors, fonts } = useTheme();
const focusedRouteKey = state.routes[state.index].key;
const previousRouteKeyRef = React.useRef(focusedRouteKey);
React.useEffect(() => {
const previousRouteKey = previousRouteKeyRef.current;
if (
previousRouteKey !== focusedRouteKey &&
descriptors[previousRouteKey]?.options.popToTopOnBlur
) {
const prevRoute = state.routes.find(
(route) => route.key === previousRouteKey
);
if (prevRoute?.state?.type === 'stack' && prevRoute.state.key) {
const popToTopAction = {
...StackActions.popToTop(),
target: prevRoute.state.key,
};
navigation.dispatch(popToTopAction);
}
}
previousRouteKeyRef.current = focusedRouteKey;
}, [descriptors, focusedRouteKey, navigation, state.index, state.routes]);
const currentOptions = descriptors[state.routes[state.index].key]?.options;
const {
fontFamily = Platform.select({
ios: fonts.medium.fontFamily,
default: fonts.regular.fontFamily,
}),
fontWeight = Platform.select({
ios: fonts.medium.fontWeight,
default: fonts.regular.fontWeight,
}),
fontSize,
fontStyle,
} = currentOptions.tabBarLabelStyle || {};
const activeTintColor =
currentOptions.tabBarActiveTintColor ?? colors.primary;
const inactiveTintColor =
currentOptions.tabBarInactiveTintColor ??
Platform.select<ColorValue | string>({
ios: PlatformColor('label'),
default: colors.text,
});
const activeIndicatorColor =
(currentOptions?.tabBarActiveIndicatorColor ??
typeof activeTintColor === 'string')
? Color(activeTintColor)?.alpha(0.1).string()
: undefined;
const onTransitionStart = ({
closing,
route,
}: {
closing: boolean;
route: Route<string>;
}) => {
navigation.emit({
type: 'transitionStart',
data: { closing },
target: route.key,
});
};
const onTransitionEnd = ({
closing,
route,
}: {
closing: boolean;
route: Route<string>;
}) => {
navigation.emit({
type: 'transitionEnd',
data: { closing },
target: route.key,
});
};
const tabBarControllerMode =
currentOptions.tabBarControllerMode === 'auto'
? 'automatic'
: currentOptions.tabBarControllerMode;
const tabBarMinimizeBehavior =
currentOptions.tabBarMinimizeBehavior === 'auto'
? 'automatic'
: currentOptions.tabBarMinimizeBehavior;
const bottomAccessory = currentOptions.bottomAccessory;
return (
<SafeAreaProviderCompat>
<Tabs.Host
bottomAccessory={
bottomAccessory
? (environment) => bottomAccessory({ placement: environment })
: undefined
}
tabBarItemLabelVisibilityMode={
currentOptions?.tabBarLabelVisibilityMode
}
tabBarControllerMode={tabBarControllerMode}
tabBarMinimizeBehavior={tabBarMinimizeBehavior}
tabBarTintColor={activeTintColor}
tabBarItemIconColor={inactiveTintColor}
tabBarItemIconColorActive={activeTintColor}
tabBarItemTitleFontColor={inactiveTintColor}
tabBarItemTitleFontColorActive={activeTintColor}
tabBarItemTitleFontFamily={fontFamily}
tabBarItemTitleFontWeight={fontWeight}
tabBarItemTitleFontSize={fontSize}
tabBarItemTitleFontSizeActive={fontSize}
tabBarItemTitleFontStyle={fontStyle}
tabBarBackgroundColor={
currentOptions.tabBarStyle?.backgroundColor ?? colors.card
}
tabBarItemActiveIndicatorColor={activeIndicatorColor}
tabBarItemActiveIndicatorEnabled={
currentOptions?.tabBarActiveIndicatorEnabled
}
tabBarItemRippleColor={currentOptions?.tabBarRippleColor}
experimentalControlNavigationStateInJS={false}
onNativeFocusChange={(e) => {
const route = state.routes.find(
(route) => route.key === e.nativeEvent.tabKey
);
if (route) {
navigation.emit({
type: 'tabPress',
target: route.key,
});
const isFocused =
state.index ===
state.routes.findIndex((r) => r.key === route.key);
if (!isFocused) {
navigation.dispatch({
...CommonActions.navigate(route.name, route.params),
target: state.key,
});
}
}
}}
>
{state.routes.map((route, index) => {
const { options, render, navigation } = descriptors[route.key];
const isFocused = state.index === index;
const isPreloaded = state.preloadedRouteKeys.includes(route.key);
const {
title,
lazy = true,
tabBarLabel,
tabBarBadgeStyle,
tabBarIcon,
tabBarBadge,
tabBarSystemItem,
tabBarBlurEffect = dark ? 'systemMaterialDark' : 'systemMaterial',
tabBarStyle,
} = options;
const {
backgroundColor: tabBarBackgroundColor,
shadowColor: tabBarShadowColor,
} = tabBarStyle || {};
const tabTitle =
// On iOS, `systemItem` already provides a localized label
// So we should only use `tabBarLabel` if explicitly provided
Platform.OS === 'ios' && tabBarSystemItem != null
? tabBarLabel
: getLabel({ label: tabBarLabel, title }, route.name);
const tabItemAppearance: TabsScreenItemStateAppearance = {
tabBarItemTitleFontFamily: fontFamily,
tabBarItemTitleFontSize: fontSize,
tabBarItemTitleFontWeight: fontWeight,
tabBarItemTitleFontStyle: fontStyle,
};
const badgeBackgroundColor =
tabBarBadgeStyle?.backgroundColor ?? colors.notification;
const badgeTextColor =
tabBarBadgeStyle?.color ??
(typeof badgeBackgroundColor === 'string'
? Color(badgeBackgroundColor).isLight()
? 'black'
: 'white'
: undefined);
const icon =
typeof tabBarIcon === 'function'
? getPlatformIcon(tabBarIcon({ focused: false }))
: tabBarIcon != null
? getPlatformIcon(tabBarIcon)
: undefined;
const selectedIcon =
typeof tabBarIcon === 'function'
? getPlatformIcon(tabBarIcon({ focused: true }))
: undefined;
return (
<Tabs.Screen
onWillDisappear={() =>
onTransitionStart({ closing: true, route })
}
onWillAppear={() => onTransitionStart({ closing: false, route })}
onDidAppear={() => onTransitionEnd({ closing: false, route })}
onDidDisappear={() => onTransitionEnd({ closing: true, route })}
key={route.key}
tabKey={route.key}
icon={icon}
selectedIcon={selectedIcon?.ios ?? selectedIcon?.shared}
tabBarItemBadgeBackgroundColor={badgeBackgroundColor}
tabBarItemBadgeTextColor={badgeTextColor}
badgeValue={tabBarBadge?.toString()}
systemItem={tabBarSystemItem}
isFocused={isFocused}
title={tabTitle}
standardAppearance={{
tabBarBackgroundColor,
tabBarShadowColor,
tabBarBlurEffect,
stacked: {
normal: tabItemAppearance,
},
inline: {
normal: tabItemAppearance,
},
compactInline: {
normal: tabItemAppearance,
},
}}
specialEffects={{
repeatedTabSelection: {
popToRoot: true,
scrollToTop: true,
},
}}
experimental_userInterfaceStyle={dark ? 'dark' : 'light'}
>
<Lazy enabled={lazy} visible={isFocused || isPreloaded}>
<ScreenWithHeader
isFocused={isFocused}
route={route}
navigation={navigation}
options={options}
>
<NavigationMetaContext.Provider value={meta}>
{render()}
</NavigationMetaContext.Provider>
</ScreenWithHeader>
</Lazy>
</Tabs.Screen>
);
})}
</Tabs.Host>
</SafeAreaProviderCompat>
);
}
function ScreenWithHeader({
isFocused,
route,
navigation,
options,
children,
}: {
isFocused: boolean;
route: Route<string>;
navigation: NativeBottomTabNavigationProp<ParamListBase>;
options: NativeBottomTabNavigationOptions;
children: React.ReactNode;
}) {
const {
headerTransparent,
header: renderCustomHeader,
headerShown = renderCustomHeader != null,
} = options;
const hasNativeHeader = headerShown && renderCustomHeader == null;
const [wasNativeHeaderShown] = React.useState(hasNativeHeader);
React.useEffect(() => {
if (wasNativeHeaderShown !== hasNativeHeader) {
throw new Error(
`Changing 'headerShown' or 'header' options dynamically is not supported when using native header.`
);
}
}, [wasNativeHeaderShown, hasNativeHeader]);
if (hasNativeHeader) {
return (
<NativeScreen route={route} navigation={navigation} options={options}>
{children}
</NativeScreen>
);
}
return (
<ScreenContent
focused={isFocused}
route={route}
navigation={navigation}
headerShown={headerShown}
headerTransparent={headerTransparent}
header={renderCustomHeader?.({
route,
navigation,
options,
})}
>
{children}
</ScreenContent>
);
}
function getPlatformIcon(icon: NativeBottomTabIcon): PlatformIcon {
return {
ios:
icon?.type === 'sfSymbol'
? icon
: icon?.type === 'image' && icon.tinted !== false
? {
type: 'templateSource',
templateSource: icon.source,
}
: undefined,
android: icon?.type === 'drawableResource' ? icon : undefined,
shared:
icon?.type === 'image'
? {
type: 'imageSource',
imageSource: icon.source,
}
: undefined,
} as const;
}
@@ -0,0 +1,20 @@
import {
type ParamListBase,
type TabNavigationState,
} from '@react-navigation/native';
import type {
NativeBottomTabDescriptorMap,
NativeBottomTabNavigationConfig,
NativeBottomTabNavigationHelpers,
} from './types';
type Props = NativeBottomTabNavigationConfig & {
state: TabNavigationState<ParamListBase>;
navigation: NativeBottomTabNavigationHelpers;
descriptors: NativeBottomTabDescriptorMap;
};
export function NativeBottomTabView(_: Props) {
throw new Error('Native Bottom Tabs are not supported on this platform.');
}
@@ -0,0 +1,212 @@
import {
getDefaultHeaderHeight,
HeaderHeightContext,
HeaderShownContext,
useFrameSize,
} from '@react-navigation/elements';
import * as React from 'react';
import {
Animated,
Platform,
StyleSheet,
useAnimatedValue,
View,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { ScreenStack, ScreenStackItem } from 'react-native-screens';
import type { NativeBottomTabHeaderProps } from '../types';
import { debounce } from './debounce';
import { AnimatedHeaderHeightContext } from './useAnimatedHeaderHeight';
import { useHeaderConfig } from './useHeaderConfig';
type Props = NativeBottomTabHeaderProps & {
children: React.ReactNode;
};
const ANDROID_DEFAULT_HEADER_HEIGHT = 56;
export function NativeScreen({ route, navigation, options, children }: Props) {
const {
header: renderCustomHeader,
headerShown = renderCustomHeader != null,
headerTransparent,
headerBackground,
} = options;
const isModal = false;
const insets = useSafeAreaInsets();
// 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 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 [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 = renderCustomHeader != null;
const animatedHeaderHeight = useAnimatedValue(defaultHeaderHeight);
const headerTopInsetEnabled = topInset !== 0;
const onHeaderHeightChange = Animated.event(
[
{
nativeEvent: {
headerHeight: animatedHeaderHeight,
},
},
],
{
useNativeDriver: true,
listener: (e) => {
if (hasCustomHeader) {
// If we have a custom header, don't use native header height
return;
}
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 {
setHeaderHeight(headerHeight);
}
}
},
}
);
const headerConfig = useHeaderConfig({
...options,
route,
headerHeight,
headerShown: hasCustomHeader ? false : headerShown === true,
headerTopInsetEnabled,
});
return (
<ScreenStack style={styles.container}>
<ScreenStackItem
screenId={route.key}
// Needed to show search bar in tab bar with systemItem=search
stackPresentation="push"
headerConfig={headerConfig}
onHeaderHeightChange={onHeaderHeightChange}
>
<AnimatedHeaderHeightContext.Provider value={animatedHeaderHeight}>
<HeaderHeightContext.Provider
value={headerShown ? 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}
{hasCustomHeader && headerShown ? (
<View
onLayout={(e) => {
const headerHeight = e.nativeEvent.layout.height;
setHeaderHeight(headerHeight);
animatedHeaderHeight.setValue(headerHeight);
}}
style={[
styles.header,
headerTransparent ? styles.absolute : null,
]}
>
{renderCustomHeader?.({
route,
navigation,
options,
})}
</View>
) : null}
<HeaderShownContext.Provider
value={isParentHeaderShown || headerShown}
>
{children}
</HeaderShownContext.Provider>
</HeaderHeightContext.Provider>
</AnimatedHeaderHeightContext.Provider>
</ScreenStackItem>
</ScreenStack>
);
}
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,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,573 @@
import * as React from 'react';
import type {
ColorValue,
ImageSourcePropType,
StyleProp,
TextStyle,
} from 'react-native';
import type {
ScreenStackHeaderConfigProps,
SearchBarProps,
} from 'react-native-screens';
import type { SFSymbol } from 'sf-symbols-typescript';
import type { NativeBottomTabHeaderProps } from '../types';
export type NativeHeaderOptions = {
/**
* String that can be displayed in the header as a fallback for `headerTitle`.
*/
title?: string;
/**
* Style of the header when a large title is shown
* The large title is shown if `headerLargeTitle` is `true` and
* the edge of any scrollable content reaches the matching edge of the header.
*
* Supported properties:
* - backgroundColor
*
* Only supported on iOS.
*
* @platform ios
*/
headerLargeStyle?: StyleProp<{
backgroundColor?: ColorValue;
}>;
/**
* Whether to enable header with large title which collapses to regular header on scroll.
*
* For large title to collapse on scroll, the content of the screen should be wrapped in a scrollable view such as `ScrollView` or `FlatList`.
* If the scrollable area doesn't fill the screen, the large title won't collapse on scroll.
* You also need to specify `contentInsetAdjustmentBehavior="automatic"` in your `ScrollView`, `FlatList` etc.
*
* Only supported on iOS.
*
* @platform ios
*/
headerLargeTitleEnabled?: boolean;
/**
* Whether drop shadow of header is visible when a large title is shown.
*
* Only supported on iOS.
*
* @platform ios
*/
headerLargeTitleShadowVisible?: boolean;
/**
* Style object for large title in header. Supported properties:
* - fontFamily
* - fontSize
* - fontWeight
* - color
*
* Only supported on iOS.
*
* @platform ios
*/
headerLargeTitleStyle?: StyleProp<{
fontFamily?: string;
fontSize?: number;
fontWeight?: string;
color?: ColorValue;
}>;
/**
* Style object for header. Supported properties:
* - backgroundColor
*/
headerStyle?: StyleProp<{
backgroundColor?: ColorValue;
}>;
/**
* Whether to hide the elevation shadow (Android) or the bottom border (iOS) on the header.
*/
headerShadowVisible?: boolean;
/**
* Boolean indicating whether the navigation bar is translucent.
* Setting this to `true` makes the header absolutely positioned,
* and changes the background color to `transparent` unless specified in `headerStyle`.
*/
headerTransparent?: boolean;
/**
* Blur effect for the translucent header.
* The `headerTransparent` option needs to be set to `true` for this to work.
*
* Only supported on iOS.
*
* @platform ios
*/
headerBlurEffect?: ScreenStackHeaderConfigProps['blurEffect'];
/**
* Tint color for the header. Changes the color of back button and title.
*/
headerTintColor?: string;
/**
* Function which returns a React Element to render as the background of the header.
* This is useful for using backgrounds such as an image, a gradient, blur effect etc.
* You can use this with `headerTransparent` to render content underneath a translucent header.
*/
headerBackground?: () => React.ReactNode;
/**
* Function which returns a React Element to display on the left side of the header.
* This replaces the back button. See `headerBackVisible` to show the back button along side left element.
* Will be overriden by `headerLeftItems` on iOS.
*/
headerLeft?: (props: NativeScreenHeaderItemProps) => React.ReactNode;
/**
* Function which returns a React Element to display on the right side of the header.
* Will be overriden by `headerRightItems` on iOS.
*/
headerRight?: (props: NativeScreenHeaderItemProps) => React.ReactNode;
/**
* Function which returns an array of items to display as on the left side of the header.
* Overrides `headerLeft`.
*
* This is an unstable API and might change in the future.
*
* @platform ios
*/
unstable_headerLeftItems?: (
props: NativeScreenHeaderItemProps
) => NativeScreenHeaderItem[];
/**
* Function which returns an array of items to display as on the right side of the header.
* Overrides `headerRight`.
*
* This is an unstable API and might change in the future.
*
* @platform ios
*/
unstable_headerRightItems?: (
props: NativeScreenHeaderItemProps
) => NativeScreenHeaderItem[];
/**
* String or a function that returns a React Element to be used by the header.
* Defaults to screen `title` or route name.
*
* When a function is passed, it receives `tintColor` and`children` in the options object as an argument.
* The title string is passed in `children`.
*
* Note that if you render a custom element by passing a function, animations for the title won't work.
*/
headerTitle?:
| string
| ((props: {
/**
* The title text of the header.
*/
children: string;
/**
* Tint color for the header.
*/
tintColor?: string;
}) => React.ReactNode);
/**
* How to align the the header title.
* Defaults to `left` on platforms other than iOS.
*
* Not supported on iOS. It's always `center` on iOS and cannot be changed.
*/
headerTitleAlign?: 'left' | 'center';
/**
* Style object for header title. Supported properties:
* - fontFamily
* - fontSize
* - fontWeight
* - color
*/
headerTitleStyle?: StyleProp<
Pick<TextStyle, 'fontFamily' | 'fontSize' | 'fontWeight'> & {
color?: string;
}
>;
/**
* Options to render a native search bar.
* You also need to specify `contentInsetAdjustmentBehavior="automatic"` in your `ScrollView`, `FlatList` etc.
* If you don't have a `ScrollView`, specify `headerTransparent: false`.
*/
headerSearchBarOptions?: SearchBarProps;
/**
* Whether to show the header. Setting this to `false` hides the header.
* Defaults to `true`.
*/
headerShown?: boolean;
/**
* Function that given returns a React Element to display as a header.
*/
header?: (props: NativeBottomTabHeaderProps) => React.ReactNode;
};
export type NativeScreenHeaderItemProps = {
/**
* Tint color for the header.
*/
tintColor?: ColorValue;
};
/**
* A button item in the header.
*/
export type NativeScreenHeaderItemButton = SharedHeaderItem & {
/**
* Type of the item.
*/
type: 'button';
/**
* Function to call when the item is pressed.
*/
onPress: () => void;
/**
* Whether the item is in a selected state.
*
* Read more: https://developer.apple.com/documentation/uikit/uibarbuttonitem/isselected
*/
selected?: boolean;
};
/**
* An action item in a menu.
*/
export type NativeScreenHeaderItemMenuAction = {
type: 'action';
/**
* Label for the menu item.
*/
label: string;
/**
* The secondary text displayed alongside the label of the menu item.
*/
description?: string;
/**
* Icon for the menu item.
*/
icon?: IconIOSSfSymbol;
/**
* Function to call when the menu item is pressed.
*/
onPress: () => void;
/**
* The state of an action- or command-based menu item.
*
* Read more: https://developer.apple.com/documentation/uikit/uimenuelement/state
*/
state?: 'on' | 'off' | 'mixed';
/**
* Whether to apply disabled style to the item.
*
* Read more: https://developer.apple.com/documentation/uikit/uimenuelement/attributes/disabled
*/
disabled?: boolean;
/**
* Whether to apply destructive style to the item.
*
* Read more: https://developer.apple.com/documentation/uikit/uimenuelement/attributes/destructive
*/
destructive?: boolean;
/**
* Whether to apply hidden style to the item.
*
* Read more: https://developer.apple.com/documentation/uikit/uimenuelement/attributes/hidden
*/
hidden?: boolean;
/**
* Whether to keep the menu presented after firing the elements action.
*
* Read more: https://developer.apple.com/documentation/uikit/uimenuelement/attributes/keepsmenupresented
*/
keepsMenuPresented?: boolean;
/**
* An elaborated title that explains the purpose of the action.
*
* On iOS, the system displays this title in the discoverability heads-up display (HUD).
* If this is not set, the HUD displays the title property.
*
* Read more: https://developer.apple.com/documentation/uikit/uiaction/discoverabilitytitle
*/
discoverabilityLabel?: string;
};
/**
* A submenu item that contains other menu items.
*/
export type NativeScreenHeaderItemMenuSubmenu = {
type: 'submenu';
/**
* Label for the submenu item.
*/
label: string;
/**
* Icon for the submenu item.
*/
icon?: IconIOSSfSymbol;
/**
* Whether the menu is displayed inline with the parent menu.
* By default, submenus are displayed after expanding the parent menu item.
* Inline menus are displayed as part of the parent menu as a section.
*
* Defaults to `false`.
*
* Read more: https://developer.apple.com/documentation/uikit/uimenu/options-swift.struct/displayinline
*/
inline?: boolean;
/**
* How the submenu items are displayed.
* - `default`: menu items are displayed normally.
* - `palette`: menu items are displayed in a horizontal row.
*
* Defaults to `default`.
*
* Read more: https://developer.apple.com/documentation/uikit/uimenu/options-swift.struct/displayaspalette
*/
layout?: 'default' | 'palette';
/**
* Whether to apply destructive style to the menu item.
*
* Read more: https://developer.apple.com/documentation/uikit/uimenuelement/attributes/destructive
*/
destructive?: boolean;
/**
* Whether multiple items in the submenu can be selected, i.e. in "on" state.
*
* Defaults to `false`.
*
* Read more: https://developer.apple.com/documentation/uikit/uimenu/options-swift.struct/singleselection
*/
multiselectable?: boolean;
/**
* Array of menu items (actions or submenus).
*/
items: NativeScreenHeaderItemMenu['menu']['items'];
};
/**
* An item that shows a menu when pressed.
*/
export type NativeScreenHeaderItemMenu = SharedHeaderItem & {
type: 'menu';
/**
* Whether the menu is a selection menu.
* Tapping an item in a selection menu will add a checkmark to the selected item.
*
* Read more: https://developer.apple.com/documentation/uikit/uibarbuttonitem/changesselectionasprimaryaction
*/
changesSelectionAsPrimaryAction?: boolean;
/**
* Menu for the item.
*/
menu: {
/**
* Optional title to show on top of the menu.
*/
title?: string;
/**
* Whether multiple items in the submenu can be selected, i.e. in "on" state.
*
* Defaults to `false`.
*
* Read more: https://developer.apple.com/documentation/uikit/uimenu/options-swift.struct/singleselection
*/
multiselectable?: boolean;
/**
* How the submenu items are displayed.
* - `default`: menu items are displayed normally.
* - `palette`: menu items are displayed in a horizontal row.
*
* Defaults to `default`.
*
* Read more: https://developer.apple.com/documentation/uikit/uimenu/options-swift.struct/displayaspalette
*/
layout?: 'default' | 'palette';
/**
* Array of menu items (actions or submenus).
*/
items: (
| NativeScreenHeaderItemMenuAction
| NativeScreenHeaderItemMenuSubmenu
)[];
};
};
/**
* An item to add spacing between other items in the header.
*/
export type NativeScreenHeaderItemSpacing = {
type: 'spacing';
/**
* The amount of spacing to add.
*/
spacing: number;
};
/**
* A custom item to display any React Element in the header.
*/
export type NativeScreenHeaderItemCustom = {
type: 'custom';
/**
* A React Element to display as the item.
*/
element: React.ReactElement;
/**
* Whether the background this item may share with other items in the bar should be hidden.
* Only available from iOS 26.0 and later.
*
* Read more: https://developer.apple.com/documentation/uikit/uibarbuttonitem/hidessharedbackground
*/
hidesSharedBackground?: boolean;
};
/**
* An item that can be displayed in the header.
* It can be a button, a menu, spacing, or a custom element.
*
* On iOS 26, when showing items on the right side of the header,
* if the items don't fit the available space, they will be collapsed into a menu automatically.
* Items with `type: 'custom'` will not be included in this automatic collapsing behavior.
*/
export type NativeScreenHeaderItem =
| NativeScreenHeaderItemButton
| NativeScreenHeaderItemMenu
| NativeScreenHeaderItemSpacing
| NativeScreenHeaderItemCustom;
type IconImage = {
/**
* - `image` - Use a local image as the icon.
*/
type: 'image';
/**
* Image source to use as the icon.
* e.g., `require('./path/to/image.png')`
*/
source: ImageSourcePropType;
/**
* Whether to apply tint color to the icon.
* Defaults to `true`.
*
* @platform ios
*/
tinted?: boolean;
};
type IconIOSSfSymbol = {
/**
* - `sfSymbol` - Use an SF Symbol as the icon on iOS.
*/
type: 'sfSymbol';
/**
* Name of the SF Symbol to use as the icon.
*
* @platform ios
*/
name: SFSymbol;
};
type IconIOS = IconIOSSfSymbol | IconImage;
type SharedHeaderItem = {
/**
* Label of the item.
*/
label: string;
/**
* Style for the item label.
*/
labelStyle?: {
fontFamily?: string;
fontSize?: number;
fontWeight?: string;
color?: ColorValue;
};
/**
* Icon for the item
*/
icon?: IconIOS;
/**
* The variant of the item.
* "prominent" only available from iOS 26.0 and later.
*
* Read more: https://developer.apple.com/documentation/uikit/uibarbuttonitem/style-swift.property
*/
variant?: 'plain' | 'done' | 'prominent';
/**
* The tint color to apply to the item.
*
* Read more: https://developer.apple.com/documentation/uikit/uibarbuttonitem/tintcolor
*/
tintColor?: ColorValue;
/**
* Whether the item is in a disabled state.
*/
disabled?: boolean;
/**
* The width of the item.
*
* Read more: https://developer.apple.com/documentation/uikit/uibarbuttonitem/width
*/
width?: number;
/**
* Whether the background this item may share with other items in the bar should be hidden.
* Only available from iOS 26.0 and later.
*
* Read more: https://developer.apple.com/documentation/uikit/uibarbuttonitem/hidessharedbackground
*/
hidesSharedBackground?: boolean;
/**
* Whether this item can share a background with other items.
* Only available from iOS 26.0 and later.
*
* Read more: https://developer.apple.com/documentation/uikit/uibarbuttonitem/sharesbackground
*/
sharesBackground?: boolean;
/**
* An identifier used to match items across transitions.
* Only available from iOS 26.0 and later.
*
* Read more: https://developer.apple.com/documentation/uikit/uibarbuttonitem/identifier
*/
identifier?: string;
/**
* A badge to display on a item.
* Only available from iOS 26.0 and later.
*
* Read more: https://developer.apple.com/documentation/uikit/uibarbuttonitembadge
*/
badge?: {
/**
* The text to display in the badge.
*/
value: number | string;
/**
* Style of the badge.
*/
style?: {
color?: ColorValue;
backgroundColor?: ColorValue;
fontFamily?: string;
fontSize?: number;
fontWeight?: string;
};
};
/**
* Accessibility label for the item.
*/
accessibilityLabel?: string;
/**
* Accessibility hint for the item.
*/
accessibilityHint?: string;
};
@@ -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,434 @@
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,
ScreenStackHeaderCenterView,
type ScreenStackHeaderConfigProps,
ScreenStackHeaderLeftView,
ScreenStackHeaderRightView,
ScreenStackHeaderSearchBarView,
SearchBar,
} from 'react-native-screens';
import type {
NativeHeaderOptions,
NativeScreenHeaderItem,
NativeScreenHeaderItemMenuAction,
NativeScreenHeaderItemMenuSubmenu,
} from './types';
type Props = NativeHeaderOptions & {
headerTopInsetEnabled: boolean;
headerHeight: number;
route: Route<string>;
};
const processBarButtonItems = (
items: NativeScreenHeaderItem[] | 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 =
typeof badgeBackgroundColor === 'string' &&
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: NativeScreenHeaderItemMenuAction | NativeScreenHeaderItemMenuSubmenu
): 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 useHeaderConfig({
headerShadowVisible,
headerLargeStyle,
headerLargeTitleEnabled,
headerLargeTitleShadowVisible,
headerLargeTitleStyle,
headerBackground,
headerLeft,
headerRight,
headerShown,
headerStyle,
headerBlurEffect,
headerTintColor,
headerTitle,
headerTitleAlign,
headerTitleStyle,
headerTransparent,
headerSearchBarOptions,
headerTopInsetEnabled,
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 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 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 titleFontFamily = headerTitleStyleFlattened.fontFamily;
const titleFontWeight = headerTitleStyleFlattened.fontWeight;
const largeTitleFontFamily = headerLargeTitleStyleFlattened.fontFamily;
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
? 'transparent'
: colors.card);
const headerLeftElement = headerLeft?.({
tintColor,
});
const headerRightElement = headerRight?.({
tintColor,
});
const headerTitleElement =
typeof headerTitle === 'function'
? headerTitle({
tintColor,
children: titleText,
})
: null;
const hasHeaderSearchBar =
isSearchBarAvailableForCurrentPlatform && headerSearchBarOptions != 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 isCenterViewRenderedAndroid = headerTitleAlign === 'center';
const leftItems = headerLeftItems?.({
tintColor,
});
let rightItems = headerRightItems?.({
tintColor,
});
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}
</>
)}
{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 {
backgroundColor: headerBackgroundColor,
blurEffect: headerBlurEffect,
color: tintColor,
direction,
hidden: headerShown === 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;
}
@@ -0,0 +1,116 @@
import {
createNavigatorFactory,
type NavigatorTypeBagBase,
type ParamListBase,
StackActions,
type StaticConfig,
type TabActionHelpers,
type TabNavigationState,
TabRouter,
type TabRouterOptions,
type TypedNavigator,
useNavigationBuilder,
} from '@react-navigation/native';
import * as React from 'react';
import { NativeBottomTabView } from './NativeBottomTabView.native';
import type {
NativeBottomTabNavigationEventMap,
NativeBottomTabNavigationOptions,
NativeBottomTabNavigationProp,
NativeBottomTabNavigatorProps,
} from './types';
function NativeBottomTabNavigator({
id,
initialRouteName,
backBehavior,
children,
layout,
screenListeners,
screenOptions,
screenLayout,
UNSTABLE_router,
UNSTABLE_routeNamesChangeBehavior,
...rest
}: NativeBottomTabNavigatorProps) {
const { state, navigation, descriptors, NavigationContent } =
useNavigationBuilder<
TabNavigationState<ParamListBase>,
TabRouterOptions,
TabActionHelpers<ParamListBase>,
NativeBottomTabNavigationOptions,
NativeBottomTabNavigationEventMap
>(TabRouter, {
id,
initialRouteName,
backBehavior,
children,
layout,
screenListeners,
screenOptions,
screenLayout,
UNSTABLE_router,
UNSTABLE_routeNamesChangeBehavior,
});
const focusedRouteKey = state.routes[state.index].key;
const previousRouteKeyRef = React.useRef(focusedRouteKey);
React.useEffect(() => {
const previousRouteKey = previousRouteKeyRef.current;
if (
previousRouteKey !== focusedRouteKey &&
descriptors[previousRouteKey]?.options.popToTopOnBlur
) {
const prevRoute = state.routes.find(
(route) => route.key === previousRouteKey
);
if (prevRoute?.state?.type === 'stack' && prevRoute.state.key) {
const popToTopAction = {
...StackActions.popToTop(),
target: prevRoute.state.key,
};
navigation.dispatch(popToTopAction);
}
}
previousRouteKeyRef.current = focusedRouteKey;
}, [descriptors, focusedRouteKey, navigation, state.index, state.routes]);
return (
<NavigationContent>
<NativeBottomTabView
{...rest}
state={state}
navigation={navigation}
descriptors={descriptors}
/>
</NavigationContent>
);
}
export function createNativeBottomTabNavigator<
const ParamList extends ParamListBase,
const NavigatorID extends string | undefined = string | undefined,
const TypeBag extends NavigatorTypeBagBase = {
ParamList: ParamList;
NavigatorID: NavigatorID;
State: TabNavigationState<ParamList>;
ScreenOptions: NativeBottomTabNavigationOptions;
EventMap: NativeBottomTabNavigationEventMap;
NavigationList: {
[RouteName in keyof ParamList]: NativeBottomTabNavigationProp<
ParamList,
RouteName,
NavigatorID
>;
};
Navigator: typeof NativeBottomTabNavigator;
},
const Config extends StaticConfig<TypeBag> = StaticConfig<TypeBag>,
>(config?: Config): TypedNavigator<TypeBag, Config> {
return createNavigatorFactory(NativeBottomTabNavigator)(config);
}
@@ -0,0 +1,4 @@
export const createNativeBottomTabNavigator: typeof import('./createNativeBottomTabNavigator.native').createNativeBottomTabNavigator =
() => {
throw new Error('Native Bottom Tabs are not supported on this platform.');
};
@@ -0,0 +1,23 @@
/**
* Navigators
*/
export { createNativeBottomTabNavigator } from './createNativeBottomTabNavigator';
/**
* Views
*/
export { NativeBottomTabView } from './NativeBottomTabView';
/**
* Types
*/
export type {
NativeBottomTabBarProps,
NativeBottomTabIcon,
NativeBottomTabNavigationEventMap,
NativeBottomTabNavigationOptions,
NativeBottomTabNavigationProp,
NativeBottomTabNavigatorProps,
NativeBottomTabOptionsArgs,
NativeBottomTabScreenProps,
} from './types';
@@ -0,0 +1,403 @@
import type {
DefaultNavigatorOptions,
Descriptor,
NavigationHelpers,
NavigationProp,
ParamListBase,
RouteProp,
TabActionHelpers,
TabNavigationState,
TabRouterOptions,
Theme,
} from '@react-navigation/native';
import type { ColorValue, ImageSourcePropType, TextStyle } from 'react-native';
import type { EdgeInsets } from 'react-native-safe-area-context';
import type {
TabBarItemLabelVisibilityMode,
TabsScreenBlurEffect,
TabsSystemItem,
} from 'react-native-screens';
import type { SFSymbol } from 'sf-symbols-typescript';
import type { NativeHeaderOptions } from './NativeScreen/types';
export type Layout = { width: number; height: number };
export type NativeBottomTabNavigationEventMap = {
/**
* Event which fires on tapping on the tab in the tab bar.
*/
tabPress: { data: undefined; canPreventDefault: false };
/**
* Event which fires when a transition animation starts.
*/
transitionStart: { data: { closing: boolean } };
/**
* Event which fires when a transition animation ends.
*/
transitionEnd: { data: { closing: boolean } };
};
export type NativeBottomTabNavigationProp<
ParamList extends ParamListBase,
RouteName extends keyof ParamList = keyof ParamList,
NavigatorID extends string | undefined = undefined,
> = NavigationProp<
ParamList,
RouteName,
NavigatorID,
TabNavigationState<ParamList>,
NativeBottomTabNavigationOptions,
NativeBottomTabNavigationEventMap
> &
TabActionHelpers<ParamList>;
export type NativeBottomTabScreenProps<
ParamList extends ParamListBase,
RouteName extends keyof ParamList = keyof ParamList,
NavigatorID extends string | undefined = undefined,
> = {
navigation: NativeBottomTabNavigationProp<ParamList, RouteName, NavigatorID>;
route: RouteProp<ParamList, RouteName>;
};
export type NativeBottomTabOptionsArgs<
ParamList extends ParamListBase,
RouteName extends keyof ParamList = keyof ParamList,
NavigatorID extends string | undefined = undefined,
> = NativeBottomTabScreenProps<ParamList, RouteName, NavigatorID> & {
theme: Theme;
};
type IconImage = {
/**
* - `image` - Use a local image as the icon.
*/
type: 'image';
/**
* Image source to use as the icon.
* e.g., `require('./path/to/image.png')`
*/
source: ImageSourcePropType;
/**
* Whether to apply tint color to the icon.
* Defaults to `true`.
*
* @platform ios
*/
tinted?: boolean;
};
type IconIOSSfSymbol = {
/**
* - `sfSymbol` - Use an SF Symbol as the icon on iOS.
*/
type: 'sfSymbol';
/**
* Name of the SF Symbol to use as the icon.
*
* @platform ios
*/
name: SFSymbol;
};
type IconAndroidDrawable = {
/**
* - `drawableResource` - Use a drawable resource as the icon on Android.
*/
type: 'drawableResource';
/**
* Name of the drawable resource to use as the icon.
*
* @platform android
*/
name: string;
};
type IconIOS = IconIOSSfSymbol | IconImage;
type IconAndroid = IconAndroidDrawable | IconImage;
export type NativeBottomTabIcon = IconIOS | IconAndroid;
export type NativeBottomTabNavigationOptions = NativeHeaderOptions & {
/**
* Title text for the screen.
*/
title?: string;
/**
* Uses iOS built-in tab bar items with standard iOS styling and localized titles.
* If set to `search`, it's positioned next to the tab bar on iOS 26 and above.
*
* The `tabBarIcon` and `tabBarLabel` options will override the icon and label from the system item.
* If you want to keep the system behavior on iOS, but need to provide icon and label for other platforms,
* Use `Platform.OS` or `Platform.select` to conditionally set `undefined` for `tabBarIcon` and `tabBarLabel` on iOS.
*
* @platform ios
*/
tabBarSystemItem?: TabsSystemItem;
/**
* Title string of the tab displayed in the tab bar
*
* Overrides the label provided by `tabBarSystemItem` on iOS.
*
* If not provided, or set to `undefined`:
* - The system values are used if `tabBarSystemItem` is set on iOS.
* - Otherwise, it falls back to the `title` or route name.
*/
tabBarLabel?: string;
/**
* Label visibility mode for the tab bar items.
*
* The following values are currently supported:
*
* - `auto` - the system decides when to show or hide labels
* - `selected` - labels are shown only for the selected tab
* - `labeled` - labels are always shown
* - `unlabeled` - labels are never shown
*
* Defaults to `auto`.
*
* @platform android
*/
tabBarLabelVisibilityMode?: TabBarItemLabelVisibilityMode;
/**
* Style object for the tab label.
*/
tabBarLabelStyle?: Pick<
TextStyle,
'fontFamily' | 'fontSize' | 'fontWeight' | 'fontStyle'
>;
/**
* Icon to display for the tab.
*
* Showing a different icon for focused tab is only supported on iOS.
*
* Overrides the icon provided by `tabBarSystemItem` on iOS.
*/
tabBarIcon?:
| NativeBottomTabIcon
| ((props: { focused: boolean }) => NativeBottomTabIcon);
/**
* Text to show in a badge on the tab icon.
*/
tabBarBadge?: number | string;
/**
* Custom style for the tab bar badge.
* You can specify a background color or text color here.
*
* @platform android
*/
tabBarBadgeStyle?: {
backgroundColor?: ColorValue;
color?: ColorValue;
};
/**
* Color for the icon and label in the active tab.
*/
tabBarActiveTintColor?: ColorValue;
/**
* Color for the icon and label in the inactive tabs.
*
* @platform android
*/
tabBarInactiveTintColor?: ColorValue;
/**
* Background color of the active indicator.
*
* @platform android
*/
tabBarActiveIndicatorColor?: ColorValue;
/**
* Specifies if the active indicator should be used. Defaults to `true`.
*
* @platform android
*/
tabBarActiveIndicatorEnabled?: boolean;
/**
* Color of tab bar item's ripple effect.
*
* @platform android
*/
tabBarRippleColor?: ColorValue;
/**
* Style object for the tab bar container.
*/
tabBarStyle?: {
/**
* Background color of the tab bar.
*
* Only supported on Android and iOS 18 and below.
*/
backgroundColor?: ColorValue;
/**
* Shadow color of the tab bar.
*
* Only supported on iOS 18 and below.
*/
shadowColor?: ColorValue;
};
/**
* Blur effect applied to the tab bar when tab screen is selected.
*
* Works with backgroundColor's alpha < 1.
*
* Only supported on iOS 18 and lower.
*
* The following values are currently supported:
*
* - `none` - disables blur effect
* - `systemDefault` - uses UIKit's default tab bar blur effect
* - one of styles mapped from UIKit's UIBlurEffectStyle, e.g. `systemUltraThinMaterial`
*
* Defaults to `systemDefault`.
*
* Complete list of possible blur effect styles is available in the official UIKit documentation:
* @see {@link https://developer.apple.com/documentation/uikit/uiblureffect/style|UIBlurEffect.Style}
*
* @platform ios
*/
tabBarBlurEffect?: TabsScreenBlurEffect;
/**
* Display mode for the tab bar.
*
* Available starting from iOS 18.
* Not supported on tvOS.
*
* The following values are currently supported:
*
* - `auto` - the system sets the display mode based on the tabs content
* - `tabBar` - the system displays the content only as a tab bar
* - `tabSidebar` - the tab bar is displayed as a sidebar
*
* Defaults to `auto`.
*
* @see {@link https://developer.apple.com/documentation/uikit/uitabbarcontroller/mode|UITabBarController.Mode}
*
* @platform ios
*/
tabBarControllerMode?: 'auto' | 'tabBar' | 'tabSidebar';
/**
* Minimize behavior for the tab bar.
*
* Available starting from iOS 26.
*
* The following values are currently supported:
*
* - `auto` - resolves to the system default minimize behavior
* - `never` - the tab bar does not minimize
* - `onScrollDown` - the tab bar minimizes when scrolling down and
* expands when scrolling back up
* - `onScrollUp` - the tab bar minimizes when scrolling up and expands
* when scrolling back down
*
* Defaults to `auto`.
*
* The supported values correspond to the official UIKit documentation:
* @see {@link https://developer.apple.com/documentation/uikit/uitabbarcontroller/minimizebehavior|UITabBarController.MinimizeBehavior}
*
* @platform ios
*/
tabBarMinimizeBehavior?: 'auto' | 'never' | 'onScrollDown' | 'onScrollUp';
/**
* Function which returns a React element to display as an accessory view.
*
* Accepts a `placement` parameter which can be one of the following values:
* - `regular` - at bottom of the screen, above the tab bar if tab bar is at the bottom
* - `inline` - inline with the collapsed bottom tab bar (e.g. when minimized based on `tabBarMinimizeBehavior`)
*
* Note: the content is rendered twice for both placements, but only one is visible at a time based on the tab bar state.
* Any shared state should be stored outside of the component to keep both versions in sync.
*
* Available starting from iOS 26.
*
* @platform ios
*/
bottomAccessory?: (options: {
placement: 'regular' | 'inline';
}) => React.ReactNode;
/**
* Whether this screens should render the first time it's accessed. Defaults to `true`.
* Set it to `false` if you want to render the screen on initial render.
*/
lazy?: boolean;
/**
* Whether any nested stack should be popped to top when navigating away from the tab.
* Defaults to `false`.
*/
popToTopOnBlur?: boolean;
};
export type NativeBottomTabDescriptor = Descriptor<
NativeBottomTabNavigationOptions,
NativeBottomTabNavigationProp<ParamListBase>,
RouteProp<ParamListBase>
>;
export type NativeBottomTabDescriptorMap = Record<
string,
NativeBottomTabDescriptor
>;
export type NativeBottomTabNavigationConfig = {};
export type NativeBottomTabBarProps = {
state: TabNavigationState<ParamListBase>;
descriptors: NativeBottomTabDescriptorMap;
navigation: NavigationHelpers<
ParamListBase,
NativeBottomTabNavigationEventMap
>;
insets: EdgeInsets;
};
export type NativeBottomTabNavigatorProps = DefaultNavigatorOptions<
ParamListBase,
string | undefined,
TabNavigationState<ParamListBase>,
NativeBottomTabNavigationOptions,
NativeBottomTabNavigationEventMap,
NativeBottomTabNavigationProp<ParamListBase>
> &
TabRouterOptions &
NativeBottomTabNavigationConfig;
export type NativeBottomTabNavigationHelpers = NavigationHelpers<
ParamListBase,
NativeBottomTabNavigationEventMap
> &
TabActionHelpers<ParamListBase>;
export type NativeBottomTabHeaderProps = {
/**
* Options for the current screen.
*/
options: NativeBottomTabNavigationOptions;
/**
* Route object for the current screen.
*/
route: RouteProp<ParamListBase>;
/**
* Navigation prop for the header.
*/
navigation: NativeBottomTabNavigationProp<ParamListBase>;
};
@@ -0,0 +1,5 @@
import * as React from 'react';
export const BottomTabBarHeightCallbackContext = React.createContext<
((height: number) => void) | undefined
>(undefined);
@@ -0,0 +1,5 @@
import * as React from 'react';
export const BottomTabBarHeightContext = React.createContext<
number | undefined
>(undefined);
@@ -0,0 +1,25 @@
import type { NavigationState } from '@react-navigation/routers';
import * as React from 'react';
import { Animated } from 'react-native';
export function useAnimatedHashMap({ routes, index }: NavigationState) {
const refs = React.useRef<Record<string, Animated.Value>>({});
const previous = refs.current;
const routeKeys = Object.keys(previous);
if (
routes.length === routeKeys.length &&
routes.every((route) => routeKeys.includes(route.key))
) {
return previous;
}
refs.current = {};
routes.forEach(({ key }, i) => {
refs.current[key] =
previous[key] ??
new Animated.Value(i === index ? 0 : i >= index ? 1 : -1);
});
return refs.current;
}
@@ -0,0 +1,15 @@
import * as React from 'react';
import { BottomTabBarHeightContext } from './BottomTabBarHeightContext';
export function useBottomTabBarHeight() {
const height = React.useContext(BottomTabBarHeightContext);
if (height === undefined) {
throw new Error(
"Couldn't find the bottom tab bar height. Are you inside a screen in Bottom Tab Navigator?"
);
}
return height;
}
@@ -0,0 +1,31 @@
import * as React from 'react';
import { type EmitterSubscription, Keyboard, Platform } from 'react-native';
export function useIsKeyboardShown() {
const [isKeyboardShown, setIsKeyboardShown] = React.useState(false);
React.useEffect(() => {
const handleKeyboardShow = () => setIsKeyboardShown(true);
const handleKeyboardHide = () => setIsKeyboardShown(false);
let subscriptions: EmitterSubscription[];
if (Platform.OS === 'ios') {
subscriptions = [
Keyboard.addListener('keyboardWillShow', handleKeyboardShow),
Keyboard.addListener('keyboardWillHide', handleKeyboardHide),
];
} else {
subscriptions = [
Keyboard.addListener('keyboardDidShow', handleKeyboardShow),
Keyboard.addListener('keyboardDidHide', handleKeyboardHide),
];
}
return () => {
subscriptions.forEach((s) => s.remove());
};
}, []);
return isKeyboardShown;
}
@@ -0,0 +1,523 @@
import {
getDefaultSidebarWidth,
getLabel,
MissingIcon,
useFrameSize,
} from '@react-navigation/elements';
import {
CommonActions,
NavigationContext,
NavigationRouteContext,
type ParamListBase,
type TabNavigationState,
useLinkBuilder,
useLocale,
useTheme,
} from '@react-navigation/native';
import React from 'react';
import {
Animated,
type LayoutChangeEvent,
Platform,
type StyleProp,
StyleSheet,
View,
type ViewStyle,
} from 'react-native';
import { type EdgeInsets } from 'react-native-safe-area-context';
import type { BottomTabBarProps, BottomTabDescriptorMap } from '../types';
import { BottomTabBarHeightCallbackContext } from '../utils/BottomTabBarHeightCallbackContext';
import { useIsKeyboardShown } from '../utils/useIsKeyboardShown';
import { BottomTabItem } from './BottomTabItem';
type Props = BottomTabBarProps & {
style?: Animated.WithAnimatedValue<StyleProp<ViewStyle>>;
};
const TABBAR_HEIGHT_UIKIT = 49;
const TABBAR_HEIGHT_UIKIT_COMPACT = 32;
const SPACING_UIKIT = 15;
const SPACING_MATERIAL = 12;
const DEFAULT_MAX_TAB_ITEM_WIDTH = 125;
const useNativeDriver = Platform.OS !== 'web';
type Options = {
state: TabNavigationState<ParamListBase>;
descriptors: BottomTabDescriptorMap;
dimensions: { height: number; width: number };
};
const shouldUseHorizontalLabels = ({
state,
descriptors,
dimensions,
}: Options) => {
const { tabBarLabelPosition } =
descriptors[state.routes[state.index].key].options;
if (tabBarLabelPosition) {
switch (tabBarLabelPosition) {
case 'beside-icon':
return true;
case 'below-icon':
return false;
}
}
if (dimensions.width >= 768) {
// Screen size matches a tablet
const maxTabWidth = state.routes.reduce((acc, route) => {
const { tabBarItemStyle } = descriptors[route.key].options;
const flattenedStyle = StyleSheet.flatten(tabBarItemStyle);
if (flattenedStyle) {
if (typeof flattenedStyle.width === 'number') {
return acc + flattenedStyle.width;
} else if (typeof flattenedStyle.maxWidth === 'number') {
return acc + flattenedStyle.maxWidth;
}
}
return acc + DEFAULT_MAX_TAB_ITEM_WIDTH;
}, 0);
return maxTabWidth <= dimensions.width;
} else {
return dimensions.width > dimensions.height;
}
};
const isCompact = ({ state, descriptors, dimensions }: Options): boolean => {
const { tabBarPosition, tabBarVariant } =
descriptors[state.routes[state.index].key].options;
if (
tabBarPosition === 'left' ||
tabBarPosition === 'right' ||
tabBarVariant === 'material'
) {
return false;
}
const isLandscape = dimensions.width > dimensions.height;
const horizontalLabels = shouldUseHorizontalLabels({
state,
descriptors,
dimensions,
});
if (
Platform.OS === 'ios' &&
!Platform.isPad &&
isLandscape &&
horizontalLabels
) {
return true;
}
return false;
};
export const getTabBarHeight = ({
state,
descriptors,
dimensions,
insets,
style,
}: Options & {
insets: EdgeInsets;
style: Animated.WithAnimatedValue<StyleProp<ViewStyle>> | undefined;
}) => {
const { tabBarPosition } = descriptors[state.routes[state.index].key].options;
const flattenedStyle = StyleSheet.flatten(style);
const customHeight =
flattenedStyle && 'height' in flattenedStyle
? flattenedStyle.height
: undefined;
if (typeof customHeight === 'number') {
return customHeight;
}
const inset = insets[tabBarPosition === 'top' ? 'top' : 'bottom'];
if (isCompact({ state, descriptors, dimensions })) {
return TABBAR_HEIGHT_UIKIT_COMPACT + inset;
}
return TABBAR_HEIGHT_UIKIT + inset;
};
export function BottomTabBar({
state,
navigation,
descriptors,
insets,
style,
}: Props) {
const { colors } = useTheme();
const { direction } = useLocale();
const { buildHref } = useLinkBuilder();
const focusedRoute = state.routes[state.index];
const focusedDescriptor = descriptors[focusedRoute.key];
const focusedOptions = focusedDescriptor.options;
const {
tabBarPosition = 'bottom',
tabBarShowLabel,
tabBarLabelPosition,
tabBarHideOnKeyboard = false,
tabBarVisibilityAnimationConfig,
tabBarVariant = 'uikit',
tabBarStyle,
tabBarBackground,
tabBarActiveTintColor,
tabBarInactiveTintColor,
tabBarActiveBackgroundColor,
tabBarInactiveBackgroundColor,
} = focusedOptions;
if (
tabBarVariant === 'material' &&
tabBarPosition !== 'left' &&
tabBarPosition !== 'right'
) {
throw new Error(
"The 'material' variant for tab bar is only supported when 'tabBarPosition' is set to 'left' or 'right'."
);
}
if (
tabBarLabelPosition === 'below-icon' &&
tabBarVariant === 'uikit' &&
(tabBarPosition === 'left' || tabBarPosition === 'right')
) {
throw new Error(
"The 'below-icon' label position for tab bar is only supported when 'tabBarPosition' is set to 'top' or 'bottom' when using the 'uikit' variant."
);
}
const isKeyboardShown = useIsKeyboardShown();
const onHeightChange = React.useContext(BottomTabBarHeightCallbackContext);
const shouldShowTabBar = !(tabBarHideOnKeyboard && isKeyboardShown);
const visibilityAnimationConfigRef = React.useRef(
tabBarVisibilityAnimationConfig
);
React.useEffect(() => {
visibilityAnimationConfigRef.current = tabBarVisibilityAnimationConfig;
});
const [isTabBarHidden, setIsTabBarHidden] = React.useState(!shouldShowTabBar);
const [visible] = React.useState(
() => new Animated.Value(shouldShowTabBar ? 1 : 0)
);
React.useEffect(() => {
const visibilityAnimationConfig = visibilityAnimationConfigRef.current;
if (shouldShowTabBar) {
const animation =
visibilityAnimationConfig?.show?.animation === 'spring'
? Animated.spring
: Animated.timing;
animation(visible, {
toValue: 1,
useNativeDriver,
duration: 250,
...visibilityAnimationConfig?.show?.config,
}).start(({ finished }) => {
if (finished) {
setIsTabBarHidden(false);
}
});
} else {
// eslint-disable-next-line @eslint-react/hooks-extra/no-direct-set-state-in-use-effect
setIsTabBarHidden(true);
const animation =
visibilityAnimationConfig?.hide?.animation === 'spring'
? Animated.spring
: Animated.timing;
animation(visible, {
toValue: 0,
useNativeDriver,
duration: 200,
...visibilityAnimationConfig?.hide?.config,
}).start();
}
return () => visible.stopAnimation();
}, [visible, shouldShowTabBar]);
const [layout, setLayout] = React.useState({
height: 0,
});
const handleLayout = (e: LayoutChangeEvent) => {
const { height } = e.nativeEvent.layout;
onHeightChange?.(height);
setLayout((layout) => {
if (height === layout.height) {
return layout;
} else {
return { height };
}
});
};
const { routes } = state;
const tabBarHeight = useFrameSize((dimensions) =>
getTabBarHeight({
state,
descriptors,
insets,
dimensions,
style: [tabBarStyle, style],
})
);
const hasHorizontalLabels = useFrameSize((dimensions) =>
shouldUseHorizontalLabels({
state,
descriptors,
dimensions,
})
);
const compact = useFrameSize((dimensions) =>
isCompact({ state, descriptors, dimensions })
);
const sidebar = tabBarPosition === 'left' || tabBarPosition === 'right';
const spacing =
tabBarVariant === 'material' ? SPACING_MATERIAL : SPACING_UIKIT;
const minSidebarWidth = useFrameSize((size) =>
sidebar && hasHorizontalLabels ? getDefaultSidebarWidth(size) : 0
);
const tabBarBackgroundElement = tabBarBackground?.();
return (
<Animated.View
style={[
tabBarPosition === 'left'
? styles.start
: tabBarPosition === 'right'
? styles.end
: styles.bottom,
(
Platform.OS === 'web'
? tabBarPosition === 'right'
: (direction === 'rtl' && tabBarPosition === 'left') ||
(direction !== 'rtl' && tabBarPosition === 'right')
)
? { borderLeftWidth: StyleSheet.hairlineWidth }
: (
Platform.OS === 'web'
? tabBarPosition === 'left'
: (direction === 'rtl' && tabBarPosition === 'right') ||
(direction !== 'rtl' && tabBarPosition === 'left')
)
? { borderRightWidth: StyleSheet.hairlineWidth }
: tabBarPosition === 'top'
? { borderBottomWidth: StyleSheet.hairlineWidth }
: { borderTopWidth: StyleSheet.hairlineWidth },
{
backgroundColor:
tabBarBackgroundElement != null ? 'transparent' : colors.card,
borderColor: colors.border,
},
sidebar
? {
paddingTop:
(hasHorizontalLabels ? spacing : spacing / 2) + insets.top,
paddingBottom:
(hasHorizontalLabels ? spacing : spacing / 2) + insets.bottom,
paddingStart:
spacing + (tabBarPosition === 'left' ? insets.left : 0),
paddingEnd:
spacing + (tabBarPosition === 'right' ? insets.right : 0),
minWidth: minSidebarWidth,
}
: [
{
transform: [
{
translateY: visible.interpolate({
inputRange: [0, 1],
outputRange: [
layout.height +
insets[tabBarPosition === 'top' ? 'top' : 'bottom'] +
StyleSheet.hairlineWidth,
0,
],
}),
},
],
// Absolutely position the tab bar so that the content is below it
// This is needed to avoid gap at bottom when the tab bar is hidden
position: isTabBarHidden ? 'absolute' : undefined,
},
{
height: tabBarHeight,
paddingBottom: tabBarPosition === 'bottom' ? insets.bottom : 0,
paddingTop: tabBarPosition === 'top' ? insets.top : 0,
paddingHorizontal: Math.max(insets.left, insets.right),
},
],
tabBarStyle,
]}
pointerEvents={isTabBarHidden ? 'none' : 'auto'}
onLayout={sidebar ? undefined : handleLayout}
>
<View pointerEvents="none" style={StyleSheet.absoluteFill}>
{tabBarBackgroundElement}
</View>
<View
role="tablist"
style={sidebar ? styles.sideContent : styles.bottomContent}
>
{routes.map((route, index) => {
const focused = index === state.index;
const { options } = descriptors[route.key];
const onPress = () => {
const event = navigation.emit({
type: 'tabPress',
target: route.key,
canPreventDefault: true,
});
if (!focused && !event.defaultPrevented) {
navigation.dispatch({
...CommonActions.navigate(route),
target: state.key,
});
}
};
const onLongPress = () => {
navigation.emit({
type: 'tabLongPress',
target: route.key,
});
};
const label =
typeof options.tabBarLabel === 'function'
? options.tabBarLabel
: getLabel(
{ label: options.tabBarLabel, title: options.title },
route.name
);
const accessibilityLabel =
options.tabBarAccessibilityLabel !== undefined
? options.tabBarAccessibilityLabel
: typeof label === 'string' && Platform.OS === 'ios'
? `${label}, tab, ${index + 1} of ${routes.length}`
: undefined;
return (
<NavigationContext.Provider
key={route.key}
value={descriptors[route.key].navigation}
>
<NavigationRouteContext.Provider value={route}>
<BottomTabItem
href={buildHref(route.name, route.params)}
route={route}
descriptor={descriptors[route.key]}
focused={focused}
horizontal={hasHorizontalLabels}
compact={compact}
sidebar={sidebar}
variant={tabBarVariant}
onPress={onPress}
onLongPress={onLongPress}
accessibilityLabel={accessibilityLabel}
testID={options.tabBarButtonTestID}
allowFontScaling={options.tabBarAllowFontScaling}
activeTintColor={tabBarActiveTintColor}
inactiveTintColor={tabBarInactiveTintColor}
activeBackgroundColor={tabBarActiveBackgroundColor}
inactiveBackgroundColor={tabBarInactiveBackgroundColor}
button={options.tabBarButton}
icon={
options.tabBarIcon ??
(({ color, size }) => (
<MissingIcon color={color} size={size} />
))
}
badge={options.tabBarBadge}
badgeStyle={options.tabBarBadgeStyle}
label={label}
showLabel={tabBarShowLabel}
labelStyle={options.tabBarLabelStyle}
iconStyle={options.tabBarIconStyle}
style={[
sidebar
? {
marginVertical: hasHorizontalLabels
? tabBarVariant === 'material'
? 0
: 1
: spacing / 2,
}
: styles.bottomItem,
options.tabBarItemStyle,
]}
/>
</NavigationRouteContext.Provider>
</NavigationContext.Provider>
);
})}
</View>
</Animated.View>
);
}
const styles = StyleSheet.create({
start: {
top: 0,
bottom: 0,
start: 0,
},
end: {
top: 0,
bottom: 0,
end: 0,
},
bottom: {
start: 0,
end: 0,
bottom: 0,
elevation: 8,
},
bottomContent: {
flex: 1,
flexDirection: 'row',
},
sideContent: {
flex: 1,
flexDirection: 'column',
},
bottomItem: {
flex: 1,
},
});
@@ -0,0 +1,435 @@
import { getLabel, Label, PlatformPressable } from '@react-navigation/elements';
import { type Route, useTheme } from '@react-navigation/native';
import Color from 'color';
import React from 'react';
import {
type GestureResponderEvent,
Platform,
type StyleProp,
StyleSheet,
type TextStyle,
View,
type ViewStyle,
} from 'react-native';
import type {
BottomTabBarButtonProps,
BottomTabDescriptor,
LabelPosition,
} from '../types';
import { TabBarIcon } from './TabBarIcon';
type Props = {
/**
* The route object which should be specified by the tab.
*/
route: Route<string>;
/**
* The `href` to use for the anchor tag on web
*/
href?: string;
/**
* Whether the tab is focused.
*/
focused: boolean;
/**
* The descriptor object for the route.
*/
descriptor: BottomTabDescriptor;
/**
* The label text of the tab.
*/
label:
| string
| ((props: {
focused: boolean;
color: string;
position: LabelPosition;
children: string;
}) => React.ReactNode);
/**
* Icon to display for the tab.
*/
icon: (props: {
focused: boolean;
size: number;
color: string;
}) => React.ReactNode;
/**
* Text to show in a badge on the tab icon.
*/
badge?: number | string;
/**
* Custom style for the badge.
*/
badgeStyle?: StyleProp<TextStyle>;
/**
* The button for the tab. Uses a `Pressable` by default.
*/
button?: (props: BottomTabBarButtonProps) => React.ReactNode;
/**
* The accessibility label for the tab.
*/
accessibilityLabel?: string;
/**
* An unique ID for testing for the tab.
*/
testID?: string;
/**
* Function to execute on press in React Native.
* On the web, this will use onClick.
*/
onPress: (
e: React.MouseEvent<HTMLElement, MouseEvent> | GestureResponderEvent
) => void;
/**
* Function to execute on long press.
*/
onLongPress: (e: GestureResponderEvent) => void;
/**
* Whether the label should be aligned with the icon horizontally.
*/
horizontal: boolean;
/**
* Whether to render the icon and label in compact mode.
*/
compact: boolean;
/**
* Whether the tab is an item in a side bar.
*/
sidebar: boolean;
/**
* Variant of navigation bar styling
* - `uikit`: iOS UIKit style
* - `material`: Material Design style
*/
variant: 'uikit' | 'material';
/**
* Color for the icon and label when the item is active.
*/
activeTintColor?: string;
/**
* Color for the icon and label when the item is inactive.
*/
inactiveTintColor?: string;
/**
* Background color for item when its active.
*/
activeBackgroundColor?: string;
/**
* Background color for item when its inactive.
*/
inactiveBackgroundColor?: string;
/**
* Whether to show the label text for the tab.
*/
showLabel?: boolean;
/**
* Whether to allow scaling the font for the label for accessibility purposes.
* Defaults to `false` on iOS 13+ where it uses `largeContentTitle`.
*/
allowFontScaling?: boolean;
/**
* Style object for the label element.
*/
labelStyle?: StyleProp<TextStyle>;
/**
* Style object for the icon element.
*/
iconStyle?: StyleProp<ViewStyle>;
/**
* Style object for the wrapper element.
*/
style?: StyleProp<ViewStyle>;
};
const renderButtonDefault = (props: BottomTabBarButtonProps) => (
<PlatformPressable {...props} />
);
const SUPPORTS_LARGE_CONTENT_VIEWER =
Platform.OS === 'ios' && parseInt(Platform.Version, 10) >= 13;
export function BottomTabItem({
route,
href,
focused,
descriptor,
label,
icon,
badge,
badgeStyle,
button = renderButtonDefault,
accessibilityLabel,
testID,
onPress,
onLongPress,
horizontal,
compact,
sidebar,
variant,
activeTintColor: customActiveTintColor,
inactiveTintColor: customInactiveTintColor,
activeBackgroundColor: customActiveBackgroundColor,
inactiveBackgroundColor = 'transparent',
showLabel = true,
// On iOS 13+, we use `largeContentTitle` for accessibility
// So we don't need the font to scale up
// https://developer.apple.com/documentation/uikit/uiview/3183939-largecontenttitle
allowFontScaling = SUPPORTS_LARGE_CONTENT_VIEWER ? false : undefined,
labelStyle,
iconStyle,
style,
}: Props) {
const { colors, fonts } = useTheme();
const activeTintColor =
customActiveTintColor ??
(variant === 'uikit' && sidebar && horizontal
? Color(colors.primary).isDark()
? 'white'
: Color(colors.primary).darken(0.71).string()
: colors.primary);
const inactiveTintColor =
customInactiveTintColor === undefined
? variant === 'material'
? Color(colors.text).alpha(0.68).rgb().string()
: Color(colors.text).mix(Color(colors.card), 0.5).hex()
: customInactiveTintColor;
const activeBackgroundColor =
customActiveBackgroundColor ??
(variant === 'material'
? Color(activeTintColor).alpha(0.12).rgb().string()
: sidebar && horizontal
? colors.primary
: 'transparent');
const { options } = descriptor;
const labelString = getLabel(
{
label:
typeof options.tabBarLabel === 'string'
? options.tabBarLabel
: undefined,
title: options.title,
},
route.name
);
let labelInactiveTintColor = inactiveTintColor;
let iconInactiveTintColor = inactiveTintColor;
if (
variant === 'uikit' &&
sidebar &&
horizontal &&
customInactiveTintColor === undefined
) {
iconInactiveTintColor = colors.primary;
labelInactiveTintColor = colors.text;
}
const renderLabel = ({ focused }: { focused: boolean }) => {
if (showLabel === false) {
return null;
}
const color = focused ? activeTintColor : labelInactiveTintColor;
if (typeof label !== 'string') {
return label({
focused,
color,
position: horizontal ? 'beside-icon' : 'below-icon',
children: labelString,
});
}
return (
<Label
style={[
horizontal
? [
styles.labelBeside,
variant === 'material'
? styles.labelSidebarMaterial
: sidebar
? styles.labelSidebarUiKit
: compact
? styles.labelBesideUikitCompact
: styles.labelBesideUikit,
icon == null && { marginStart: 0 },
]
: styles.labelBeneath,
compact || (variant === 'uikit' && sidebar && horizontal)
? fonts.regular
: fonts.medium,
labelStyle,
]}
allowFontScaling={allowFontScaling}
tintColor={color}
>
{label}
</Label>
);
};
const renderIcon = ({ focused }: { focused: boolean }) => {
if (icon === undefined) {
return null;
}
const activeOpacity = focused ? 1 : 0;
const inactiveOpacity = focused ? 0 : 1;
return (
<TabBarIcon
route={route}
variant={variant}
size={compact ? 'compact' : 'regular'}
badge={badge}
badgeStyle={badgeStyle}
activeOpacity={activeOpacity}
allowFontScaling={allowFontScaling}
inactiveOpacity={inactiveOpacity}
activeTintColor={activeTintColor}
inactiveTintColor={iconInactiveTintColor}
renderIcon={icon}
style={iconStyle}
/>
);
};
const scene = { route, focused };
const backgroundColor = focused
? activeBackgroundColor
: inactiveBackgroundColor;
const { flex } = StyleSheet.flatten(style || {});
const borderRadius =
variant === 'material'
? horizontal
? 56
: 16
: sidebar && horizontal
? 10
: 0;
return (
<View
style={[
// Clip ripple effect on Android
{
borderRadius,
overflow: variant === 'material' ? 'hidden' : 'visible',
},
style,
]}
>
{button({
href,
onPress,
onLongPress,
testID,
'aria-label': accessibilityLabel,
'accessibilityLargeContentTitle': labelString,
'accessibilityShowsLargeContentViewer': true,
// FIXME: role: 'tab' doesn't seem to work as expected on iOS
'role': Platform.select({ ios: 'button', default: 'tab' }),
'aria-selected': focused,
'android_ripple': { borderless: true },
'hoverEffect':
variant === 'material' || (sidebar && horizontal)
? { color: colors.text }
: undefined,
'pressOpacity': 1,
'style': [
styles.tab,
{ flex, backgroundColor, borderRadius },
sidebar
? variant === 'material'
? horizontal
? styles.tabBarSidebarMaterial
: styles.tabVerticalMaterial
: horizontal
? styles.tabBarSidebarUiKit
: styles.tabVerticalUiKit
: variant === 'material'
? styles.tabVerticalMaterial
: horizontal
? styles.tabHorizontalUiKit
: styles.tabVerticalUiKit,
],
'children': (
<React.Fragment>
{renderIcon(scene)}
{renderLabel(scene)}
</React.Fragment>
),
})}
</View>
);
}
const styles = StyleSheet.create({
tab: {
alignItems: 'center',
// Roundness for iPad hover effect
borderRadius: 10,
borderCurve: 'continuous',
},
tabVerticalUiKit: {
justifyContent: 'flex-start',
flexDirection: 'column',
padding: 5,
},
tabVerticalMaterial: {
padding: 10,
},
tabHorizontalUiKit: {
justifyContent: 'center',
alignItems: 'center',
flexDirection: 'row',
padding: 5,
},
tabBarSidebarUiKit: {
justifyContent: 'flex-start',
alignItems: 'center',
flexDirection: 'row',
paddingVertical: 7,
paddingHorizontal: 5,
},
tabBarSidebarMaterial: {
justifyContent: 'flex-start',
alignItems: 'center',
flexDirection: 'row',
paddingVertical: 15,
paddingStart: 16,
paddingEnd: 24,
},
labelSidebarMaterial: {
marginStart: 12,
},
labelSidebarUiKit: {
fontSize: 17,
marginStart: 10,
},
labelBeneath: {
fontSize: 10,
},
labelBeside: {
marginEnd: 12,
lineHeight: 24,
},
labelBesideUikit: {
fontSize: 13,
marginStart: 5,
},
labelBesideUikitCompact: {
fontSize: 12,
marginStart: 5,
},
});
@@ -0,0 +1,365 @@
import {
getHeaderTitle,
Header,
SafeAreaProviderCompat,
Screen,
} from '@react-navigation/elements';
import {
type NavigationAction,
type ParamListBase,
StackActions,
type TabNavigationState,
} from '@react-navigation/native';
import * as React from 'react';
import { Animated, Platform, StyleSheet } from 'react-native';
import { SafeAreaInsetsContext } from 'react-native-safe-area-context';
import {
FadeTransition,
ShiftTransition,
} from '../TransitionConfigs/TransitionPresets';
import type {
BottomTabBarProps,
BottomTabDescriptorMap,
BottomTabHeaderProps,
BottomTabNavigationConfig,
BottomTabNavigationHelpers,
BottomTabNavigationOptions,
BottomTabNavigationProp,
} from '../types';
import { BottomTabBarHeightCallbackContext } from '../utils/BottomTabBarHeightCallbackContext';
import { BottomTabBarHeightContext } from '../utils/BottomTabBarHeightContext';
import { useAnimatedHashMap } from '../utils/useAnimatedHashMap';
import { BottomTabBar, getTabBarHeight } from './BottomTabBar';
import { MaybeScreen, MaybeScreenContainer } from './ScreenFallback';
type Props = BottomTabNavigationConfig & {
state: TabNavigationState<ParamListBase>;
navigation: BottomTabNavigationHelpers;
descriptors: BottomTabDescriptorMap;
};
const EPSILON = 1e-5;
const STATE_INACTIVE = 0;
const STATE_TRANSITIONING_OR_BELOW_TOP = 1;
const STATE_ON_TOP = 2;
const NAMED_TRANSITIONS_PRESETS = {
fade: FadeTransition,
shift: ShiftTransition,
none: {
sceneStyleInterpolator: undefined,
transitionSpec: {
animation: 'timing',
config: { duration: 0 },
},
},
} as const;
const useNativeDriver = Platform.OS !== 'web';
const hasAnimation = (options: BottomTabNavigationOptions) => {
const { animation, transitionSpec } = options;
if (animation) {
return animation !== 'none';
}
return Boolean(transitionSpec);
};
const renderTabBarDefault = (props: BottomTabBarProps) => (
<BottomTabBar {...props} />
);
export function BottomTabView(props: Props) {
const {
tabBar = renderTabBarDefault,
state,
navigation,
descriptors,
safeAreaInsets,
detachInactiveScreens = Platform.OS === 'web' ||
Platform.OS === 'android' ||
Platform.OS === 'ios',
} = props;
const focusedRouteKey = state.routes[state.index].key;
/**
* List of loaded tabs, tabs will be loaded when navigated to.
*/
const [loaded, setLoaded] = React.useState([focusedRouteKey]);
if (!loaded.includes(focusedRouteKey)) {
// Set the current tab to be loaded if it was not loaded before
setLoaded([...loaded, focusedRouteKey]);
}
const previousRouteKeyRef = React.useRef(focusedRouteKey);
const tabAnims = useAnimatedHashMap(state);
React.useEffect(() => {
const previousRouteKey = previousRouteKeyRef.current;
let popToTopAction: NavigationAction | undefined;
if (
previousRouteKey !== focusedRouteKey &&
descriptors[previousRouteKey]?.options.popToTopOnBlur
) {
const prevRoute = state.routes.find(
(route) => route.key === previousRouteKey
);
if (prevRoute?.state?.type === 'stack' && prevRoute.state.key) {
popToTopAction = {
...StackActions.popToTop(),
target: prevRoute.state.key,
};
}
}
const animateToIndex = () => {
if (previousRouteKey !== focusedRouteKey) {
navigation.emit({
type: 'transitionStart',
target: focusedRouteKey,
});
}
Animated.parallel(
state.routes
.map((route, index) => {
const { options } = descriptors[route.key];
const {
animation = 'none',
transitionSpec = NAMED_TRANSITIONS_PRESETS[animation]
.transitionSpec,
} = options;
let spec = transitionSpec;
if (
route.key !== previousRouteKey &&
route.key !== focusedRouteKey
) {
// Don't animate if the screen is not previous one or new one
// This will avoid flicker for screens not involved in the transition
spec = NAMED_TRANSITIONS_PRESETS.none.transitionSpec;
}
spec = spec ?? NAMED_TRANSITIONS_PRESETS.none.transitionSpec;
const toValue =
index === state.index ? 0 : index >= state.index ? 1 : -1;
return Animated[spec.animation](tabAnims[route.key], {
...spec.config,
toValue,
useNativeDriver,
});
})
.filter(Boolean) as Animated.CompositeAnimation[]
).start(({ finished }) => {
if (finished && popToTopAction) {
navigation.dispatch(popToTopAction);
}
if (previousRouteKey !== focusedRouteKey) {
navigation.emit({
type: 'transitionEnd',
target: focusedRouteKey,
});
}
});
};
animateToIndex();
previousRouteKeyRef.current = focusedRouteKey;
}, [
descriptors,
focusedRouteKey,
navigation,
state.index,
state.routes,
tabAnims,
]);
const dimensions = SafeAreaProviderCompat.initialMetrics.frame;
const [tabBarHeight, setTabBarHeight] = React.useState(() =>
getTabBarHeight({
state,
descriptors,
dimensions,
insets: {
...SafeAreaProviderCompat.initialMetrics.insets,
...props.safeAreaInsets,
},
style: descriptors[state.routes[state.index].key].options.tabBarStyle,
})
);
const renderTabBar = () => {
return (
<SafeAreaInsetsContext.Consumer>
{(insets) =>
tabBar({
state: state,
descriptors: descriptors,
navigation: navigation,
insets: {
top: safeAreaInsets?.top ?? insets?.top ?? 0,
right: safeAreaInsets?.right ?? insets?.right ?? 0,
bottom: safeAreaInsets?.bottom ?? insets?.bottom ?? 0,
left: safeAreaInsets?.left ?? insets?.left ?? 0,
},
})
}
</SafeAreaInsetsContext.Consumer>
);
};
const { routes } = state;
// If there is no animation, we only have 2 states: visible and invisible
const hasTwoStates = !routes.some((route) =>
hasAnimation(descriptors[route.key].options)
);
const { tabBarPosition = 'bottom' } = descriptors[focusedRouteKey].options;
const tabBarElement = (
<BottomTabBarHeightCallbackContext.Provider
key="tabbar"
value={setTabBarHeight}
>
{renderTabBar()}
</BottomTabBarHeightCallbackContext.Provider>
);
return (
<SafeAreaProviderCompat
style={{
flexDirection:
tabBarPosition === 'left' || tabBarPosition === 'right'
? 'row'
: 'column',
}}
>
{tabBarPosition === 'top' || tabBarPosition === 'left'
? tabBarElement
: null}
<MaybeScreenContainer
key="screens"
enabled={detachInactiveScreens}
hasTwoStates={hasTwoStates}
style={styles.screens}
>
{routes.map((route, index) => {
const descriptor = descriptors[route.key];
const {
lazy = true,
animation = 'none',
sceneStyleInterpolator = NAMED_TRANSITIONS_PRESETS[animation]
.sceneStyleInterpolator,
} = descriptor.options;
const isFocused = state.index === index;
const isPreloaded = state.preloadedRouteKeys.includes(route.key);
if (
lazy &&
!loaded.includes(route.key) &&
!isFocused &&
!isPreloaded
) {
// Don't render a lazy screen if we've never navigated to it or it wasn't preloaded
return null;
}
const {
freezeOnBlur,
header = ({ layout, options }: BottomTabHeaderProps) => (
<Header
{...options}
layout={layout}
title={getHeaderTitle(options, route.name)}
/>
),
headerShown,
headerStatusBarHeight,
headerTransparent,
sceneStyle: customSceneStyle,
} = descriptor.options;
const { sceneStyle } =
sceneStyleInterpolator?.({
current: {
progress: tabAnims[route.key],
},
}) ?? {};
const animationEnabled = hasAnimation(descriptor.options);
const activityState = isFocused
? STATE_ON_TOP // the screen is on top after the transition
: animationEnabled // is animation is not enabled, immediately move to inactive state
? tabAnims[route.key].interpolate({
inputRange: [0, 1 - EPSILON, 1],
outputRange: [
STATE_TRANSITIONING_OR_BELOW_TOP, // screen visible during transition
STATE_TRANSITIONING_OR_BELOW_TOP,
STATE_INACTIVE, // the screen is detached after transition
],
extrapolate: 'extend',
})
: STATE_INACTIVE;
return (
<MaybeScreen
key={route.key}
style={[StyleSheet.absoluteFill, { zIndex: isFocused ? 0 : -1 }]}
active={activityState}
enabled={detachInactiveScreens}
freezeOnBlur={freezeOnBlur}
shouldFreeze={activityState === STATE_INACTIVE && !isPreloaded}
>
<BottomTabBarHeightContext.Provider
value={tabBarPosition === 'bottom' ? tabBarHeight : 0}
>
<Screen
focused={isFocused}
route={descriptor.route}
navigation={descriptor.navigation}
headerShown={headerShown}
headerStatusBarHeight={headerStatusBarHeight}
headerTransparent={headerTransparent}
header={header({
layout: dimensions,
route: descriptor.route,
navigation:
descriptor.navigation as BottomTabNavigationProp<ParamListBase>,
options: descriptor.options,
})}
style={[customSceneStyle, animationEnabled && sceneStyle]}
>
{descriptor.render()}
</Screen>
</BottomTabBarHeightContext.Provider>
</MaybeScreen>
);
})}
</MaybeScreenContainer>
{tabBarPosition === 'bottom' || tabBarPosition === 'right'
? tabBarElement
: null}
</SafeAreaProviderCompat>
);
}
const styles = StyleSheet.create({
screens: {
flex: 1,
overflow: 'hidden',
},
});
@@ -0,0 +1,50 @@
import * as React from 'react';
import {
Animated,
type StyleProp,
View,
type ViewProps,
type ViewStyle,
} from 'react-native';
type Props = {
enabled: boolean;
active: 0 | 1 | 2 | Animated.AnimatedInterpolation<0 | 1>;
children: React.ReactNode;
freezeOnBlur?: boolean;
shouldFreeze: boolean;
style?: StyleProp<ViewStyle>;
};
let Screens: typeof import('react-native-screens') | undefined;
try {
Screens = require('react-native-screens');
} catch (e) {
// Ignore
}
export const MaybeScreenContainer = ({
enabled,
...rest
}: ViewProps & {
enabled: boolean;
hasTwoStates: boolean;
children: React.ReactNode;
}) => {
if (Screens?.screensEnabled?.()) {
return <Screens.ScreenContainer enabled={enabled} {...rest} />;
}
return <View {...rest} />;
};
export function MaybeScreen({ enabled, active, ...rest }: ViewProps & Props) {
if (Screens?.screensEnabled?.()) {
return (
<Screens.Screen enabled={enabled} activityState={active} {...rest} />
);
}
return <View {...rest} />;
}
@@ -0,0 +1,141 @@
import { Badge } from '@react-navigation/elements';
import type { Route } from '@react-navigation/native';
import React from 'react';
import {
type StyleProp,
StyleSheet,
type TextStyle,
View,
type ViewStyle,
} from 'react-native';
type Props = {
route: Route<string>;
variant: 'uikit' | 'material';
size: 'compact' | 'regular';
badge?: string | number;
badgeStyle?: StyleProp<TextStyle>;
activeOpacity: number;
inactiveOpacity: number;
activeTintColor: string;
inactiveTintColor: string;
renderIcon: (props: {
focused: boolean;
color: string;
size: number;
}) => React.ReactNode;
allowFontScaling?: boolean;
style: StyleProp<ViewStyle>;
};
/**
* Icon sizes taken from Apple HIG
* https://developer.apple.com/design/human-interface-guidelines/tab-bars
*/
const ICON_SIZE_WIDE = 31;
const ICON_SIZE_WIDE_COMPACT = 23;
const ICON_SIZE_TALL = 28;
const ICON_SIZE_TALL_COMPACT = 20;
const ICON_SIZE_ROUND = 25;
const ICON_SIZE_ROUND_COMPACT = 18;
const ICON_SIZE_MATERIAL = 24;
export function TabBarIcon({
route: _,
variant,
size,
badge,
badgeStyle,
activeOpacity,
inactiveOpacity,
activeTintColor,
inactiveTintColor,
renderIcon,
allowFontScaling,
style,
}: Props) {
const iconSize =
variant === 'material'
? ICON_SIZE_MATERIAL
: size === 'compact'
? ICON_SIZE_ROUND_COMPACT
: ICON_SIZE_ROUND;
// We render the icon twice at the same position on top of each other:
// active and inactive one, so we can fade between them.
return (
<View
style={[
variant === 'material'
? styles.wrapperMaterial
: size === 'compact'
? styles.wrapperUikitCompact
: styles.wrapperUikit,
style,
]}
>
<View
style={[
styles.icon,
{
opacity: activeOpacity,
// Workaround for react-native >= 0.54 layout bug
minWidth: iconSize,
},
]}
>
{renderIcon({
focused: true,
size: iconSize,
color: activeTintColor,
})}
</View>
<View style={[styles.icon, { opacity: inactiveOpacity }]}>
{renderIcon({
focused: false,
size: iconSize,
color: inactiveTintColor,
})}
</View>
<Badge
visible={badge != null}
size={iconSize * 0.75}
allowFontScaling={allowFontScaling}
style={[styles.badge, badgeStyle]}
>
{badge}
</Badge>
</View>
);
}
const styles = StyleSheet.create({
icon: {
// We render the icon twice at the same position on top of each other:
// active and inactive one, so we can fade between them:
// Cover the whole iconContainer:
position: 'absolute',
alignSelf: 'center',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
width: '100%',
},
wrapperUikit: {
width: ICON_SIZE_WIDE,
height: ICON_SIZE_TALL,
},
wrapperUikitCompact: {
width: ICON_SIZE_WIDE_COMPACT,
height: ICON_SIZE_TALL_COMPACT,
},
wrapperMaterial: {
width: ICON_SIZE_MATERIAL,
height: ICON_SIZE_MATERIAL,
},
badge: {
position: 'absolute',
end: -3,
top: -3,
},
});