diff --git a/docs/6.x/docs/guides/bottom-navigation.md b/docs/6.x/docs/guides/bottom-navigation.md
index 380a8b8be3..ebae22e855 100644
--- a/docs/6.x/docs/guides/bottom-navigation.md
+++ b/docs/6.x/docs/guides/bottom-navigation.md
@@ -5,7 +5,7 @@ title: Using BottomNavigation with React Navigation
Build a Material Design bottom tab bar by combining two pieces:
- `@react-navigation/bottom-tabs` handles routing, state, and screen options.
-- `BottomNavigation.Bar` renders the Material 3 tab bar (ripple, badges, shifting/labeled modes).
+- `BottomNavigation.Bar` renders the Material 3 Expressive tab bar (active indicator, badges, optional shifting labels, and vertical or horizontal items).
@@ -36,6 +36,7 @@ function MyTabs() {
{
const event = navigation.emit({
type: 'tabPress',
diff --git a/docs/6.x/docs/guides/migration.md b/docs/6.x/docs/guides/migration.md
index 35a8f74830..364680709a 100644
--- a/docs/6.x/docs/guides/migration.md
+++ b/docs/6.x/docs/guides/migration.md
@@ -13,6 +13,7 @@ The following props now accept animated styles returned from `useAnimatedStyle`.
- `Appbar.Action` and `Appbar.BackAction`: `style`
- `Badge`: `style`
- `Banner`: `style`
+- `BottomNavigation`: `barStyle`, and `BottomNavigation.Bar`: `style`
- `Button`: `style`
- `Card`: `style`
- `Chip`: `style`
@@ -109,6 +110,31 @@ Some components now accept explicit `testID` props for their interactable elemen
## Components
+### BottomNavigation
+
+The bar follows the Material Design 3 Expressive navigation bar spec.
+
+- Height is 64dp (was 80dp when unlabeled, and the label used a 56dp height constant).
+- The active indicator is 56×32 with a full corner, and it now stays mounted so the pill can scale and fade with a spatial spring.
+- Active labels use the `secondary` color and `labelMediumEmphasized`. Horizontal items (medium windows) place the label on the indicator and use `onSecondaryContainer`.
+- Destinations use visible state layers. The previous `rippleColor: 'transparent'` treatment is gone.
+- `itemLayout` selects `vertical`, `horizontal`, or `auto` (switches at 600dp). The default is `auto`.
+- `shifting` no longer translates icons or requires two tabs. It only fades inactive labels in place.
+- Scene and bar animations use Reanimated. `barStyle` / `BottomNavigation.Bar` `style` accept Reanimated animated styles, not `Animated.Value`. `sceneAnimationEasing` is an `(value: number) => number` function.
+- Screens still lazy-mount on first visit. Route updates only remount a destination when its `key` changes; inactive screens keep their mounted state.
+
+```diff
+
+```
+
### Appbar
The `style` props for `Appbar` and `Appbar.Header` no longer accept `Animated.Value` or `Animated.AnimatedInterpolation`. They only accept static styles.
diff --git a/docs/src/data/extendedExamples/BottomNavigationBar.ts b/docs/src/data/extendedExamples/BottomNavigationBar.ts
index 7a49db0bce..7cd24c402d 100644
--- a/docs/src/data/extendedExamples/BottomNavigationBar.ts
+++ b/docs/src/data/extendedExamples/BottomNavigationBar.ts
@@ -31,6 +31,7 @@ const MyTabs = createBottomTabNavigator({
{
const event = navigation.emit({
type: 'tabPress',
@@ -135,6 +136,7 @@ export default function App() {
{
const event = navigation.emit({
type: 'tabPress',
diff --git a/example/src/Examples/BottomNavigationBarExample.tsx b/example/src/Examples/BottomNavigationBarExample.tsx
index 9fbddf7f23..e89c50df21 100644
--- a/example/src/Examples/BottomNavigationBarExample.tsx
+++ b/example/src/Examples/BottomNavigationBarExample.tsx
@@ -36,6 +36,7 @@ const BottomNavigationBarExample = createBottomTabNavigator({
tabBar: ({ navigation, state, descriptors }) => (
{
const event = navigation.emit({
type: 'tabPress',
diff --git a/example/src/Examples/BottomNavigationExample.tsx b/example/src/Examples/BottomNavigationExample.tsx
index b66bb474d6..766b7eba58 100644
--- a/example/src/Examples/BottomNavigationExample.tsx
+++ b/example/src/Examples/BottomNavigationExample.tsx
@@ -1,21 +1,20 @@
import * as React from 'react';
-import {
- Dimensions,
- Easing,
- Image,
- Platform,
- StyleSheet,
- View,
-} from 'react-native';
+import { Dimensions, Image, Platform, StyleSheet, View } from 'react-native';
import { useNavigation } from '@react-navigation/native';
import { Appbar, BottomNavigation, Menu } from 'react-native-paper';
-import type { BottomNavigationRoute } from 'react-native-paper';
+import type {
+ BottomNavigationItemLayout,
+ BottomNavigationRoute,
+} from 'react-native-paper';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import ScreenWrapper from '../ScreenWrapper';
type Route = { route: { key: string } };
+type SceneAnimation = React.ComponentProps<
+ typeof BottomNavigation
+>['sceneAnimationType'];
const MORE_ICON = Platform.OS === 'ios' ? 'dots-horizontal' : 'dots-vertical';
@@ -40,16 +39,24 @@ const PhotoGallery = ({ route }: Route) => {
);
};
+const renderScene = BottomNavigation.SceneMap({
+ album: PhotoGallery,
+ library: PhotoGallery,
+ favorites: PhotoGallery,
+ purchased: PhotoGallery,
+});
+
const BottomNavigationExample = () => {
const navigation = useNavigation('BottomNavigation');
const insets = useSafeAreaInsets();
const [index, setIndex] = React.useState(0);
const [menuVisible, setMenuVisible] = React.useState(false);
- const [sceneAnimation, setSceneAnimation] =
- React.useState<
- React.ComponentProps['sceneAnimationType']
- >();
+ const [sceneAnimation, setSceneAnimation] = React.useState();
+ const [labeled, setLabeled] = React.useState(true);
+ const [shifting, setShifting] = React.useState(false);
+ const [itemLayout, setItemLayout] =
+ React.useState('auto');
const [routes] = React.useState([
{
@@ -75,6 +82,7 @@ const BottomNavigationExample = () => {
title: 'Purchased',
focusedIcon: 'shopping',
unfocusedIcon: 'shopping-outline',
+ badge: 3,
},
]);
@@ -123,6 +131,46 @@ const BottomNavigationExample = () => {
}}
title="Scene animation: opacity"
/>
+ {
+ setLabeled((value) => !value);
+ setMenuVisible(false);
+ }}
+ title={labeled ? 'Labels: on' : 'Labels: off'}
+ />
+ {
+ setShifting((value) => !value);
+ setMenuVisible(false);
+ }}
+ title={shifting ? 'Shifting labels: on' : 'Shifting labels: off'}
+ />
+ {
+ setItemLayout('auto');
+ setMenuVisible(false);
+ }}
+ title="Layout: auto"
+ />
+ {
+ setItemLayout('vertical');
+ setMenuVisible(false);
+ }}
+ title="Layout: vertical"
+ />
+ {
+ setItemLayout('horizontal');
+ setMenuVisible(false);
+ }}
+ title="Layout: horizontal"
+ />
{
navigationState={{ index, routes }}
onIndexChange={setIndex}
labelMaxFontSizeMultiplier={2}
- renderScene={BottomNavigation.SceneMap({
- album: PhotoGallery,
- library: PhotoGallery,
- favorites: PhotoGallery,
- purchased: PhotoGallery,
- })}
+ labeled={labeled}
+ shifting={shifting}
+ itemLayout={itemLayout}
+ renderScene={renderScene}
sceneAnimationEnabled={sceneAnimation !== undefined}
sceneAnimationType={sceneAnimation}
- sceneAnimationEasing={Easing.ease}
getLazy={({ route }) => route.key !== 'album'}
/>
diff --git a/src/components/BottomNavigation/BottomNavigation.tsx b/src/components/BottomNavigation/BottomNavigation.tsx
index adecee59a3..33d1527378 100644
--- a/src/components/BottomNavigation/BottomNavigation.tsx
+++ b/src/components/BottomNavigation/BottomNavigation.tsx
@@ -1,249 +1,75 @@
import * as React from 'react';
-// eslint-disable-next-line no-restricted-imports -- TODO: migrate BottomNavigation to Reanimated.
-import { Animated, Platform, StyleSheet, View } from 'react-native';
-import type {
- ColorValue,
- EasingFunction,
- StyleProp,
- ViewStyle,
-} from 'react-native';
+import { Platform, StyleSheet, View } from 'react-native';
+import type { StyleProp, ViewStyle } from 'react-native';
+import { useSharedValue, withTiming } from 'react-native-reanimated';
import useLatestCallback from 'use-latest-callback';
import BottomNavigationBar from './BottomNavigationBar';
-import BottomNavigationRouteScreen from './BottomNavigationRouteScreen';
+import BottomNavigationScene from './BottomNavigationScene';
+import SceneMap from './SceneMap';
+import type {
+ BaseRoute,
+ BarProps,
+ SceneAnimationEasing,
+ SceneAnimationType,
+} from './types';
import { useInternalTheme } from '../../core/theming';
+import { useReduceMotion } from '../../theme/accessibility/ReduceMotionContext';
import type { ThemeProp } from '../../theme/types';
-import useAnimatedValueArray from '../../utils/useAnimatedValueArray';
-import type { IconSource } from '../Icon';
-import type { Props as TouchableRippleProps } from '../TouchableRipple/TouchableRipple';
-export type BaseRoute = {
- key: string;
- title?: string;
- focusedIcon?: IconSource;
- unfocusedIcon?: IconSource;
- badge?: string | number | boolean;
- 'aria-label'?: string;
- testID?: string;
- lazy?: boolean;
-};
+export type { BaseRoute, NavigationState } from './types';
-type NavigationState = {
- index: number;
- routes: Route[];
-};
-
-type TabPressEvent = {
- defaultPrevented: boolean;
- preventDefault(): void;
-};
-
-type TouchableProps = TouchableRippleProps & {
- key: string;
- route: Route;
- children: React.ReactNode;
- borderless?: boolean;
- centered?: boolean;
- rippleColor?: ColorValue;
-};
-
-export type Props = {
- /**
- * Whether the shifting style is used, the active tab icon shifts up to show the label and the inactive tabs won't have a label.
- *
- * By default, this is `false` with theme version 3 and `true` when you have more than 3 tabs.
- * Pass `shifting={false}` to explicitly disable this animation, or `shifting={true}` to always use this animation.
- * Note that you need at least 2 tabs be able to run this animation.
- */
- shifting?: boolean;
- /**
- * Whether to show labels in tabs. When `false`, only icons will be displayed.
- */
- labeled?: boolean;
- /**
- * Whether tabs should be spread across the entire width.
- */
- compact?: boolean;
- /**
- * State for the bottom navigation. The state should contain the following properties:
- *
- * - `index`: a number representing the index of the active route in the `routes` array
- * - `routes`: an array containing a list of route objects used for rendering the tabs
- *
- * Each route object should contain the following properties:
- *
- * - `key`: a unique key to identify the route (required)
- * - `title`: title of the route to use as the tab label
- * - `focusedIcon`: icon to use as the focused tab icon, can be a string, an image source or a react component @renamed Renamed from 'icon' to 'focusedIcon' in v5.x
- * - `unfocusedIcon`: icon to use as the unfocused tab icon, can be a string, an image source or a react component @supported Available in v5.x with theme version 3
- * - `badge`: badge to show on the tab icon, can be `true` to show a dot, `string` or `number` to show text.
- * - `aria-label`: accessibility label for the tab button
- * - `testID`: test id for the tab button
- *
- * Example:
- *
- * ```js
- * {
- * index: 1,
- * routes: [
- * { key: 'music', title: 'Favorites', focusedIcon: 'heart', unfocusedIcon: 'heart-outline'},
- * { key: 'albums', title: 'Albums', focusedIcon: 'album' },
- * { key: 'recents', title: 'Recents', focusedIcon: 'history' },
- * { key: 'notifications', title: 'Notifications', focusedIcon: 'bell', unfocusedIcon: 'bell-outline' },
- * ]
- * }
- * ```
- *
- * `BottomNavigation` is a controlled component, which means the `index` needs to be updated via the `onIndexChange` callback.
- */
- navigationState: NavigationState;
+export type Props = Omit<
+ BarProps,
+ 'onTabPress' | 'animationEasing' | 'style'
+> & {
/**
* Callback which is called on tab change, receives the index of the new tab as argument.
* The navigation state needs to be updated when it's called, otherwise the change is dropped.
*/
onIndexChange: (index: number) => void;
/**
- * Callback which returns a react element to render as the page for the tab. Receives an object containing the route as the argument:
- *
- * ```js
- * renderScene = ({ route, jumpTo }) => {
- * switch (route.key) {
- * case 'music':
- * return ;
- * case 'albums':
- * return ;
- * }
- * }
- * ```
+ * Callback which returns a react element to render as the page for the tab. Receives an object containing the route as the argument.
*
* Pages are lazily rendered, which means that a page will be rendered the first time you navigate to it.
* After initial render, all the pages stay rendered to preserve their state.
*
* You need to make sure that your individual routes implement a `shouldComponentUpdate` to improve the performance.
- * To make it easier to specify the components, you can use the `SceneMap` helper:
- *
- * ```js
- * renderScene = BottomNavigation.SceneMap({
- * music: MusicRoute,
- * albums: AlbumsRoute,
- * });
- * ```
- *
- * Specifying the components this way is easier and takes care of implementing a `shouldComponentUpdate` method.
- * Each component will receive the current route and a `jumpTo` method as it's props.
- * The `jumpTo` method can be used to navigate to other tabs programmatically:
- *
- * ```js
- * this.props.jumpTo('albums')
- * ```
+ * To make it easier to specify the components, you can use the `SceneMap` helper.
*/
renderScene: (props: {
route: Route;
jumpTo: (key: string) => void;
}) => React.ReactNode | null;
- /**
- * Callback which returns a React Element to be used as tab icon.
- */
- renderIcon?: (props: {
- route: Route;
- focused: boolean;
- color: ColorValue;
- }) => React.ReactNode;
- /**
- * Callback which React Element to be used as tab label.
- */
- renderLabel?: (props: {
- route: Route;
- focused: boolean;
- color: ColorValue;
- }) => React.ReactNode;
- /**
- * Callback which returns a React element to be used as the touchable for the tab item.
- * Renders a `TouchableRipple` on Android and `Pressable` on iOS.
- */
- renderTouchable?: (props: TouchableProps) => React.ReactNode;
- /**
- * Get accessibility label for the tab button. This is read by the screen reader when the user taps the tab.
- * Uses `route['aria-label']` by default.
- */
- getAccessibilityLabel?: (props: { route: Route }) => string | undefined;
- /**
- * Get badge for the tab, uses `route.badge` by default.
- */
- getBadge?: (props: { route: Route }) => boolean | number | string | undefined;
- /**
- * Get label text for the tab, uses `route.title` by default. Use `renderLabel` to replace label component.
- */
- getLabelText?: (props: { route: Route }) => string | undefined;
/**
* Get lazy for the current screen. Uses true by default.
*/
getLazy?: (props: { route: Route }) => boolean | undefined;
- /**
- * Get the id to locate this tab button in tests, uses `route.testID` by default.
- */
- getTestID?: (props: { route: Route }) => string | undefined;
/**
* Function to execute on tab press. It receives the route for the pressed tab, useful for things like scroll to top.
*/
- onTabPress?: (props: { route: Route } & TabPressEvent) => void;
- /**
- * Function to execute on tab long press. It receives the route for the pressed tab, useful for things like custom action when longed pressed.
- */
- onTabLongPress?: (props: { route: Route } & TabPressEvent) => void;
- /**
- * Custom color for icon and label in the active tab.
- */
- activeColor?: string;
+ onTabPress?: BarProps['onTabPress'];
/**
- * Custom color for icon and label in the inactive tab.
- */
- inactiveColor?: string;
- /**
- * Whether animation is enabled for scenes transitions in `shifting` mode.
- * By default, the scenes cross-fade during tab change when `shifting` is enabled.
- * Specify `sceneAnimationEnabled` as `false` to disable the animation.
+ * Whether animation is enabled for scene transitions.
+ * By default, scenes are not animated.
*/
sceneAnimationEnabled?: boolean;
/**
- * The scene animation effect. Specify `'shifting'` for a different effect.
+ * The scene animation effect. Specify `'shifting'` for a horizontal slide.
* By default, 'opacity' will be used.
*/
- sceneAnimationType?: 'opacity' | 'shifting';
- /**
- * The scene animation Easing.
- */
- sceneAnimationEasing?: EasingFunction | undefined;
- /**
- * Whether the bottom navigation bar is hidden when keyboard is shown.
- * On Android, this works best when [`windowSoftInputMode`](https://developer.android.com/guide/topics/manifest/activity-element#wsoft) is set to `adjustResize`.
- */
- keyboardHidesNavigationBar?: boolean;
+ sceneAnimationType?: SceneAnimationType;
/**
- * Safe area insets for the tab bar. This can be used to avoid elements like the navigation bar on Android and bottom safe area on iOS.
- * The bottom insets for iOS is added by default. You can override the behavior with this option.
+ * The scene animation easing. Accepts a `(value: number) => number` function,
+ * including easings from `react-native-reanimated`.
*/
- safeAreaInsets?: {
- top?: number;
- right?: number;
- bottom?: number;
- left?: number;
- };
+ sceneAnimationEasing?: SceneAnimationEasing;
/**
- * Style for the bottom navigation bar. You can pass a custom background color here:
- *
- * ```js
- * barStyle={{ backgroundColor: '#694fad' }}
- * ```
- */
- barStyle?: Animated.WithAnimatedValue>;
- /**
- * Specifies the largest possible scale a label font can reach.
+ * Style for the bottom navigation bar.
*/
- labelMaxFontSizeMultiplier?: number;
+ barStyle?: BarProps['style'];
style?: StyleProp;
- activeIndicatorStyle?: StyleProp;
/**
* @optional
*/
@@ -258,18 +84,13 @@ export type Props = {
barTestID?: string;
};
-const FAR_FAR_AWAY = Platform.OS === 'web' ? 0 : 9999;
-
-const SceneComponent = React.memo(({ component, ...rest }: any) =>
- React.createElement(component, rest)
-);
-
/**
* BottomNavigation provides quick navigation between top-level views of an app with a bottom navigation bar.
* It is primarily designed for use on mobile. If you want to use the navigation bar only see [`BottomNavigation.Bar`](BottomNavigationBar).
*
- * By default BottomNavigation uses primary color as a background, in dark theme with `adaptive` mode it will use surface colour instead.
- * See [Dark Theme](https://callstack.github.io/react-native-paper/docs/guides/theming#dark-theme) for more information.
+ * The bar follows the Material Design 3 Expressive navigation bar spec: 64dp height,
+ * 56×32 active indicator, `secondary` active labels, and an optional horizontal
+ * item layout on medium windows.
*
* ## Usage
* ```js
@@ -290,7 +111,7 @@ const SceneComponent = React.memo(({ component, ...rest }: any) =>
* { key: 'music', title: 'Favorites', focusedIcon: 'heart', unfocusedIcon: 'heart-outline'},
* { key: 'albums', title: 'Albums', focusedIcon: 'album' },
* { key: 'recents', title: 'Recents', focusedIcon: 'history' },
- * { key: 'notifications', title: 'Notifications', focusedIcon: 'bell', unfocusedIcon: 'bell-outline' },
+ * { key: 'notifications', title: 'Notifications', focusedIcon: 'bell', unfocusedIcon: 'bell-outline', badge: 3 },
* ]);
*
* const renderScene = BottomNavigation.SceneMap({
@@ -335,122 +156,55 @@ const BottomNavigation = ({
onTabPress,
onTabLongPress,
onIndexChange,
- shifting: shiftingProp,
+ shifting,
+ itemLayout,
safeAreaInsets,
labelMaxFontSizeMultiplier = 1,
- compact: compactProp,
+ compact,
testID,
barTestID,
theme: themeOverrides,
getLazy = ({ route }: { route: Route }) => route.lazy,
}: Props) => {
const theme = useInternalTheme(themeOverrides);
- const { scale } = theme.animation;
- const compact = compactProp ?? false;
- let shifting = shiftingProp ?? false;
-
- if (shifting && navigationState.routes.length < 2) {
- shifting = false;
- console.warn(
- 'BottomNavigation needs at least 2 tabs to run shifting animation'
- );
- }
-
+ const reduceMotion = useReduceMotion();
const focusedKey = navigationState.routes[navigationState.index].key;
+ const activeIndex = useSharedValue(navigationState.index);
- /**
- * Active state of individual tab item positions:
- * -1 if they're before the active tab, 0 if they're active, 1 if they're after the active tab
- */
- const tabsPositionAnims = useAnimatedValueArray(
- navigationState.routes.map((_, i) =>
- i === navigationState.index ? 0 : i >= navigationState.index ? 1 : -1
- )
- );
-
- /**
- * The top offset for each tab item to position it offscreen.
- * Placing items offscreen helps to save memory usage for inactive screens with removeClippedSubviews.
- * We use animated values for this to prevent unnecessary re-renders.
- */
- const offsetsAnims = useAnimatedValueArray(
- navigationState.routes.map(
- // offscreen === 1, normal === 0
- (_, i) => (i === navigationState.index ? 0 : 1)
- )
+ const [loaded, setLoaded] = React.useState(
+ () => new Set([focusedKey])
);
- /**
- * List of loaded tabs, tabs will be loaded when navigated to.
- */
- const [loaded, setLoaded] = React.useState([focusedKey]);
-
- if (!loaded.includes(focusedKey)) {
- // Set the current tab to be loaded if it was not loaded before
- setLoaded((loaded) => [...loaded, focusedKey]);
+ if (!loaded.has(focusedKey)) {
+ setLoaded((current) => {
+ const next = new Set(current);
+ next.add(focusedKey);
+ return next;
+ });
}
- const animateToIndex = React.useCallback(
- (index: number) => {
- Animated.parallel([
- ...navigationState.routes.map((_, i) =>
- Animated.timing(tabsPositionAnims[i], {
- toValue: i === index ? 0 : i >= index ? 1 : -1,
- duration: 150 * scale,
- useNativeDriver: true,
- easing: sceneAnimationEasing,
- })
- ),
- ]).start(({ finished }) => {
- if (finished) {
- // Position all inactive screens offscreen to save memory usage
- // Only do it when animation has finished to avoid glitches mid-transition if switching fast
- offsetsAnims.forEach((offset, i) => {
- if (i === index) {
- offset.setValue(0);
- } else {
- offset.setValue(1);
- }
- });
- }
- });
- },
- [
- navigationState.routes,
- offsetsAnims,
- scale,
- tabsPositionAnims,
- sceneAnimationEasing,
- ]
- );
-
React.useEffect(() => {
- // Workaround for native animated bug in react-native@^0.57
- // Context: https://github.com/callstack/react-native-paper/pull/637
- animateToIndex(navigationState.index);
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, []);
-
- const prevNavigationState = React.useRef | undefined>(
- undefined
- );
+ if (!sceneAnimationEnabled || reduceMotion) {
+ activeIndex.value = navigationState.index;
+ return;
+ }
- React.useEffect(() => {
- // Reset offsets of previous and current tabs before animation
- offsetsAnims.forEach((offset, i) => {
- if (
- i === navigationState.index ||
- i === prevNavigationState.current?.index
- ) {
- offset.setValue(0);
- }
+ activeIndex.value = withTiming(navigationState.index, {
+ duration: theme.motion.duration.short3 * theme.animation.scale,
+ easing: sceneAnimationEasing,
});
-
- animateToIndex(navigationState.index);
- }, [navigationState.index, animateToIndex, offsetsAnims]);
+ }, [
+ activeIndex,
+ navigationState.index,
+ reduceMotion,
+ sceneAnimationEasing,
+ sceneAnimationEnabled,
+ theme.animation.scale,
+ theme.motion.duration.short3,
+ ]);
const handleTabPress = useLatestCallback(
- (event: { route: Route } & TabPressEvent) => {
+ (event: Parameters['onTabPress']>>[0]) => {
onTabPress?.(event);
if (event.defaultPrevented) {
@@ -462,7 +216,6 @@ const BottomNavigation = ({
);
if (index !== navigationState.index) {
- prevNavigationState.current = navigationState;
onIndexChange(index);
}
}
@@ -473,92 +226,37 @@ const BottomNavigation = ({
(route) => route.key === key
);
- prevNavigationState.current = navigationState;
onIndexChange(index);
});
+ const renderSceneStable = useLatestCallback(renderScene);
+ const getLazyStable = useLatestCallback(getLazy);
const { routes } = navigationState;
- const { colors } = theme;
return (
-
+
{routes.map((route, index) => {
- if (getLazy({ route }) !== false && !loaded.includes(route.key)) {
- // Don't render a screen if we've never navigated to it
+ const isLazy = getLazyStable({ route }) !== false;
+
+ if (isLazy && !loaded.has(route.key)) {
return null;
}
- const focused = navigationState.index === index;
- const previouslyFocused =
- prevNavigationState.current?.index === index;
- const countAlphaOffscreen =
- sceneAnimationEnabled && (focused || previouslyFocused);
- const renderToHardwareTextureAndroid =
- sceneAnimationEnabled && focused;
-
- const opacity = sceneAnimationEnabled
- ? tabsPositionAnims[index].interpolate({
- inputRange: [-1, 0, 1],
- outputRange: [0, 1, 0],
- })
- : focused
- ? 1
- : 0;
-
- const offsetTarget = focused ? 0 : FAR_FAR_AWAY;
-
- const top = sceneAnimationEnabled
- ? offsetsAnims[index].interpolate({
- inputRange: [0, 1],
- outputRange: [0, offsetTarget],
- })
- : offsetTarget;
-
- const left =
- sceneAnimationType === 'shifting'
- ? tabsPositionAnims[index].interpolate({
- inputRange: [-1, 0, 1],
- outputRange: [-50, 0, 50],
- })
- : 0;
-
- const zIndex = focused ? 1 : 0;
-
return (
-
-
- {renderScene({ route, jumpTo })}
-
-
+ focused={navigationState.index === index}
+ activeIndex={activeIndex}
+ sceneAnimationEnabled={sceneAnimationEnabled}
+ sceneAnimationType={sceneAnimationType}
+ renderScene={renderSceneStable}
+ jumpTo={jumpTo}
+ />
);
})}
@@ -581,6 +279,7 @@ const BottomNavigation = ({
onTabPress={handleTabPress}
onTabLongPress={onTabLongPress}
shifting={shifting}
+ itemLayout={itemLayout}
safeAreaInsets={safeAreaInsets}
labelMaxFontSizeMultiplier={labelMaxFontSizeMultiplier}
compact={compact}
@@ -591,32 +290,7 @@ const BottomNavigation = ({
);
};
-/**
- * Function which takes a map of route keys to components.
- * Pure components are used to minimize re-rendering of the pages.
- * This drastically improves the animation performance.
- */
-BottomNavigation.SceneMap = (scenes: {
- [key: string]: React.ComponentType<{
- route: Route;
- jumpTo: (key: string) => void;
- }>;
-}) => {
- return ({
- route,
- jumpTo,
- }: {
- route: Route;
- jumpTo: (key: string) => void;
- }) => (
-
- );
-};
+BottomNavigation.SceneMap = SceneMap;
// @component ./BottomNavigationBar.tsx
BottomNavigation.Bar = BottomNavigationBar;
diff --git a/src/components/BottomNavigation/BottomNavigationBar.tsx b/src/components/BottomNavigation/BottomNavigationBar.tsx
index c53545b39a..c86ab3ef24 100644
--- a/src/components/BottomNavigation/BottomNavigationBar.tsx
+++ b/src/components/BottomNavigation/BottomNavigationBar.tsx
@@ -1,235 +1,24 @@
import * as React from 'react';
-// eslint-disable-next-line no-restricted-imports -- TODO: migrate BottomNavigation to Reanimated.
-import { Animated, Platform, StyleSheet, Pressable, View } from 'react-native';
-import type {
- ColorValue,
- EasingFunction,
- StyleProp,
- ViewStyle,
-} from 'react-native';
-
+import { Platform, StyleSheet, View } from 'react-native';
+import type { ColorValue } from 'react-native';
+
+import Animated, {
+ interpolate,
+ useAnimatedStyle,
+ useSharedValue,
+ withTiming,
+} from 'react-native-reanimated';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
+import { scheduleOnRN } from 'react-native-worklets';
+import useLatestCallback from 'use-latest-callback';
-import {
- getActiveTintColor,
- getInactiveTintColor,
- getLabelColor,
-} from './utils';
+import BottomNavigationItem from './BottomNavigationItem';
+import { NavigationBarTokens } from './tokens';
+import type { BarProps, BaseRoute } from './types';
+import { resolveItemLayout } from './utils';
import { useInternalTheme } from '../../core/theming';
-import type { ThemeProp } from '../../theme/types';
-import useAnimatedValue from '../../utils/useAnimatedValue';
-import useAnimatedValueArray from '../../utils/useAnimatedValueArray';
import useIsKeyboardShown from '../../utils/useIsKeyboardShown';
import useLayout from '../../utils/useLayout';
-import Badge from '../Badge';
-import Icon from '../Icon';
-import type { IconSource } from '../Icon';
-import TouchableRipple from '../TouchableRipple/TouchableRipple';
-import type { Props as TouchableRippleProps } from '../TouchableRipple/TouchableRipple';
-import Text from '../Typography/Text';
-
-type BaseRoute = {
- key: string;
- title?: string;
- focusedIcon?: IconSource;
- unfocusedIcon?: IconSource;
- badge?: string | number | boolean;
- /**
- * Accessibility label for the tab. This is read by the screen reader when the user focuses the tab.
- */
- 'aria-label'?: string;
- testID?: string;
- lazy?: boolean;
-};
-
-type NavigationState = {
- index: number;
- routes: Route[];
-};
-
-type TabPressEvent = {
- defaultPrevented: boolean;
- preventDefault(): void;
-};
-
-type TouchableProps = TouchableRippleProps & {
- key: string;
- route: Route;
- children: React.ReactNode;
- borderless?: boolean;
- centered?: boolean;
- rippleColor?: ColorValue;
-};
-
-export type Props = {
- /**
- * Whether the shifting style is used, the active tab icon shifts up to show the label and the inactive tabs won't have a label.
- *
- * By default, this is `false` with theme version 3 and `true` when you have more than 3 tabs.
- * Pass `shifting={false}` to explicitly disable this animation, or `shifting={true}` to always use this animation.
- * Note that you need at least 2 tabs be able to run this animation.
- */
- shifting?: boolean;
- /**
- * Whether to show labels in tabs. When `false`, only icons will be displayed.
- */
- labeled?: boolean;
- /**
- * Whether tabs should be spread across the entire width.
- */
- compact?: boolean;
- /**
- * State for the bottom navigation. The state should contain the following properties:
- *
- * - `index`: a number representing the index of the active route in the `routes` array
- * - `routes`: an array containing a list of route objects used for rendering the tabs
- *
- * Each route object should contain the following properties:
- *
- * - `key`: a unique key to identify the route (required)
- * - `title`: title of the route to use as the tab label
- * - `focusedIcon`: icon to use as the focused tab icon, can be a string, an image source or a react component @renamed Renamed from 'icon' to 'focusedIcon' in v5.x
- * - `unfocusedIcon`: icon to use as the unfocused tab icon, can be a string, an image source or a react component @supported Available in v5.x with theme version 3
- * - `badge`: badge to show on the tab icon, can be `true` to show a dot, `string` or `number` to show text.
- * - `aria-label`: accessibility label for the tab button
- * - `testID`: test id for the tab button
- *
- * Example:
- *
- * ```js
- * {
- * index: 1,
- * routes: [
- * { key: 'music', title: 'Favorites', focusedIcon: 'heart', unfocusedIcon: 'heart-outline'},
- * { key: 'albums', title: 'Albums', focusedIcon: 'album' },
- * { key: 'recents', title: 'Recents', focusedIcon: 'history' },
- * { key: 'notifications', title: 'Notifications', focusedIcon: 'bell', unfocusedIcon: 'bell-outline' },
- * ]
- * }
- * ```
- *
- * `BottomNavigation.Bar` is a controlled component, which means the `index` needs to be updated via the `onTabPress` callback.
- */
- navigationState: NavigationState;
- /**
- * Callback which returns a React Element to be used as tab icon.
- */
- renderIcon?: (props: {
- route: Route;
- focused: boolean;
- color: ColorValue;
- }) => React.ReactNode;
- /**
- * Callback which React Element to be used as tab label.
- */
- renderLabel?: (props: {
- route: Route;
- focused: boolean;
- color: ColorValue;
- }) => React.ReactNode;
- /**
- * Callback which returns a React element to be used as the touchable for the tab item.
- * Renders a `TouchableRipple` on Android and `Pressable` on iOS.
- */
- renderTouchable?: (props: TouchableProps) => React.ReactNode;
- /**
- * Get accessibility label for the tab button. This is read by the screen reader when the user taps the tab.
- * Uses `route['aria-label']` by default.
- */
- getAccessibilityLabel?: (props: { route: Route }) => string | undefined;
- /**
- * Get badge for the tab, uses `route.badge` by default.
- */
- getBadge?: (props: { route: Route }) => boolean | number | string | undefined;
- /**
- * Get label text for the tab, uses `route.title` by default. Use `renderLabel` to replace label component.
- */
- getLabelText?: (props: { route: Route }) => string | undefined;
- /**
- * Get the id to locate this tab button in tests, uses `route.testID` by default.
- */
- getTestID?: (props: { route: Route }) => string | undefined;
- /**
- * Function to execute on tab press. It receives the route for the pressed tab. Use this to update the navigation state.
- */
- onTabPress: (props: { route: Route } & TabPressEvent) => void;
- /**
- * Function to execute on tab long press. It receives the route for the pressed tab
- */
- onTabLongPress?: (props: { route: Route } & TabPressEvent) => void;
- /**
- * Custom color for icon and label in the active tab.
- */
- activeColor?: string;
- /**
- * Custom color for icon and label in the inactive tab.
- */
- inactiveColor?: string;
- /**
- * The scene animation Easing.
- */
- animationEasing?: EasingFunction | undefined;
- /**
- * Whether the bottom navigation bar is hidden when keyboard is shown.
- * On Android, this works best when [`windowSoftInputMode`](https://developer.android.com/guide/topics/manifest/activity-element#wsoft) is set to `adjustResize`.
- */
- keyboardHidesNavigationBar?: boolean;
- /**
- * Safe area insets for the tab bar. This can be used to avoid elements like the navigation bar on Android and bottom safe area on iOS.
- * The bottom insets for iOS is added by default. You can override the behavior with this option.
- */
- safeAreaInsets?: {
- top?: number;
- right?: number;
- bottom?: number;
- left?: number;
- };
- /**
- * Specifies the largest possible scale a label font can reach.
- */
- labelMaxFontSizeMultiplier?: number;
- style?: Animated.WithAnimatedValue>;
- activeIndicatorStyle?: StyleProp;
- /**
- * @optional
- */
- theme?: ThemeProp;
- /**
- * TestID used for testing purposes
- */
- testID?: string;
-};
-
-const MIN_TAB_WIDTH = 96;
-const MAX_TAB_WIDTH = 168;
-const BAR_HEIGHT = 56;
-const OUTLINE_WIDTH = 64;
-
-const Touchable = ({
- route: _0,
- style,
- children,
- borderless,
- centered,
- rippleColor,
- ...rest
-}: TouchableProps) =>
- TouchableRipple.supported ? (
-
- {children}
-
- ) : (
-
- {children}
-
- );
/**
* A navigation bar which can easily be integrated with [React Navigation's Bottom Tabs Navigator](https://reactnavigation.org/docs/bottom-tab-navigator/).
@@ -241,7 +30,6 @@ const Touchable = ({
* import { useState } from 'react';
* import { View } from 'react-native';
* import { BottomNavigation, Text, Provider } from 'react-native-paper';
- * import MaterialCommunityIcons from '@react-native-vector-icons/material-design-icons';
*
* function HomeScreen() {
* return (
@@ -255,7 +43,7 @@ const Touchable = ({
* return (
*
* Settings!
- *
+ *
* );
* }
*
@@ -263,24 +51,13 @@ const Touchable = ({
* const [index, setIndex] = useState(0);
*
* const routes = [
- * { key: 'home', title: 'Home', icon: 'home' },
- * { key: 'settings', title: 'Settings', icon: 'cog' },
+ * { key: 'home', title: 'Home', focusedIcon: 'home' },
+ * { key: 'settings', title: 'Settings', focusedIcon: 'cog' },
* ];
-
- * const renderScene = ({ route }) => {
- * switch (route.key) {
- * case 'home':
- * return ;
- * case 'settings':
- * return ;
- * default:
- * return null;
- * }
- * };
*
* return (
*
- * {renderScene({ route: routes[index] })}
+ * {index === 0 ? : }
* {
@@ -289,10 +66,6 @@ const Touchable = ({
* setIndex(newIndex);
* }
* }}
- * renderIcon={({ route, color }) => (
- *
- * )}
- * getLabelText={({ route }) => route.title}
* />
*
* );
@@ -303,9 +76,7 @@ const BottomNavigationBar = ({
navigationState,
renderIcon,
renderLabel,
- renderTouchable = ({ key, ...props }: TouchableProps) => (
-
- ),
+ renderTouchable,
getLabelText = ({ route }: { route: Route }) => route.title,
getBadge = ({ route }: { route: Route }) => route.badge,
getAccessibilityLabel = ({ route }: { route: Route }) => route['aria-label'],
@@ -320,106 +91,53 @@ const BottomNavigationBar = ({
onTabPress,
onTabLongPress,
shifting: shiftingProp,
+ itemLayout = 'auto',
safeAreaInsets,
labelMaxFontSizeMultiplier = 1,
compact: compactProp,
testID,
theme: themeOverrides,
-}: Props) => {
+}: BarProps) => {
const theme = useInternalTheme(themeOverrides);
- const { colors } = theme;
const { bottom, left, right } = useSafeAreaInsets();
const { scale } = theme.animation;
const compact = compactProp ?? false;
- let shifting = shiftingProp ?? false;
+ const shifting = shiftingProp ?? false;
- if (shifting && navigationState.routes.length < 2) {
- shifting = false;
- console.warn(
- 'BottomNavigation.Bar needs at least 2 tabs to run shifting animation'
- );
- }
-
- /**
- * Visibility of the navigation bar, visible state is 1 and invisible is 0.
- */
- const visibleAnim = useAnimatedValue(1);
-
- /**
- * Active state of individual tab items, active state is 1 and inactive state is 0.
- */
- const tabsAnims = useAnimatedValueArray(
- navigationState.routes.map(
- // focused === 1, unfocused === 0
- (_, i) => (i === navigationState.index ? 1 : 0)
- )
- );
-
- /**
- * Layout of the navigation bar.
- */
+ const visibleAnim = useSharedValue(1);
const [layout, onLayout] = useLayout();
-
- /**
- * Track whether the keyboard is visible to show and hide the navigation bar.
- */
const [keyboardVisible, setKeyboardVisible] = React.useState(false);
- const handleKeyboardShow = React.useCallback(() => {
+ const handleKeyboardShow = useLatestCallback(() => {
setKeyboardVisible(true);
- Animated.timing(visibleAnim, {
- toValue: 0,
- duration: 150 * scale,
- useNativeDriver: true,
- }).start();
- }, [scale, visibleAnim]);
-
- const handleKeyboardHide = React.useCallback(() => {
- Animated.timing(visibleAnim, {
- toValue: 1,
- duration: 100 * scale,
- useNativeDriver: true,
- }).start(() => {
- setKeyboardVisible(false);
+ visibleAnim.value = withTiming(0, {
+ duration: theme.motion.duration.short3 * scale,
});
- }, [scale, visibleAnim]);
-
- const animateToIndex = React.useCallback(
- (index: number) => {
- Animated.parallel(
- navigationState.routes.map((_, i) =>
- Animated.timing(tabsAnims[i], {
- toValue: i === index ? 1 : 0,
- duration: 150 * scale,
- useNativeDriver: true,
- easing: animationEasing,
- })
- )
- ).start(() => {
- // Workaround a bug in native animations where this is reset after first animation
- tabsAnims.map((tab, i) => tab.setValue(i === index ? 1 : 0));
- });
- },
- [scale, navigationState.routes, tabsAnims, animationEasing]
- );
+ });
- React.useEffect(() => {
- // Workaround for native animated bug in react-native@^0.57
- // Context: https://github.com/callstack/react-native-paper/pull/637
- animateToIndex(navigationState.index);
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, []);
+ const handleKeyboardHide = useLatestCallback(() => {
+ visibleAnim.value = withTiming(
+ 1,
+ { duration: theme.motion.duration.short2 * scale },
+ (finished) => {
+ if (finished) {
+ scheduleOnRN(setKeyboardVisible, false);
+ }
+ }
+ );
+ });
useIsKeyboardShown({
onShow: handleKeyboardShow,
onHide: handleKeyboardHide,
});
- React.useEffect(() => {
- animateToIndex(navigationState.index);
- }, [navigationState.index, animateToIndex]);
+ const resolvedLayout = resolveItemLayout({
+ itemLayout,
+ width: layout.width,
+ });
- const eventForIndex = (index: number) => {
+ const eventForIndex = useLatestCallback((index: number) => {
const event = {
route: navigationState.routes[index],
defaultPrevented: false,
@@ -429,30 +147,39 @@ const BottomNavigationBar = ({
};
return event;
- };
-
- const { routes } = navigationState;
-
- const {
- backgroundColor: customBackground,
- // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
- } = (StyleSheet.flatten(style) || {}) as {
- backgroundColor?: ColorValue;
- };
-
- const backgroundColor = customBackground || colors.surfaceContainer;
+ });
- const activeTintColor = getActiveTintColor({
- activeColor,
- theme,
+ const handleItemPress = useLatestCallback((index: number) => {
+ onTabPress(eventForIndex(index));
});
- const inactiveTintColor = getInactiveTintColor({
- inactiveColor,
- theme,
+ const handleItemLongPress = useLatestCallback((index: number) => {
+ onTabLongPress?.(eventForIndex(index));
});
- const maxTabWidth = routes.length > 3 ? MIN_TAB_WIDTH : MAX_TAB_WIDTH;
+ const getLabelTextStable = useLatestCallback(getLabelText);
+ const getBadgeStable = useLatestCallback(getBadge);
+ const getAccessibilityLabelStable = useLatestCallback(getAccessibilityLabel);
+ const getTestIDStable = useLatestCallback(getTestID);
+ const renderIconStable = useLatestCallback(renderIcon ?? (() => null));
+ const renderLabelStable = useLatestCallback(renderLabel ?? (() => null));
+
+ const { routes } = navigationState;
+ const flattenedStyle = StyleSheet.flatten(style);
+ const customBackground =
+ flattenedStyle &&
+ typeof flattenedStyle === 'object' &&
+ 'backgroundColor' in flattenedStyle
+ ? // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- flattened style may carry a background override.
+ (flattenedStyle.backgroundColor as ColorValue | undefined)
+ : undefined;
+ const backgroundColor =
+ customBackground ?? theme.colors[NavigationBarTokens.colors.container];
+
+ const maxTabWidth =
+ routes.length > 3
+ ? NavigationBarTokens.minTabWidth
+ : NavigationBarTokens.maxTabWidth;
const maxTabBarWidth = maxTabWidth * routes.length;
const insets = {
@@ -461,309 +188,81 @@ const BottomNavigationBar = ({
bottom: safeAreaInsets?.bottom ?? bottom,
};
- const pointerEvents = layout.measured
- ? keyboardHidesNavigationBar && keyboardVisible
- ? 'none'
- : 'auto'
- : 'none';
+ const keyboardStyle = useAnimatedStyle(
+ () => ({
+ transform: [
+ {
+ translateY: interpolate(
+ visibleAnim.value,
+ [0, 1],
+ [layout.height, 0]
+ ),
+ },
+ ],
+ }),
+ [layout.height]
+ );
return (
-
+
- {routes.map((route, index) => {
- const focused = navigationState.index === index;
- const active = tabsAnims[index];
-
- // Move down the icon to account for no-label in shifting and smaller label in non-shifting.
- const translateY = labeled
- ? shifting
- ? active.interpolate({
- inputRange: [0, 1],
- outputRange: [7, 0],
- })
- : 0
- : 7;
-
- // We render the active icon and label on top of inactive ones and cross-fade them on change.
- // This trick gives the illusion that we are animating between active and inactive colors.
- // This is to ensure that we can use native driver, as colors cannot be animated with native driver.
- const activeOpacity = active;
-
- const inactiveOpacity = active.interpolate({
- inputRange: [0, 1],
- outputRange: [1, 0],
- });
-
- const v3ActiveOpacity = focused ? 1 : 0;
-
- const v3InactiveOpacity = shifting
- ? inactiveOpacity
- : focused
- ? 0
- : 1;
-
- // Scale horizontally the outline pill
- const outlineScale = focused
- ? active.interpolate({
- inputRange: [0, 1],
- outputRange: [0.5, 1],
- })
- : 0;
-
- const badge = getBadge({ route });
-
- const activeLabelColor = getLabelColor({
- tintColor: activeTintColor,
- hasColor: Boolean(activeColor),
- focused,
- theme,
- });
-
- const inactiveLabelColor = getLabelColor({
- tintColor: inactiveTintColor,
- hasColor: Boolean(inactiveColor),
- focused,
- theme,
- });
-
- const badgeStyle = {
- top: typeof badge === 'boolean' ? 4 : 2,
- right:
- badge != null && typeof badge !== 'boolean'
- ? String(badge).length * -2
- : 0,
- };
-
- const isLegacyOrV3Shifting = shifting && labeled;
-
- const font = theme.fonts.labelMedium;
-
- return renderTouchable({
- key: route.key,
- route,
- borderless: true,
- centered: true,
- rippleColor: 'transparent',
- onPress: () => onTabPress(eventForIndex(index)),
- onLongPress: () => onTabLongPress?.(eventForIndex(index)),
- testID: getTestID({ route }),
- 'aria-label': getAccessibilityLabel({ route }),
- role: Platform.OS === 'ios' ? 'button' : 'tab',
- 'aria-selected': focused,
- style: [styles.item, styles.v3Item],
- children: (
-
-
- {focused && (
-
- )}
-
- {renderIcon ? (
- renderIcon({
- route,
- focused: true,
- color: activeTintColor,
- })
- ) : (
-
- )}
-
-
- {renderIcon ? (
- renderIcon({
- route,
- focused: false,
- color: inactiveTintColor,
- })
- ) : (
-
- )}
-
-
- {typeof badge === 'boolean' ? (
-
- ) : (
- {badge}
- )}
-
-
- {labeled ? (
-
-
- {renderLabel ? (
- renderLabel({
- route,
- focused: true,
- color: activeLabelColor,
- })
- ) : (
-
- {getLabelText({ route })}
-
- )}
-
- {shifting ? null : (
-
- {renderLabel ? (
- renderLabel({
- route,
- focused: false,
- color: inactiveLabelColor,
- })
- ) : (
-
- {getLabelText({ route })}
-
- )}
-
- )}
-
- ) : null}
-
- ),
- });
- })}
+ {routes.map((route, index) => (
+ handleItemPress(index)}
+ onLongPress={
+ onTabLongPress ? () => handleItemLongPress(index) : undefined
+ }
+ labelMaxFontSizeMultiplier={labelMaxFontSizeMultiplier}
+ activeIndicatorStyle={activeIndicatorStyle}
+ animationEasing={animationEasing}
+ theme={theme}
+ />
+ ))}
-
+
);
};
@@ -778,85 +277,34 @@ const styles = StyleSheet.create({
right: 0,
bottom: 0,
},
+ barHidden: {
+ position: 'absolute',
+ },
barContent: {
alignItems: 'center',
- overflow: 'hidden',
+ overflow: 'visible',
+ minHeight: NavigationBarTokens.containerHeight,
},
items: {
flexDirection: 'row',
+ minHeight: NavigationBarTokens.containerHeight,
...(Platform.OS === 'web'
? {
width: '100%',
}
: null),
},
- item: {
- flex: 1,
- // Top padding is 6 and bottom padding is 10
- // The extra 4dp bottom padding is offset by label's height
- paddingVertical: 6,
+ verticalItems: {
+ alignItems: 'stretch',
},
- v3Item: {
- paddingVertical: 0,
- },
- iconContainer: {
- height: 24,
- width: 24,
- marginTop: 2,
- marginHorizontal: 12,
- alignSelf: 'center',
- },
- v3IconContainer: {
- height: 32,
- width: 32,
- marginBottom: 4,
- marginTop: 0,
- justifyContent: 'center',
- },
- iconWrapper: {
- ...StyleSheet.absoluteFill,
+ horizontalItems: {
alignItems: 'center',
- },
- v3IconWrapper: {
- top: 4,
- },
- labelContainer: {
- height: 16,
- paddingBottom: 2,
- },
- labelWrapper: {
- ...StyleSheet.absoluteFill,
- },
- // eslint-disable-next-line react-native/no-color-literals
- label: {
- fontSize: 12,
- height: BAR_HEIGHT,
- textAlign: 'center',
- backgroundColor: 'transparent',
- ...(Platform.OS === 'web'
- ? {
- whiteSpace: 'nowrap',
- alignSelf: 'center',
- }
- : null),
- },
- badgeContainer: {
- position: 'absolute',
- left: 0,
- },
- v3TouchableContainer: {
- paddingTop: 12,
- paddingBottom: 16,
- },
- v3NoLabelContainer: {
- height: 80,
justifyContent: 'center',
- alignItems: 'center',
},
- outline: {
- width: OUTLINE_WIDTH,
- height: OUTLINE_WIDTH / 2,
- borderRadius: OUTLINE_WIDTH / 4,
- alignSelf: 'center',
+ pointerEventsNone: {
+ pointerEvents: 'none',
+ },
+ pointerEventsAuto: {
+ pointerEvents: 'auto',
},
});
diff --git a/src/components/BottomNavigation/BottomNavigationItem.tsx b/src/components/BottomNavigation/BottomNavigationItem.tsx
new file mode 100644
index 0000000000..ff7beb6506
--- /dev/null
+++ b/src/components/BottomNavigation/BottomNavigationItem.tsx
@@ -0,0 +1,397 @@
+import * as React from 'react';
+import { Platform, StyleSheet, View } from 'react-native';
+import type { ColorValue, StyleProp, ViewStyle } from 'react-native';
+
+import Animated, {
+ interpolate,
+ useAnimatedStyle,
+ useSharedValue,
+ withSpring,
+ withTiming,
+} from 'react-native-reanimated';
+
+import { renderDefaultTouchable } from './BottomNavigationTouchable';
+import { NavigationBarTokens } from './tokens';
+import type {
+ BaseRoute,
+ RenderIcon,
+ RenderLabel,
+ RenderTouchable,
+ SceneAnimationEasing,
+} from './types';
+import {
+ getActiveTintColor,
+ getInactiveTintColor,
+ getItemRippleColor,
+ getLabelColor,
+} from './utils';
+import { useReduceMotion } from '../../theme/accessibility/ReduceMotionContext';
+import { toRawSpring } from '../../theme/tokens/sys/motion';
+import { cornerFull } from '../../theme/tokens/sys/shape';
+import type { InternalTheme } from '../../theme/types';
+import Badge from '../Badge';
+import Icon from '../Icon';
+import type { IconSource } from '../Icon';
+import Text from '../Typography/Text';
+
+export type Props = {
+ route: Route;
+ focused: boolean;
+ labeled: boolean;
+ shifting: boolean;
+ itemLayout: 'vertical' | 'horizontal';
+ activeColor?: ColorValue;
+ inactiveColor?: ColorValue;
+ renderIcon?: RenderIcon;
+ renderLabel?: RenderLabel;
+ renderTouchable?: RenderTouchable;
+ getLabelText: (props: { route: Route }) => string | undefined;
+ getBadge: (props: { route: Route }) => boolean | number | string | undefined;
+ getAccessibilityLabel: (props: { route: Route }) => string | undefined;
+ getTestID: (props: { route: Route }) => string | undefined;
+ onPress: () => void;
+ onLongPress?: () => void;
+ labelMaxFontSizeMultiplier: number;
+ activeIndicatorStyle?: StyleProp;
+ animationEasing?: SceneAnimationEasing;
+ theme: InternalTheme;
+};
+
+const renderDefaultIcon = ({
+ source,
+ color,
+}: {
+ source: IconSource | undefined;
+ color: ColorValue;
+}) => {
+ if (source == null) {
+ return null;
+ }
+
+ return ;
+};
+
+function BottomNavigationItem({
+ route,
+ focused,
+ labeled,
+ shifting,
+ itemLayout,
+ activeColor,
+ inactiveColor,
+ renderIcon,
+ renderLabel,
+ renderTouchable = renderDefaultTouchable,
+ getLabelText,
+ getBadge,
+ getAccessibilityLabel,
+ getTestID,
+ onPress,
+ onLongPress,
+ labelMaxFontSizeMultiplier,
+ activeIndicatorStyle,
+ animationEasing,
+ theme,
+}: Props) {
+ const reduceMotion = useReduceMotion();
+ const progress = useSharedValue(focused ? 1 : 0);
+ const { scale } = theme.animation;
+ const isHorizontal = itemLayout === 'horizontal';
+ const onIndicator = isHorizontal && focused;
+
+ React.useEffect(() => {
+ const target = focused ? 1 : 0;
+
+ if (reduceMotion) {
+ progress.value = target;
+ return;
+ }
+
+ if (animationEasing) {
+ progress.value = withTiming(target, {
+ duration: theme.motion.duration.short3 * scale,
+ easing: animationEasing,
+ });
+ return;
+ }
+
+ progress.value = withSpring(
+ target,
+ toRawSpring(theme.motion.spring.default.spatial)
+ );
+ }, [animationEasing, focused, progress, reduceMotion, scale, theme]);
+
+ const indicatorStyle = useAnimatedStyle(() => ({
+ opacity: progress.value,
+ transform: [
+ {
+ scaleX: interpolate(progress.value, [0, 1], [0.4, 1]),
+ },
+ ],
+ }));
+
+ const activeIconStyle = useAnimatedStyle(() => ({
+ opacity: progress.value,
+ }));
+
+ const inactiveIconStyle = useAnimatedStyle(() => ({
+ opacity: 1 - progress.value,
+ }));
+
+ const labelVisibilityStyle = useAnimatedStyle(() => ({
+ opacity: shifting ? progress.value : 1,
+ }));
+
+ const activeTintColor = getActiveTintColor({ activeColor, theme });
+ const inactiveTintColor = getInactiveTintColor({ inactiveColor, theme });
+ const labelColor = getLabelColor({
+ tintColor: focused ? activeTintColor : inactiveTintColor,
+ hasColor: Boolean(focused ? activeColor : inactiveColor),
+ focused,
+ onIndicator,
+ theme,
+ });
+ const badge = getBadge({ route });
+ const label = getLabelText({ route });
+ const rippleColor = getItemRippleColor({ focused, theme });
+ const labelVariant = focused ? 'labelMediumEmphasized' : 'labelMedium';
+ const isLargeBadge = badge != null && typeof badge !== 'boolean';
+ const indicatorBackground =
+ theme.colors[NavigationBarTokens.colors.activeIndicator];
+
+ const icon = (
+
+ {isHorizontal ? null : (
+
+ )}
+
+ {renderIcon
+ ? renderIcon({
+ route,
+ focused: true,
+ color: activeTintColor,
+ })
+ : renderDefaultIcon({
+ source: route.focusedIcon,
+ color: activeTintColor,
+ })}
+
+
+ {renderIcon
+ ? renderIcon({
+ route,
+ focused: false,
+ color: inactiveTintColor,
+ })
+ : renderDefaultIcon({
+ source: route.unfocusedIcon ?? route.focusedIcon,
+ color: inactiveTintColor,
+ })}
+
+
+ {typeof badge === 'boolean' ? (
+
+ ) : (
+ {badge}
+ )}
+
+
+ );
+
+ const labelNode = labeled ? (
+
+ {renderLabel ? (
+ renderLabel({
+ route,
+ focused,
+ color: labelColor,
+ })
+ ) : (
+
+ {label}
+
+ )}
+
+ ) : null;
+
+ return renderTouchable({
+ key: route.key,
+ route,
+ borderless: true,
+ centered: true,
+ rippleColor,
+ onPress,
+ onLongPress,
+ testID: getTestID({ route }),
+ 'aria-label': getAccessibilityLabel({ route }),
+ role: Platform.OS === 'ios' ? 'button' : 'tab',
+ 'aria-selected': focused,
+ style: [
+ styles.item,
+ isHorizontal ? styles.horizontalItem : styles.verticalItem,
+ ],
+ children: (
+
+ {isHorizontal ? (
+
+ ) : null}
+ {icon}
+ {labelNode}
+
+ ),
+ });
+}
+
+function routeVisualsEqual(
+ previous: Route,
+ next: Route
+) {
+ return (
+ previous.key === next.key &&
+ previous.title === next.title &&
+ previous.badge === next.badge &&
+ previous.focusedIcon === next.focusedIcon &&
+ previous.unfocusedIcon === next.unfocusedIcon &&
+ previous['aria-label'] === next['aria-label'] &&
+ previous.testID === next.testID
+ );
+}
+
+function propsAreEqual(
+ previous: Props,
+ next: Props
+) {
+ return (
+ previous.focused === next.focused &&
+ previous.labeled === next.labeled &&
+ previous.shifting === next.shifting &&
+ previous.itemLayout === next.itemLayout &&
+ previous.activeColor === next.activeColor &&
+ previous.inactiveColor === next.inactiveColor &&
+ previous.labelMaxFontSizeMultiplier === next.labelMaxFontSizeMultiplier &&
+ previous.activeIndicatorStyle === next.activeIndicatorStyle &&
+ previous.animationEasing === next.animationEasing &&
+ previous.theme === next.theme &&
+ routeVisualsEqual(previous.route, next.route)
+ );
+}
+
+// React.memo erases the generic; restore it so callers keep Route inference.
+// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
+export default React.memo(
+ BottomNavigationItem,
+ propsAreEqual
+) as typeof BottomNavigationItem;
+
+const styles = StyleSheet.create({
+ item: {
+ paddingVertical: 0,
+ },
+ verticalItem: {
+ flex: 1,
+ },
+ horizontalItem: {
+ flexGrow: 0,
+ flexShrink: 1,
+ marginHorizontal: NavigationBarTokens.itemHorizontalGap / 2,
+ },
+ verticalContent: {
+ alignItems: 'center',
+ paddingTop: NavigationBarTokens.itemVerticalSpace,
+ paddingBottom: NavigationBarTokens.itemVerticalSpace,
+ },
+ unlabeledContent: {
+ height: NavigationBarTokens.containerHeight,
+ justifyContent: 'center',
+ paddingTop: 0,
+ paddingBottom: 0,
+ },
+ horizontalContent: {
+ alignItems: 'center',
+ flexDirection: 'row',
+ height: NavigationBarTokens.horizontalIndicatorHeight,
+ paddingEnd: NavigationBarTokens.horizontalIndicatorTrailing,
+ paddingStart: NavigationBarTokens.horizontalIndicatorLeading,
+ },
+ verticalIcon: {
+ alignItems: 'center',
+ height: NavigationBarTokens.verticalIndicatorHeight,
+ justifyContent: 'center',
+ width: NavigationBarTokens.verticalIndicatorWidth,
+ },
+ horizontalIcon: {
+ alignItems: 'center',
+ height: NavigationBarTokens.icon,
+ justifyContent: 'center',
+ width: NavigationBarTokens.icon,
+ },
+ indicator: {
+ ...StyleSheet.absoluteFill,
+ borderRadius: cornerFull,
+ },
+ iconLayer: {
+ ...StyleSheet.absoluteFill,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ badge: {
+ position: 'absolute',
+ },
+ smallBadge: {
+ end: -NavigationBarTokens.smallBadgeOffset,
+ top: -NavigationBarTokens.smallBadgeOffset,
+ },
+ largeBadge: {
+ end: -NavigationBarTokens.largeBadgeOffset,
+ top: -NavigationBarTokens.largeBadgeOffset,
+ },
+ verticalLabel: {
+ marginTop: NavigationBarTokens.iconLabelSpace,
+ },
+ horizontalLabel: {
+ marginStart: NavigationBarTokens.iconLabelSpace,
+ },
+ label: {
+ textAlign: 'center',
+ ...(Platform.OS === 'web'
+ ? {
+ whiteSpace: 'nowrap',
+ }
+ : null),
+ },
+});
diff --git a/src/components/BottomNavigation/BottomNavigationRouteScreen.tsx b/src/components/BottomNavigation/BottomNavigationRouteScreen.tsx
index affffc6793..53024a54a8 100644
--- a/src/components/BottomNavigation/BottomNavigationRouteScreen.tsx
+++ b/src/components/BottomNavigation/BottomNavigationRouteScreen.tsx
@@ -1,34 +1,21 @@
-import React from 'react';
import type { ReactNode } from 'react';
-// eslint-disable-next-line no-restricted-imports -- TODO: migrate BottomNavigation to Reanimated.
-import { Animated, Platform, View } from 'react-native';
import type { ViewProps } from 'react-native';
-interface Props extends ViewProps {
- visibility?: 0 | 1 | Animated.AnimatedInterpolation;
- index: number;
-}
-
-class BottomNavigationRouteScreen extends React.Component {
- render(): ReactNode {
- const { style, index, children, visibility, ...rest } = this.props;
+import Animated from 'react-native-reanimated';
- // On Web, the unfocused tab screens can still be clicked since they are transparent, but still there
- // Hiding them with `display: none` makes sure that they won't receive clicks
- // We only set it on Web since on native, react-native-pager-view's breaks due to layout changing
- const display =
- Platform.OS === 'web' ? (visibility === 0 ? 'none' : 'flex') : undefined;
+type Props = ViewProps & {
+ index: number;
+};
- return (
-
- {children}
-
- );
- }
-}
+const BottomNavigationRouteScreen = ({
+ style,
+ index,
+ children,
+ ...rest
+}: Props): ReactNode => (
+
+ {children}
+
+);
-export default Animated.createAnimatedComponent(BottomNavigationRouteScreen);
+export default BottomNavigationRouteScreen;
diff --git a/src/components/BottomNavigation/BottomNavigationScene.tsx b/src/components/BottomNavigation/BottomNavigationScene.tsx
new file mode 100644
index 0000000000..55b93e118f
--- /dev/null
+++ b/src/components/BottomNavigation/BottomNavigationScene.tsx
@@ -0,0 +1,135 @@
+import * as React from 'react';
+import { Platform, StyleSheet } from 'react-native';
+
+import Animated, {
+ Extrapolation,
+ interpolate,
+ useAnimatedStyle,
+ type SharedValue,
+} from 'react-native-reanimated';
+
+import BottomNavigationRouteScreen from './BottomNavigationRouteScreen';
+import type { BaseRoute, SceneAnimationType } from './types';
+
+const FAR_FAR_AWAY = Platform.OS === 'web' ? 0 : 9999;
+
+export type Props = {
+ route: Route;
+ index: number;
+ focused: boolean;
+ activeIndex: SharedValue;
+ sceneAnimationEnabled: boolean;
+ sceneAnimationType: SceneAnimationType;
+ renderScene: (props: {
+ route: Route;
+ jumpTo: (key: string) => void;
+ }) => React.ReactNode | null;
+ jumpTo: (key: string) => void;
+};
+
+function BottomNavigationScene({
+ route,
+ index,
+ focused,
+ activeIndex,
+ sceneAnimationEnabled,
+ sceneAnimationType,
+ renderScene,
+ jumpTo,
+}: Props) {
+ const animatedStyle = useAnimatedStyle(() => {
+ if (!sceneAnimationEnabled) {
+ const isFocused = activeIndex.value === index;
+
+ return {
+ opacity: isFocused ? 1 : 0,
+ display:
+ Platform.OS === 'web' && !isFocused
+ ? ('none' as const)
+ : ('flex' as const),
+ transform: [
+ { translateX: 0 },
+ { translateY: isFocused ? 0 : FAR_FAR_AWAY },
+ ],
+ };
+ }
+
+ const position = index - activeIndex.value;
+ const distance = Math.abs(position);
+ const hidden = distance >= 0.99;
+
+ return {
+ opacity: interpolate(distance, [0, 1], [1, 0], Extrapolation.CLAMP),
+ display:
+ Platform.OS === 'web' && hidden ? ('none' as const) : ('flex' as const),
+ transform: [
+ {
+ translateX: sceneAnimationType === 'shifting' ? position * 50 : 0,
+ },
+ { translateY: hidden ? FAR_FAR_AWAY : 0 },
+ ],
+ };
+ }, [index, sceneAnimationEnabled, sceneAnimationType]);
+
+ return (
+
+
+ {renderScene({ route, jumpTo })}
+
+
+ );
+}
+
+function routeEquals(previous: Route, next: Route) {
+ return previous.key === next.key;
+}
+
+function propsAreEqual(
+ previous: Props,
+ next: Props
+) {
+ return (
+ previous.index === next.index &&
+ previous.focused === next.focused &&
+ previous.sceneAnimationEnabled === next.sceneAnimationEnabled &&
+ previous.sceneAnimationType === next.sceneAnimationType &&
+ previous.activeIndex === next.activeIndex &&
+ previous.jumpTo === next.jumpTo &&
+ previous.renderScene === next.renderScene &&
+ routeEquals(previous.route, next.route)
+ );
+}
+
+// React.memo erases the generic; restore it so callers keep Route inference.
+// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
+export default React.memo(
+ BottomNavigationScene,
+ propsAreEqual
+) as typeof BottomNavigationScene;
+
+const styles = StyleSheet.create({
+ content: {
+ flex: 1,
+ },
+ front: {
+ zIndex: 1,
+ },
+ back: {
+ zIndex: 0,
+ },
+});
diff --git a/src/components/BottomNavigation/BottomNavigationTouchable.tsx b/src/components/BottomNavigation/BottomNavigationTouchable.tsx
new file mode 100644
index 0000000000..ce947bf679
--- /dev/null
+++ b/src/components/BottomNavigation/BottomNavigationTouchable.tsx
@@ -0,0 +1,38 @@
+import { Pressable } from 'react-native';
+
+import type { BaseRoute, TouchableProps } from './types';
+import TouchableRipple from '../TouchableRipple/TouchableRipple';
+
+const BottomNavigationTouchable = ({
+ route: _route,
+ style,
+ children,
+ borderless,
+ centered,
+ rippleColor,
+ ...rest
+}: TouchableProps) =>
+ TouchableRipple.supported ? (
+
+ {children}
+
+ ) : (
+
+ {children}
+
+ );
+
+const renderDefaultTouchable = ({
+ key,
+ ...props
+}: TouchableProps) => ;
+
+export { renderDefaultTouchable };
+export default BottomNavigationTouchable;
diff --git a/src/components/BottomNavigation/SceneMap.tsx b/src/components/BottomNavigation/SceneMap.tsx
new file mode 100644
index 0000000000..f902308fd1
--- /dev/null
+++ b/src/components/BottomNavigation/SceneMap.tsx
@@ -0,0 +1,44 @@
+import * as React from 'react';
+
+import type { BaseRoute } from './types';
+
+type SceneProps = {
+ route: Route;
+ jumpTo: (key: string) => void;
+};
+
+const SceneComponent = React.memo(
+ ({
+ component,
+ ...rest
+ }: {
+ component: React.ComponentType>;
+ route: BaseRoute;
+ jumpTo: (key: string) => void;
+ }) => React.createElement(component, rest)
+);
+
+/**
+ * Function which takes a map of route keys to components.
+ * Pure components are used to minimize re-rendering of the pages.
+ */
+const SceneMap = (scenes: {
+ [key: string]: React.ComponentType>;
+}) => {
+ return ({ route, jumpTo }: SceneProps) => (
+
+ >
+ }
+ route={route}
+ jumpTo={jumpTo}
+ />
+ );
+};
+
+export default SceneMap;
diff --git a/src/components/BottomNavigation/tokens.ts b/src/components/BottomNavigation/tokens.ts
new file mode 100644
index 0000000000..dbcbfc1a3f
--- /dev/null
+++ b/src/components/BottomNavigation/tokens.ts
@@ -0,0 +1,41 @@
+import type { ColorRole } from '../../theme/types';
+
+/**
+ * Material Design 3 Expressive navigation bar tokens.
+ * @see https://m3.material.io/components/navigation-bar/specs
+ */
+const sizes = {
+ containerHeight: 64,
+ tallContainerHeight: 80,
+ mediumWindowMinWidth: 600,
+ icon: 24,
+ verticalIndicatorWidth: 56,
+ verticalIndicatorHeight: 32,
+ horizontalIndicatorHeight: 40,
+ horizontalIndicatorLeading: 16,
+ horizontalIndicatorTrailing: 16,
+ iconLabelSpace: 4,
+ itemVerticalSpace: 6,
+ itemHorizontalGap: 8,
+ minTabWidth: 96,
+ maxTabWidth: 168,
+ smallBadgeOffset: 2,
+ largeBadgeOffset: 4,
+} as const;
+
+const colors = {
+ container: 'surfaceContainer',
+ activeIcon: 'onSecondaryContainer',
+ activeLabel: 'secondary',
+ activeLabelOnIndicator: 'onSecondaryContainer',
+ activeIndicator: 'secondaryContainer',
+ inactiveIcon: 'onSurfaceVariant',
+ inactiveLabel: 'onSurfaceVariant',
+ activeStateLayer: 'onSecondaryContainer',
+ inactiveStateLayer: 'onSurface',
+} as const satisfies Record;
+
+export const NavigationBarTokens = {
+ ...sizes,
+ colors,
+};
diff --git a/src/components/BottomNavigation/types.ts b/src/components/BottomNavigation/types.ts
new file mode 100644
index 0000000000..c6f42adae8
--- /dev/null
+++ b/src/components/BottomNavigation/types.ts
@@ -0,0 +1,184 @@
+import type { ReactNode } from 'react';
+import type { ColorValue, StyleProp, ViewStyle } from 'react-native';
+
+import type { AnimatedStyle } from 'react-native-reanimated';
+
+import type { ThemeProp } from '../../theme/types';
+import type { IconSource } from '../Icon';
+import type { Props as TouchableRippleProps } from '../TouchableRipple/TouchableRipple';
+
+export type BaseRoute = {
+ key: string;
+ title?: string;
+ focusedIcon?: IconSource;
+ unfocusedIcon?: IconSource;
+ badge?: string | number | boolean;
+ 'aria-label'?: string;
+ testID?: string;
+ lazy?: boolean;
+};
+
+export type NavigationState = {
+ index: number;
+ routes: Route[];
+};
+
+export type TabPressEvent = {
+ defaultPrevented: boolean;
+ preventDefault(): void;
+};
+
+export type TouchableProps = TouchableRippleProps & {
+ key: string;
+ route: Route;
+ children: ReactNode;
+ borderless?: boolean;
+ centered?: boolean;
+ rippleColor?: ColorValue;
+};
+
+export type SceneAnimationType = 'opacity' | 'shifting';
+
+export type ItemLayout = 'vertical' | 'horizontal' | 'auto';
+
+export type SceneAnimationEasing = (value: number) => number;
+
+export type SafeAreaInsets = {
+ top?: number;
+ right?: number;
+ bottom?: number;
+ left?: number;
+};
+
+export type RenderIcon = (props: {
+ route: Route;
+ focused: boolean;
+ color: ColorValue;
+}) => ReactNode;
+
+export type RenderLabel = (props: {
+ route: Route;
+ focused: boolean;
+ color: ColorValue;
+}) => ReactNode;
+
+export type RenderTouchable = (
+ props: TouchableProps
+) => ReactNode;
+
+export type BarProps = {
+ /**
+ * Whether inactive destinations hide their labels.
+ * This is a library extension (Material Design 2 shifting). Material Design 3
+ * keeps labels visible for every destination when `labeled` is `true`.
+ */
+ shifting?: boolean;
+ /**
+ * Whether to show labels in tabs. When `false`, only icons are displayed.
+ */
+ labeled?: boolean;
+ /**
+ * Whether tabs should be spread across the entire width.
+ */
+ compact?: boolean;
+ /**
+ * Destination layout. `vertical` stacks the icon above the label (compact
+ * windows). `horizontal` places the icon and label inside a pill (medium
+ * windows). `auto` switches at the Material medium window width (600dp).
+ */
+ itemLayout?: ItemLayout;
+ /**
+ * State for the bottom navigation. The state should contain the following properties:
+ *
+ * - `index`: a number representing the index of the active route in the `routes` array
+ * - `routes`: an array containing a list of route objects used for rendering the tabs
+ *
+ * Each route object should contain the following properties:
+ *
+ * - `key`: a unique key to identify the route (required)
+ * - `title`: title of the route to use as the tab label
+ * - `focusedIcon`: icon to use as the focused tab icon, can be a string, an image source or a react component
+ * - `unfocusedIcon`: icon to use as the unfocused tab icon, can be a string, an image source or a react component
+ * - `badge`: badge to show on the tab icon, can be `true` to show a dot, `string` or `number` to show text.
+ * - `aria-label`: accessibility label for the tab button
+ * - `testID`: test id for the tab button
+ *
+ * `BottomNavigation.Bar` is a controlled component, which means the `index` needs to be updated via the `onTabPress` callback.
+ */
+ navigationState: NavigationState;
+ /**
+ * Callback which returns a React Element to be used as tab icon.
+ */
+ renderIcon?: RenderIcon;
+ /**
+ * Callback which returns a React Element to be used as tab label.
+ */
+ renderLabel?: RenderLabel;
+ /**
+ * Callback which returns a React element to be used as the touchable for the tab item.
+ * Renders a `TouchableRipple` on Android and `Pressable` on iOS.
+ */
+ renderTouchable?: RenderTouchable;
+ /**
+ * Get accessibility label for the tab button. This is read by the screen reader when the user taps the tab.
+ * Uses `route['aria-label']` by default.
+ */
+ getAccessibilityLabel?: (props: { route: Route }) => string | undefined;
+ /**
+ * Get badge for the tab, uses `route.badge` by default.
+ */
+ getBadge?: (props: { route: Route }) => boolean | number | string | undefined;
+ /**
+ * Get label text for the tab, uses `route.title` by default. Use `renderLabel` to replace label component.
+ */
+ getLabelText?: (props: { route: Route }) => string | undefined;
+ /**
+ * Get the id to locate this tab button in tests, uses `route.testID` by default.
+ */
+ getTestID?: (props: { route: Route }) => string | undefined;
+ /**
+ * Function to execute on tab press. It receives the route for the pressed tab. Use this to update the navigation state.
+ */
+ onTabPress: (props: { route: Route } & TabPressEvent) => void;
+ /**
+ * Function to execute on tab long press. It receives the route for the pressed tab
+ */
+ onTabLongPress?: (props: { route: Route } & TabPressEvent) => void;
+ /**
+ * Custom color for icon and label in the active tab.
+ */
+ activeColor?: ColorValue;
+ /**
+ * Custom color for icon and label in the inactive tab.
+ */
+ inactiveColor?: ColorValue;
+ /**
+ * Optional easing used when a custom indicator transition is requested.
+ * Defaults to the Material 3 spatial spring.
+ */
+ animationEasing?: SceneAnimationEasing;
+ /**
+ * Whether the bottom navigation bar is hidden when keyboard is shown.
+ * On Android, this works best when [`windowSoftInputMode`](https://developer.android.com/guide/topics/manifest/activity-element#wsoft) is set to `adjustResize`.
+ */
+ keyboardHidesNavigationBar?: boolean;
+ /**
+ * Safe area insets for the tab bar. This can be used to avoid elements like the navigation bar on Android and bottom safe area on iOS.
+ * The bottom insets for iOS is added by default. You can override the behavior with this option.
+ */
+ safeAreaInsets?: SafeAreaInsets;
+ /**
+ * Specifies the largest possible scale a label font can reach.
+ */
+ labelMaxFontSizeMultiplier?: number;
+ style?: StyleProp>;
+ activeIndicatorStyle?: StyleProp;
+ /**
+ * @optional
+ */
+ theme?: ThemeProp;
+ /**
+ * TestID used for testing purposes
+ */
+ testID?: string;
+};
diff --git a/src/components/BottomNavigation/utils.ts b/src/components/BottomNavigation/utils.ts
index 1f882cd256..a88574ad83 100644
--- a/src/components/BottomNavigation/utils.ts
+++ b/src/components/BottomNavigation/utils.ts
@@ -1,5 +1,9 @@
import type { ColorValue } from 'react-native';
+import color from 'color';
+
+import { NavigationBarTokens } from './tokens';
+import { tokens } from '../../theme/tokens';
import type { InternalTheme } from '../../theme/types';
export const getActiveTintColor = ({
@@ -13,7 +17,7 @@ export const getActiveTintColor = ({
return activeColor;
}
- return theme.colors.onSecondaryContainer;
+ return theme.colors[NavigationBarTokens.colors.activeIcon];
};
export const getInactiveTintColor = ({
@@ -27,18 +31,20 @@ export const getInactiveTintColor = ({
return inactiveColor;
}
- return theme.colors.onSurfaceVariant;
+ return theme.colors[NavigationBarTokens.colors.inactiveIcon];
};
export const getLabelColor = ({
tintColor,
hasColor,
focused,
+ onIndicator,
theme,
}: {
tintColor: ColorValue;
hasColor: boolean;
focused: boolean;
+ onIndicator?: boolean;
theme: InternalTheme;
}) => {
const { colors } = theme;
@@ -47,7 +53,39 @@ export const getLabelColor = ({
}
if (focused) {
- return colors.onSurface;
+ return onIndicator
+ ? colors[NavigationBarTokens.colors.activeLabelOnIndicator]
+ : colors[NavigationBarTokens.colors.activeLabel];
}
- return colors.onSurfaceVariant;
+ return colors[NavigationBarTokens.colors.inactiveLabel];
+};
+
+export const getItemRippleColor = ({
+ focused,
+ theme,
+}: {
+ focused: boolean;
+ theme: InternalTheme;
+}) => {
+ const role = focused
+ ? theme.colors[NavigationBarTokens.colors.activeStateLayer]
+ : theme.colors[NavigationBarTokens.colors.inactiveStateLayer];
+
+ return color(role).alpha(tokens.md.sys.state.opacity.pressed).rgb().string();
+};
+
+export const resolveItemLayout = ({
+ itemLayout,
+ width,
+}: {
+ itemLayout: 'vertical' | 'horizontal' | 'auto';
+ width: number;
+}): 'vertical' | 'horizontal' => {
+ if (itemLayout !== 'auto') {
+ return itemLayout;
+ }
+
+ return width >= NavigationBarTokens.mediumWindowMinWidth
+ ? 'horizontal'
+ : 'vertical';
};
diff --git a/src/components/__tests__/BottomNavigation.test.tsx b/src/components/__tests__/BottomNavigation.test.tsx
index 1020631055..4eb341ecd8 100644
--- a/src/components/__tests__/BottomNavigation.test.tsx
+++ b/src/components/__tests__/BottomNavigation.test.tsx
@@ -1,13 +1,4 @@
-/* eslint-disable no-restricted-imports -- TODO: remove after BottomNavigation migrates to Reanimated. */
-import {
- Animated,
- Easing,
- Keyboard,
- Platform,
- StyleSheet,
- Text,
-} from 'react-native';
-/* eslint-enable no-restricted-imports */
+import { Keyboard, Platform, StyleSheet, Text } from 'react-native';
import type { KeyboardEvent } from 'react-native';
import { describe, expect, it, jest } from '@jest/globals';
@@ -21,7 +12,9 @@ import BottomNavigationRouteScreen from '../BottomNavigation/BottomNavigationRou
import {
getActiveTintColor,
getInactiveTintColor,
+ getItemRippleColor,
getLabelColor,
+ resolveItemLayout,
} from '../BottomNavigation/utils';
import Icon from '../Icon';
@@ -50,10 +43,13 @@ const renderScene = ({ route }: { route: { title: string } }) => (
const getTab = (index: number) =>
screen.getAllByRole(Platform.OS === 'ios' ? 'button' : 'tab')[index];
-const layoutNavigationBar = async () => {
- await fireEvent(screen.getByTestId('bottom-navigation-bar'), 'layout', {
+const layoutNavigationBar = async (
+ testID = 'bottom-navigation-bar',
+ width = 360
+) => {
+ await fireEvent(screen.getByTestId(testID), 'layout', {
nativeEvent: {
- layout: { height: 56, width: 360 },
+ layout: { height: 64, width },
},
});
};
@@ -80,7 +76,6 @@ it('renders bottom navigation with scene animation', async () => {
shifting
sceneAnimationEnabled
sceneAnimationType="shifting"
- sceneAnimationEasing={Easing.ease}
navigationState={createState(0, 5)}
onIndexChange={jest.fn()}
renderScene={renderScene}
@@ -91,69 +86,6 @@ it('renders bottom navigation with scene animation', async () => {
expect(tree).toMatchSnapshot();
});
-// eslint-disable-next-line jest/no-disabled-tests
-it.skip('sceneAnimationEnabled matches animation requirements', async () => {
- const ease = Easing.ease;
-
- await render(
-
- );
-
- // Simulate the button press
- await userEvent.press(screen.getAllByRole('button')[1]);
-
- // Expect the calls to Animated.parallel
- expect(Animated.parallel).toHaveBeenCalledTimes(2);
-
- // Expect the first call to Animated.parallel
- expect(Animated.parallel).toHaveBeenCalledWith(
- expect.arrayContaining([
- expect.objectContaining({
- // ripple
- config: expect.objectContaining({ toValue: 1, duration: 400 }),
- }),
- ])
- );
-
- // Expect the second call to Animated.parallel
- expect(Animated.parallel).toHaveBeenCalledWith(
- expect.arrayContaining([
- expect.objectContaining({
- // previous position anims, shifting to the left
- config: expect.objectContaining({
- toValue: -1,
- duration: 150,
- easing: ease,
- }),
- }),
- expect.objectContaining({
- // active page visibility
- config: expect.objectContaining({
- toValue: 1,
- duration: 150,
- easing: ease,
- }),
- }),
- expect.objectContaining({
- // next position anims, shifting to the right
- config: expect.objectContaining({
- toValue: 1,
- duration: 150,
- easing: ease,
- }),
- }),
- ])
- );
-});
-
it('calls onIndexChange', async () => {
const onIndexChange = jest.fn();
await render(
@@ -169,7 +101,6 @@ it('calls onIndexChange', async () => {
await layoutNavigationBar();
- // pressing same index as active navigation state does not call onIndexChange
await userEvent.press(getTab(0));
expect(onIndexChange).not.toHaveBeenCalled();
@@ -256,23 +187,17 @@ it('renders non-shifting bottom navigation', async () => {
expect(tree).toMatchSnapshot();
});
-it('does not crash when shifting is true and the number of tabs in the navigationState is less than 2', async () => {
- jest.spyOn(console, 'warn').mockImplementation(() => {});
-
+it('does not crash when shifting is true and the number of tabs is less than 2', async () => {
await render(
);
- expect(console.warn).toHaveBeenCalledWith(
- 'BottomNavigation needs at least 2 tabs to run shifting animation'
- );
-
- jest.restoreAllMocks();
+ expect(screen.getAllByText('Route: 0').length).toBeGreaterThan(0);
});
it('renders custom icon and label in shifting bottom navigation', async () => {
@@ -391,22 +316,15 @@ it('hides labels in non-shifting bottom navigation', async () => {
expect(tree).toMatchSnapshot();
});
-it('should have appropriate display style according to the visibility on web', async () => {
- const originalPlatform = Platform.OS;
- Platform.OS = 'web';
-
- const { rerender } = await render(
-
+it('renders a route screen', async () => {
+ await render(
+
+ Visible
+
);
- const wrapper = screen.getByTestId('RouteScreen: 0');
-
- expect(wrapper).toHaveStyle({ display: 'flex' });
-
- await rerender();
- expect(wrapper).toHaveStyle({ display: 'none' });
-
- Platform.OS = originalPlatform;
+ expect(screen.getByTestId('RouteScreen: 0')).toBeOnTheScreen();
+ expect(screen.getByText('Visible')).toBeOnTheScreen();
});
it('should have labelMaxFontSizeMultiplier passed to label', async () => {
@@ -414,7 +332,7 @@ it('should have labelMaxFontSizeMultiplier passed to label', async () => {
await render(
{
{
expect(toJSON()).toMatchSnapshot();
});
-it('uses the rendered bar height when hiding it for the keyboard', async () => {
+it('hides the bar above the keyboard without dropping consumer styles', async () => {
let handleKeyboardShow: ((event: KeyboardEvent) => void) | undefined;
const addKeyboardListener = Keyboard.addListener.bind(Keyboard);
const keyboardListenerSpy = jest
@@ -494,8 +412,8 @@ it('uses the rendered bar height when hiding it for the keyboard', async () => {
expect(navigation).toHaveStyle({
height: 96,
position: 'absolute',
- transform: [{ translateY: 72 }],
});
+ expect(navigation).toHaveStyle({ pointerEvents: 'none' });
keyboardListenerSpy.mockRestore();
});
@@ -529,6 +447,83 @@ it('renders bottom navigation with getLazy', async () => {
expect(screen.queryByTestId('RouteScreen: 2')).not.toBeOnTheScreen();
});
+it('mounts a lazy screen after it becomes focused', async () => {
+ const onIndexChange = jest.fn();
+ const { rerender } = await render(
+ true}
+ />
+ );
+
+ expect(screen.getByTestId('RouteScreen: 0')).toBeOnTheScreen();
+ expect(screen.queryByTestId('RouteScreen: 1')).not.toBeOnTheScreen();
+
+ await layoutNavigationBar();
+ await userEvent.press(getTab(1));
+ expect(onIndexChange).toHaveBeenCalledWith(1);
+
+ await rerender(
+ true}
+ />
+ );
+
+ expect(
+ screen.getByTestId('RouteScreen: 0', { includeHiddenElements: true })
+ ).toBeOnTheScreen();
+ expect(
+ screen.getByTestId('RouteScreen: 1', { includeHiddenElements: true })
+ ).toBeOnTheScreen();
+});
+
+it('renders numeric and dot badges', async () => {
+ await render(
+
+ );
+
+ expect(screen.getByText('3')).toBeOnTheScreen();
+ expect(screen.getAllByText('Inbox').length).toBeGreaterThan(0);
+ expect(screen.getAllByText('Updates').length).toBeGreaterThan(0);
+});
+
+it('renders horizontal items when itemLayout is horizontal', async () => {
+ const tree = (
+ await render(
+
+ )
+ ).toJSON();
+
+ expect(tree).toMatchSnapshot();
+});
+
it('applies maxTabBarWidth styling if compact prop is truthy', async () => {
const { toJSON } = await render(
{
it.each([
{ tintColor: '#FBF7DB', focused: true, expected: '#FBF7DB' },
{ tintColor: '#853D4B', focused: true, expected: '#853D4B' },
- { tintColor: undefined, focused: true, expected: Palette.neutral10 },
+ { tintColor: undefined, focused: true, expected: Palette.secondary40 },
+ {
+ tintColor: undefined,
+ focused: true,
+ onIndicator: true,
+ expected: Palette.secondary10,
+ },
{
tintColor: undefined,
focused: false,
expected: Palette.neutralVariant30,
},
])(
- 'returns $expected when tintColor: $tintColor, focused: $focused',
- ({ tintColor, focused, expected }) => {
+ 'returns $expected when tintColor: $tintColor, focused: $focused, onIndicator: $onIndicator',
+ ({ tintColor, focused, onIndicator, expected }) => {
const result = getLabelColor({
tintColor: tintColor ?? '',
hasColor: Boolean(tintColor),
focused,
+ onIndicator,
theme: LightTheme,
});
expect(result).toBe(expected);
@@ -616,31 +618,42 @@ describe('getLabelColor', () => {
);
});
-it('supports animated styles in bar', async () => {
- const value = new Animated.Value(1);
+describe('resolveItemLayout', () => {
+ it('keeps an explicit layout', () => {
+ expect(resolveItemLayout({ itemLayout: 'horizontal', width: 320 })).toBe(
+ 'horizontal'
+ );
+ expect(resolveItemLayout({ itemLayout: 'vertical', width: 800 })).toBe(
+ 'vertical'
+ );
+ });
+
+ it('uses horizontal items at the medium window width', () => {
+ expect(resolveItemLayout({ itemLayout: 'auto', width: 600 })).toBe(
+ 'horizontal'
+ );
+ expect(resolveItemLayout({ itemLayout: 'auto', width: 360 })).toBe(
+ 'vertical'
+ );
+ });
+});
+
+it('uses a pressed state-layer color for the active item ripple', () => {
+ expect(getItemRippleColor({ focused: true, theme: LightTheme })).toBe(
+ 'rgba(29, 25, 43, 0.1)'
+ );
+});
+
+it('supports styles in bar', async () => {
await render(
);
- expect(screen.getByTestId('bottom-navigation')).toHaveStyle({
- transform: [{ scale: 1 }],
- });
-
- Animated.timing(value, {
- toValue: 1.5,
- useNativeDriver: false,
- duration: 200,
- }).start();
-
- await act(() => {
- jest.advanceTimersByTime(200);
- });
-
expect(screen.getByTestId('bottom-navigation')).toHaveStyle({
transform: [{ scale: 1.5 }],
});
diff --git a/src/components/__tests__/__snapshots__/BottomNavigation.test.tsx.snap b/src/components/__tests__/__snapshots__/BottomNavigation.test.tsx.snap
index 22b83abaaa..649461fd23 100644
--- a/src/components/__tests__/__snapshots__/BottomNavigation.test.tsx.snap
+++ b/src/components/__tests__/__snapshots__/BottomNavigation.test.tsx.snap
@@ -38,10 +38,9 @@ exports[`allows customizing Route's type via generics 1`] = `
"position": "absolute",
"right": 0,
"top": 0,
- "zIndex": 1,
},
{
- "display": undefined,
+ "zIndex": 1,
},
]
}
@@ -51,18 +50,23 @@ exports[`allows customizing Route's type via generics 1`] = `
collapsable={false}
renderToHardwareTextureAndroid={false}
style={
- {
- "flex": 1,
- "opacity": 1,
- "transform": [
- {
- "translateX": 0,
- },
- {
- "translateY": 0,
- },
- ],
- }
+ [
+ {
+ "flex": 1,
+ },
+ {
+ "display": "flex",
+ "opacity": 1,
+ "transform": [
+ {
+ "translateX": 0,
+ },
+ {
+ "translateY": 0,
+ },
+ ],
+ },
+ ]
}
>
@@ -75,22 +79,33 @@ exports[`allows customizing Route's type via generics 1`] = `
collapsable={false}
onLayout={[Function]}
style={
- {
- "bottom": 0,
- "left": 0,
- "pointerEvents": "none",
- "right": 0,
- }
+ [
+ {
+ "bottom": 0,
+ "left": 0,
+ "right": 0,
+ },
+ null,
+ null,
+ undefined,
+ {
+ "pointerEvents": "none",
+ },
+ ]
}
>
-
-
- First
-
-
-
-
- First
-
-
+ First
+
@@ -419,11 +383,10 @@ exports[`allows customizing Route's type via generics 1`] = `
style={
[
{
- "flex": 1,
- "paddingVertical": 6,
+ "paddingVertical": 0,
},
{
- "paddingVertical": 0,
+ "flex": 1,
},
]
}
@@ -431,64 +394,100 @@ exports[`allows customizing Route's type via generics 1`] = `
+
-
-
- Second
-
-
-
-
- Second
-
-
+ Second
+
@@ -690,10 +616,9 @@ exports[`applies maxTabBarWidth styling if compact prop is truthy 1`] = `
"position": "absolute",
"right": 0,
"top": 0,
- "zIndex": 1,
},
{
- "display": undefined,
+ "zIndex": 1,
},
]
}
@@ -703,18 +628,23 @@ exports[`applies maxTabBarWidth styling if compact prop is truthy 1`] = `
collapsable={false}
renderToHardwareTextureAndroid={false}
style={
- {
- "flex": 1,
- "opacity": 1,
- "transform": [
- {
- "translateX": 0,
- },
- {
- "translateY": 0,
- },
- ],
- }
+ [
+ {
+ "flex": 1,
+ },
+ {
+ "display": "flex",
+ "opacity": 1,
+ "transform": [
+ {
+ "translateX": 0,
+ },
+ {
+ "translateY": 0,
+ },
+ ],
+ },
+ ]
}
>
@@ -736,10 +666,9 @@ exports[`applies maxTabBarWidth styling if compact prop is truthy 1`] = `
"position": "absolute",
"right": 0,
"top": 0,
- "zIndex": 0,
},
{
- "display": undefined,
+ "zIndex": 0,
},
]
}
@@ -749,18 +678,23 @@ exports[`applies maxTabBarWidth styling if compact prop is truthy 1`] = `
collapsable={false}
renderToHardwareTextureAndroid={false}
style={
- {
- "flex": 1,
- "opacity": 0,
- "transform": [
- {
- "translateX": 0,
- },
- {
- "translateY": 9999,
- },
- ],
- }
+ [
+ {
+ "flex": 1,
+ },
+ {
+ "display": "flex",
+ "opacity": 0,
+ "transform": [
+ {
+ "translateX": 0,
+ },
+ {
+ "translateY": 9999,
+ },
+ ],
+ },
+ ]
}
>
@@ -782,10 +716,9 @@ exports[`applies maxTabBarWidth styling if compact prop is truthy 1`] = `
"position": "absolute",
"right": 0,
"top": 0,
- "zIndex": 0,
},
{
- "display": undefined,
+ "zIndex": 0,
},
]
}
@@ -795,18 +728,23 @@ exports[`applies maxTabBarWidth styling if compact prop is truthy 1`] = `
collapsable={false}
renderToHardwareTextureAndroid={false}
style={
- {
- "flex": 1,
- "opacity": 0,
- "transform": [
- {
- "translateX": 0,
- },
- {
- "translateY": 9999,
- },
- ],
- }
+ [
+ {
+ "flex": 1,
+ },
+ {
+ "display": "flex",
+ "opacity": 0,
+ "transform": [
+ {
+ "translateX": 0,
+ },
+ {
+ "translateY": 9999,
+ },
+ ],
+ },
+ ]
}
>
@@ -828,10 +766,9 @@ exports[`applies maxTabBarWidth styling if compact prop is truthy 1`] = `
"position": "absolute",
"right": 0,
"top": 0,
- "zIndex": 0,
},
{
- "display": undefined,
+ "zIndex": 0,
},
]
}
@@ -841,18 +778,23 @@ exports[`applies maxTabBarWidth styling if compact prop is truthy 1`] = `
collapsable={false}
renderToHardwareTextureAndroid={false}
style={
- {
- "flex": 1,
- "opacity": 0,
- "transform": [
- {
- "translateX": 0,
- },
- {
- "translateY": 9999,
- },
- ],
- }
+ [
+ {
+ "flex": 1,
+ },
+ {
+ "display": "flex",
+ "opacity": 0,
+ "transform": [
+ {
+ "translateX": 0,
+ },
+ {
+ "translateY": 9999,
+ },
+ ],
+ },
+ ]
}
>
@@ -865,22 +807,33 @@ exports[`applies maxTabBarWidth styling if compact prop is truthy 1`] = `
collapsable={false}
onLayout={[Function]}
style={
- {
- "bottom": 0,
- "left": 0,
- "pointerEvents": "none",
- "right": 0,
- }
+ [
+ {
+ "bottom": 0,
+ "left": 0,
+ "right": 0,
+ },
+ null,
+ null,
+ undefined,
+ {
+ "pointerEvents": "none",
+ },
+ ]
}
>
-
-
- Route: 0
-
-
-
-
- Route: 0
-
-
+ Route: 0
+
@@ -1271,11 +1173,10 @@ exports[`applies maxTabBarWidth styling if compact prop is truthy 1`] = `
style={
[
{
- "flex": 1,
- "paddingVertical": 6,
+ "paddingVertical": 0,
},
{
- "paddingVertical": 0,
+ "flex": 1,
},
]
}
@@ -1283,38 +1184,70 @@ exports[`applies maxTabBarWidth styling if compact prop is truthy 1`] = `
+
-
-
- Route: 1
-
-
-
-
- Route: 1
-
-
+ Route: 1
+
@@ -1591,11 +1455,10 @@ exports[`applies maxTabBarWidth styling if compact prop is truthy 1`] = `
style={
[
{
- "flex": 1,
- "paddingVertical": 6,
+ "paddingVertical": 0,
},
{
- "paddingVertical": 0,
+ "flex": 1,
},
]
}
@@ -1603,38 +1466,70 @@ exports[`applies maxTabBarWidth styling if compact prop is truthy 1`] = `
+
-
-
- Route: 2
-
-
-
-
- Route: 2
-
-
+ Route: 2
+
@@ -1911,11 +1737,10 @@ exports[`applies maxTabBarWidth styling if compact prop is truthy 1`] = `
style={
[
{
- "flex": 1,
- "paddingVertical": 6,
+ "paddingVertical": 0,
},
{
- "paddingVertical": 0,
+ "flex": 1,
},
]
}
@@ -1923,44 +1748,76 @@ exports[`applies maxTabBarWidth styling if compact prop is truthy 1`] = `
-
+
+
-
-
- Route: 3
-
-
-
-
- Route: 3
-
-
+ Route: 3
+
@@ -2231,11 +2019,10 @@ exports[`applies maxTabBarWidth styling if compact prop is truthy 1`] = `
style={
[
{
- "flex": 1,
- "paddingVertical": 6,
+ "paddingVertical": 0,
},
{
- "paddingVertical": 0,
+ "flex": 1,
},
]
}
@@ -2243,38 +2030,70 @@ exports[`applies maxTabBarWidth styling if compact prop is truthy 1`] = `
+
-
-
- Route: 4
-
-
-
-
- Route: 4
-
-
+ Route: 4
+
@@ -2562,10 +2312,9 @@ exports[`does not apply maxTabBarWidth styling if compact prop is falsy 1`] = `
"position": "absolute",
"right": 0,
"top": 0,
- "zIndex": 1,
},
{
- "display": undefined,
+ "zIndex": 1,
},
]
}
@@ -2575,25 +2324,30 @@ exports[`does not apply maxTabBarWidth styling if compact prop is falsy 1`] = `
collapsable={false}
renderToHardwareTextureAndroid={false}
style={
- {
- "flex": 1,
- "opacity": 1,
- "transform": [
- {
- "translateX": 0,
- },
- {
- "translateY": 0,
- },
- ],
- }
- }
- >
-
- Route: 0
-
-
-
+ [
+ {
+ "flex": 1,
+ },
+ {
+ "display": "flex",
+ "opacity": 1,
+ "transform": [
+ {
+ "translateX": 0,
+ },
+ {
+ "translateY": 0,
+ },
+ ],
+ },
+ ]
+ }
+ >
+
+ Route: 0
+
+
+
@@ -2654,10 +2412,9 @@ exports[`does not apply maxTabBarWidth styling if compact prop is falsy 1`] = `
"position": "absolute",
"right": 0,
"top": 0,
- "zIndex": 0,
},
{
- "display": undefined,
+ "zIndex": 0,
},
]
}
@@ -2667,18 +2424,23 @@ exports[`does not apply maxTabBarWidth styling if compact prop is falsy 1`] = `
collapsable={false}
renderToHardwareTextureAndroid={false}
style={
- {
- "flex": 1,
- "opacity": 0,
- "transform": [
- {
- "translateX": 0,
- },
- {
- "translateY": 9999,
- },
- ],
- }
+ [
+ {
+ "flex": 1,
+ },
+ {
+ "display": "flex",
+ "opacity": 0,
+ "transform": [
+ {
+ "translateX": 0,
+ },
+ {
+ "translateY": 9999,
+ },
+ ],
+ },
+ ]
}
>
@@ -2700,10 +2462,9 @@ exports[`does not apply maxTabBarWidth styling if compact prop is falsy 1`] = `
"position": "absolute",
"right": 0,
"top": 0,
- "zIndex": 0,
},
{
- "display": undefined,
+ "zIndex": 0,
},
]
}
@@ -2713,18 +2474,23 @@ exports[`does not apply maxTabBarWidth styling if compact prop is falsy 1`] = `
collapsable={false}
renderToHardwareTextureAndroid={false}
style={
- {
- "flex": 1,
- "opacity": 0,
- "transform": [
- {
- "translateX": 0,
- },
- {
- "translateY": 9999,
- },
- ],
- }
+ [
+ {
+ "flex": 1,
+ },
+ {
+ "display": "flex",
+ "opacity": 0,
+ "transform": [
+ {
+ "translateX": 0,
+ },
+ {
+ "translateY": 9999,
+ },
+ ],
+ },
+ ]
}
>
@@ -2737,22 +2503,33 @@ exports[`does not apply maxTabBarWidth styling if compact prop is falsy 1`] = `
collapsable={false}
onLayout={[Function]}
style={
- {
- "bottom": 0,
- "left": 0,
- "pointerEvents": "none",
- "right": 0,
- }
+ [
+ {
+ "bottom": 0,
+ "left": 0,
+ "right": 0,
+ },
+ null,
+ null,
+ undefined,
+ {
+ "pointerEvents": "none",
+ },
+ ]
}
>
-
-
- Route: 0
-
-
-
-
- Route: 0
-
-
+ Route: 0
+
@@ -3141,11 +2867,10 @@ exports[`does not apply maxTabBarWidth styling if compact prop is falsy 1`] = `
style={
[
{
- "flex": 1,
- "paddingVertical": 6,
+ "paddingVertical": 0,
},
{
- "paddingVertical": 0,
+ "flex": 1,
},
]
}
@@ -3153,38 +2878,70 @@ exports[`does not apply maxTabBarWidth styling if compact prop is falsy 1`] = `
+
-
-
- Route: 1
-
-
-
-
- Route: 1
-
-
+ Route: 1
+
@@ -3461,11 +3149,10 @@ exports[`does not apply maxTabBarWidth styling if compact prop is falsy 1`] = `
style={
[
{
- "flex": 1,
- "paddingVertical": 6,
+ "paddingVertical": 0,
},
{
- "paddingVertical": 0,
+ "flex": 1,
},
]
}
@@ -3473,38 +3160,70 @@ exports[`does not apply maxTabBarWidth styling if compact prop is falsy 1`] = `
+
-
-
- Route: 2
-
-
-
-
- Route: 2
-
-
+ Route: 2
+
@@ -3781,11 +3431,10 @@ exports[`does not apply maxTabBarWidth styling if compact prop is falsy 1`] = `
style={
[
{
- "flex": 1,
- "paddingVertical": 6,
+ "paddingVertical": 0,
},
{
- "paddingVertical": 0,
+ "flex": 1,
},
]
}
@@ -3793,38 +3442,70 @@ exports[`does not apply maxTabBarWidth styling if compact prop is falsy 1`] = `
+
+ [
+ {
+ "alignItems": "center",
+ "bottom": 0,
+ "justifyContent": "center",
+ "left": 0,
+ "position": "absolute",
+ "right": 0,
+ "top": 0,
+ },
+ {
+ "opacity": 1,
+ },
+ ]
+ }
+ >
-
-
- Route: 3
-
-
-
-
- Route: 3
-
-
+ Route: 3
+
@@ -4101,11 +3713,10 @@ exports[`does not apply maxTabBarWidth styling if compact prop is falsy 1`] = `
style={
[
{
- "flex": 1,
- "paddingVertical": 6,
+ "paddingVertical": 0,
},
{
- "paddingVertical": 0,
+ "flex": 1,
},
]
}
@@ -4113,38 +3724,70 @@ exports[`does not apply maxTabBarWidth styling if compact prop is falsy 1`] = `
+
-
-
- Route: 4
-
-
-
-
- Route: 4
-
-
+ Route: 4
+
@@ -4431,10 +4005,9 @@ exports[`hides labels in non-shifting bottom navigation 1`] = `
"position": "absolute",
"right": 0,
"top": 0,
- "zIndex": 1,
},
{
- "display": undefined,
+ "zIndex": 1,
},
]
}
@@ -4444,18 +4017,23 @@ exports[`hides labels in non-shifting bottom navigation 1`] = `
collapsable={false}
renderToHardwareTextureAndroid={false}
style={
- {
- "flex": 1,
- "opacity": 1,
- "transform": [
- {
- "translateX": 0,
- },
- {
- "translateY": 0,
- },
- ],
- }
+ [
+ {
+ "flex": 1,
+ },
+ {
+ "display": "flex",
+ "opacity": 1,
+ "transform": [
+ {
+ "translateX": 0,
+ },
+ {
+ "translateY": 0,
+ },
+ ],
+ },
+ ]
}
>
@@ -4468,22 +4046,33 @@ exports[`hides labels in non-shifting bottom navigation 1`] = `
collapsable={false}
onLayout={[Function]}
style={
- {
- "bottom": 0,
- "left": 0,
- "pointerEvents": "none",
- "right": 0,
- }
+ [
+ {
+ "bottom": 0,
+ "left": 0,
+ "right": 0,
+ },
+ null,
+ null,
+ undefined,
+ {
+ "pointerEvents": "none",
+ },
+ ]
}
>
+
+
@@ -5199,22 +4896,33 @@ exports[`hides labels in shifting bottom navigation 1`] = `
collapsable={false}
onLayout={[Function]}
style={
- {
- "bottom": 0,
- "left": 0,
- "pointerEvents": "none",
- "right": 0,
- }
+ [
+ {
+ "bottom": 0,
+ "left": 0,
+ "right": 0,
+ },
+ null,
+ null,
+ undefined,
+ {
+ "pointerEvents": "none",
+ },
+ ]
}
>
-
+
+
+
@@ -5939,10 +5755,9 @@ exports[`renders bottom navigation with getLazy 1`] = `
"position": "absolute",
"right": 0,
"top": 0,
- "zIndex": 0,
},
{
- "display": undefined,
+ "zIndex": 0,
},
]
}
@@ -5952,18 +5767,23 @@ exports[`renders bottom navigation with getLazy 1`] = `
collapsable={false}
renderToHardwareTextureAndroid={false}
style={
- {
- "flex": 1,
- "opacity": 0,
- "transform": [
- {
- "translateX": 0,
- },
- {
- "translateY": 9999,
- },
- ],
- }
+ [
+ {
+ "flex": 1,
+ },
+ {
+ "display": "flex",
+ "opacity": 0,
+ "transform": [
+ {
+ "translateX": 0,
+ },
+ {
+ "translateY": 9999,
+ },
+ ],
+ },
+ ]
}
>
@@ -5985,10 +5805,9 @@ exports[`renders bottom navigation with getLazy 1`] = `
"position": "absolute",
"right": 0,
"top": 0,
- "zIndex": 0,
},
{
- "display": undefined,
+ "zIndex": 0,
},
]
}
@@ -5998,18 +5817,23 @@ exports[`renders bottom navigation with getLazy 1`] = `
collapsable={false}
renderToHardwareTextureAndroid={false}
style={
- {
- "flex": 1,
- "opacity": 0,
- "transform": [
- {
- "translateX": 0,
- },
- {
- "translateY": 9999,
- },
- ],
- }
+ [
+ {
+ "flex": 1,
+ },
+ {
+ "display": "flex",
+ "opacity": 0,
+ "transform": [
+ {
+ "translateX": 0,
+ },
+ {
+ "translateY": 9999,
+ },
+ ],
+ },
+ ]
}
>
@@ -6031,10 +5855,9 @@ exports[`renders bottom navigation with getLazy 1`] = `
"position": "absolute",
"right": 0,
"top": 0,
- "zIndex": 0,
},
{
- "display": undefined,
+ "zIndex": 0,
},
]
}
@@ -6044,18 +5867,23 @@ exports[`renders bottom navigation with getLazy 1`] = `
collapsable={false}
renderToHardwareTextureAndroid={false}
style={
- {
- "flex": 1,
- "opacity": 0,
- "transform": [
- {
- "translateX": 0,
- },
- {
- "translateY": 9999,
- },
- ],
- }
+ [
+ {
+ "flex": 1,
+ },
+ {
+ "display": "flex",
+ "opacity": 0,
+ "transform": [
+ {
+ "translateX": 0,
+ },
+ {
+ "translateY": 9999,
+ },
+ ],
+ },
+ ]
}
>
@@ -6068,22 +5896,33 @@ exports[`renders bottom navigation with getLazy 1`] = `
collapsable={false}
onLayout={[Function]}
style={
- {
- "bottom": 0,
- "left": 0,
- "pointerEvents": "none",
- "right": 0,
- }
+ [
+ {
+ "bottom": 0,
+ "left": 0,
+ "right": 0,
+ },
+ null,
+ null,
+ undefined,
+ {
+ "pointerEvents": "none",
+ },
+ ]
}
>
-
-
- Route: 0
-
-
-
-
- Route: 0
-
-
+ Route: 0
+
@@ -6472,11 +6260,10 @@ exports[`renders bottom navigation with getLazy 1`] = `
style={
[
{
- "flex": 1,
- "paddingVertical": 6,
+ "paddingVertical": 0,
},
{
- "paddingVertical": 0,
+ "flex": 1,
},
]
}
@@ -6484,38 +6271,70 @@ exports[`renders bottom navigation with getLazy 1`] = `
+
-
-
- Route: 1
-
-
-
-
- Route: 1
-
-
+ Route: 1
+
@@ -6792,11 +6542,10 @@ exports[`renders bottom navigation with getLazy 1`] = `
style={
[
{
- "flex": 1,
- "paddingVertical": 6,
+ "paddingVertical": 0,
},
{
- "paddingVertical": 0,
+ "flex": 1,
},
]
}
@@ -6804,39 +6553,71 @@ exports[`renders bottom navigation with getLazy 1`] = `
+
-
-
- Route: 2
-
-
-
-
- Route: 2
-
-
+ Route: 2
+
@@ -7112,11 +6824,10 @@ exports[`renders bottom navigation with getLazy 1`] = `
style={
[
{
- "flex": 1,
- "paddingVertical": 6,
+ "paddingVertical": 0,
},
{
- "paddingVertical": 0,
+ "flex": 1,
},
]
}
@@ -7124,38 +6835,70 @@ exports[`renders bottom navigation with getLazy 1`] = `
+
-
-
- Route: 3
-
-
-
-
- Route: 3
-
-
+ Route: 3
+
@@ -7432,11 +7106,10 @@ exports[`renders bottom navigation with getLazy 1`] = `
style={
[
{
- "flex": 1,
- "paddingVertical": 6,
+ "paddingVertical": 0,
},
{
- "paddingVertical": 0,
+ "flex": 1,
},
]
}
@@ -7444,43 +7117,75 @@ exports[`renders bottom navigation with getLazy 1`] = `
-
+
+
-
-
- Route: 4
-
-
-
-
- Route: 4
-
-
+ Route: 4
+
@@ -7762,10 +7398,9 @@ exports[`renders bottom navigation with scene animation 1`] = `
"position": "absolute",
"right": 0,
"top": 0,
- "zIndex": 1,
},
{
- "display": undefined,
+ "zIndex": 1,
},
]
}
@@ -7775,18 +7410,23 @@ exports[`renders bottom navigation with scene animation 1`] = `
collapsable={false}
renderToHardwareTextureAndroid={true}
style={
- {
- "flex": 1,
- "opacity": 1,
- "transform": [
- {
- "translateX": 0,
- },
- {
- "translateY": 0,
- },
- ],
- }
+ [
+ {
+ "flex": 1,
+ },
+ {
+ "display": "flex",
+ "opacity": 1,
+ "transform": [
+ {
+ "translateX": 0,
+ },
+ {
+ "translateY": 0,
+ },
+ ],
+ },
+ ]
}
>
@@ -7799,22 +7439,33 @@ exports[`renders bottom navigation with scene animation 1`] = `
collapsable={false}
onLayout={[Function]}
style={
- {
- "bottom": 0,
- "left": 0,
- "pointerEvents": "none",
- "right": 0,
- }
+ [
+ {
+ "bottom": 0,
+ "left": 0,
+ "right": 0,
+ },
+ null,
+ null,
+ undefined,
+ {
+ "pointerEvents": "none",
+ },
+ ]
}
>
-
-
- Route: 0
-
-
+ ],
+ ]
+ }
+ >
+ Route: 0
+
@@ -8152,11 +7803,10 @@ exports[`renders bottom navigation with scene animation 1`] = `
style={
[
{
- "flex": 1,
- "paddingVertical": 6,
+ "paddingVertical": 0,
},
{
- "paddingVertical": 0,
+ "flex": 1,
},
]
}
@@ -8164,43 +7814,70 @@ exports[`renders bottom navigation with scene animation 1`] = `
+
-
-
- Route: 1
-
-
+ ],
+ ]
+ }
+ >
+ Route: 1
+
@@ -8421,11 +8085,10 @@ exports[`renders bottom navigation with scene animation 1`] = `
style={
[
{
- "flex": 1,
- "paddingVertical": 6,
+ "paddingVertical": 0,
},
{
- "paddingVertical": 0,
+ "flex": 1,
},
]
}
@@ -8433,43 +8096,70 @@ exports[`renders bottom navigation with scene animation 1`] = `
+
-
-
- Route: 2
-
-
+ ],
+ ]
+ }
+ >
+ Route: 2
+
@@ -8690,11 +8367,10 @@ exports[`renders bottom navigation with scene animation 1`] = `
style={
[
{
- "flex": 1,
- "paddingVertical": 6,
+ "paddingVertical": 0,
},
{
- "paddingVertical": 0,
+ "flex": 1,
},
]
}
@@ -8702,43 +8378,70 @@ exports[`renders bottom navigation with scene animation 1`] = `
+
-
-
- Route: 3
-
-
+ ],
+ ]
+ }
+ >
+ Route: 3
+
@@ -8959,11 +8649,10 @@ exports[`renders bottom navigation with scene animation 1`] = `
style={
[
{
- "flex": 1,
- "paddingVertical": 6,
+ "paddingVertical": 0,
},
{
- "paddingVertical": 0,
+ "flex": 1,
},
]
}
@@ -8971,43 +8660,70 @@ exports[`renders bottom navigation with scene animation 1`] = `
+
-
-
- Route: 4
-
-
+ ],
+ ]
+ }
+ >
+ Route: 4
+
@@ -9239,10 +8942,9 @@ exports[`renders custom background color passed to barStyle property 1`] = `
"position": "absolute",
"right": 0,
"top": 0,
- "zIndex": 1,
},
{
- "display": undefined,
+ "zIndex": 1,
},
]
}
@@ -9252,18 +8954,23 @@ exports[`renders custom background color passed to barStyle property 1`] = `
collapsable={false}
renderToHardwareTextureAndroid={false}
style={
- {
- "flex": 1,
- "opacity": 1,
- "transform": [
- {
- "translateX": 0,
- },
- {
- "translateY": 0,
- },
- ],
- }
+ [
+ {
+ "flex": 1,
+ },
+ {
+ "display": "flex",
+ "opacity": 1,
+ "transform": [
+ {
+ "translateX": 0,
+ },
+ {
+ "translateY": 0,
+ },
+ ],
+ },
+ ]
}
>
@@ -9276,23 +8983,35 @@ exports[`renders custom background color passed to barStyle property 1`] = `
collapsable={false}
onLayout={[Function]}
style={
- {
- "backgroundColor": "rgba(228, 105, 98, 1)",
- "bottom": 0,
- "left": 0,
- "pointerEvents": "none",
- "right": 0,
- }
+ [
+ {
+ "bottom": 0,
+ "left": 0,
+ "right": 0,
+ },
+ null,
+ null,
+ {
+ "backgroundColor": "rgba(228, 105, 98, 1)",
+ },
+ {
+ "pointerEvents": "none",
+ },
+ ]
}
>
-
-
- Route: 0
-
-
-
-
- Route: 0
-
-
+ Route: 0
+
@@ -9681,11 +9349,10 @@ exports[`renders custom background color passed to barStyle property 1`] = `
style={
[
{
- "flex": 1,
- "paddingVertical": 6,
+ "paddingVertical": 0,
},
{
- "paddingVertical": 0,
+ "flex": 1,
},
]
}
@@ -9693,38 +9360,70 @@ exports[`renders custom background color passed to barStyle property 1`] = `
+
-
-
- Route: 1
-
-
-
-
- Route: 1
-
-
+ Route: 1
+
@@ -10001,11 +9631,10 @@ exports[`renders custom background color passed to barStyle property 1`] = `
style={
[
{
- "flex": 1,
- "paddingVertical": 6,
+ "paddingVertical": 0,
},
{
- "paddingVertical": 0,
+ "flex": 1,
},
]
}
@@ -10013,38 +9642,70 @@ exports[`renders custom background color passed to barStyle property 1`] = `
+
-
-
- Route: 2
-
-
-
-
- Route: 2
-
-
+ Route: 2
+
@@ -10331,10 +9923,9 @@ exports[`renders custom icon and label in non-shifting bottom navigation 1`] = `
"position": "absolute",
"right": 0,
"top": 0,
- "zIndex": 1,
},
{
- "display": undefined,
+ "zIndex": 1,
},
]
}
@@ -10344,18 +9935,23 @@ exports[`renders custom icon and label in non-shifting bottom navigation 1`] = `
collapsable={false}
renderToHardwareTextureAndroid={false}
style={
- {
- "flex": 1,
- "opacity": 1,
- "transform": [
- {
- "translateX": 0,
- },
- {
- "translateY": 0,
- },
- ],
- }
+ [
+ {
+ "flex": 1,
+ },
+ {
+ "display": "flex",
+ "opacity": 1,
+ "transform": [
+ {
+ "translateX": 0,
+ },
+ {
+ "translateY": 0,
+ },
+ ],
+ },
+ ]
}
>
@@ -10368,22 +9964,33 @@ exports[`renders custom icon and label in non-shifting bottom navigation 1`] = `
collapsable={false}
onLayout={[Function]}
style={
- {
- "bottom": 0,
- "left": 0,
- "pointerEvents": "none",
- "right": 0,
- }
+ [
+ {
+ "bottom": 0,
+ "left": 0,
+ "right": 0,
+ },
+ null,
+ null,
+ undefined,
+ {
+ "pointerEvents": "none",
+ },
+ ]
}
>
-
-
- Route: 0
-
-
-
+
-
- Route: 0
-
-
+ Route: 0
+
@@ -10647,11 +10243,10 @@ exports[`renders custom icon and label in non-shifting bottom navigation 1`] = `
style={
[
{
- "flex": 1,
- "paddingVertical": 6,
+ "paddingVertical": 0,
},
{
- "paddingVertical": 0,
+ "flex": 1,
},
]
}
@@ -10659,76 +10254,112 @@ exports[`renders custom icon and label in non-shifting bottom navigation 1`] = `
-
+
+
-
-
- Route: 1
-
-
-
-
- Route: 1
-
-
+ Route: 1
+
@@ -10842,11 +10440,10 @@ exports[`renders custom icon and label in non-shifting bottom navigation 1`] = `
style={
[
{
- "flex": 1,
- "paddingVertical": 6,
+ "paddingVertical": 0,
},
{
- "paddingVertical": 0,
+ "flex": 1,
},
]
}
@@ -10854,64 +10451,100 @@ exports[`renders custom icon and label in non-shifting bottom navigation 1`] = `
+
-
-
- Route: 2
-
-
-
-
- Route: 2
-
-
+ Route: 2
+
@@ -11047,10 +10647,9 @@ exports[`renders custom icon and label in shifting bottom navigation 1`] = `
"position": "absolute",
"right": 0,
"top": 0,
- "zIndex": 1,
},
{
- "display": undefined,
+ "zIndex": 1,
},
]
}
@@ -11060,18 +10659,23 @@ exports[`renders custom icon and label in shifting bottom navigation 1`] = `
collapsable={false}
renderToHardwareTextureAndroid={false}
style={
- {
- "flex": 1,
- "opacity": 1,
- "transform": [
- {
- "translateX": 0,
- },
- {
- "translateY": 0,
- },
- ],
- }
+ [
+ {
+ "flex": 1,
+ },
+ {
+ "display": "flex",
+ "opacity": 1,
+ "transform": [
+ {
+ "translateX": 0,
+ },
+ {
+ "translateY": 0,
+ },
+ ],
+ },
+ ]
}
>
@@ -11084,22 +10688,33 @@ exports[`renders custom icon and label in shifting bottom navigation 1`] = `
collapsable={false}
onLayout={[Function]}
style={
- {
- "bottom": 0,
- "left": 0,
- "pointerEvents": "none",
- "right": 0,
- }
+ [
+ {
+ "bottom": 0,
+ "left": 0,
+ "right": 0,
+ },
+ null,
+ null,
+ undefined,
+ {
+ "pointerEvents": "none",
+ },
+ ]
}
>
-
+ />
+
-
-
- Route: 0
-
-
+ Route: 0
+
@@ -11345,11 +10967,10 @@ exports[`renders custom icon and label in shifting bottom navigation 1`] = `
style={
[
{
- "flex": 1,
- "paddingVertical": 6,
+ "paddingVertical": 0,
},
{
- "paddingVertical": 0,
+ "flex": 1,
},
]
}
@@ -11357,69 +10978,100 @@ exports[`renders custom icon and label in shifting bottom navigation 1`] = `
+
-
-
- Route: 1
-
-
+ Route: 1
+
@@ -11522,11 +11164,10 @@ exports[`renders custom icon and label in shifting bottom navigation 1`] = `
style={
[
{
- "flex": 1,
- "paddingVertical": 6,
+ "paddingVertical": 0,
},
{
- "paddingVertical": 0,
+ "flex": 1,
},
]
}
@@ -11534,69 +11175,100 @@ exports[`renders custom icon and label in shifting bottom navigation 1`] = `
+
-
-
- Route: 2
-
-
+ Route: 2
+
@@ -11699,11 +11361,10 @@ exports[`renders custom icon and label in shifting bottom navigation 1`] = `
style={
[
{
- "flex": 1,
- "paddingVertical": 6,
+ "paddingVertical": 0,
},
{
- "paddingVertical": 0,
+ "flex": 1,
},
]
}
@@ -11711,69 +11372,100 @@ exports[`renders custom icon and label in shifting bottom navigation 1`] = `
+
+
+ Route: 3
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Route: 4
+
+
+
+
+
+
+
+
+`;
+
+exports[`renders custom icon and label with custom colors in non-shifting bottom navigation 1`] = `
+
+
+
+
+
+ Route: 0
+
+
+
+
+
+
+
+
+
+
+
+
+
+ magnify
+
+
+
+
+ magnify
+
+
+
+
+
+
+
+
+ Route: 0
+
+
+
+
+
+
+
+
+
+ camera
+
+
+
- Route: 3
+ camera
+
+
+
+
+
+
+ Route: 1
+
@@ -11876,11 +12452,10 @@ exports[`renders custom icon and label in shifting bottom navigation 1`] = `
style={
[
{
- "flex": 1,
- "paddingVertical": 6,
+ "paddingVertical": 0,
},
{
- "paddingVertical": 0,
+ "flex": 1,
},
]
}
@@ -11888,69 +12463,160 @@ exports[`renders custom icon and label in shifting bottom navigation 1`] = `
+
+ inbox
+
+
+
+ >
+
+ inbox
+
+
-
-
- Route: 4
-
-
+ Route: 2
+
@@ -12025,7 +12706,7 @@ exports[`renders custom icon and label in shifting bottom navigation 1`] = `
`;
-exports[`renders custom icon and label with custom colors in non-shifting bottom navigation 1`] = `
+exports[`renders custom icon and label with custom colors in shifting bottom navigation 1`] = `
@@ -12100,22 +12785,33 @@ exports[`renders custom icon and label with custom colors in non-shifting bottom
collapsable={false}
onLayout={[Function]}
style={
- {
- "bottom": 0,
- "left": 0,
- "pointerEvents": "none",
- "right": 0,
- }
+ [
+ {
+ "bottom": 0,
+ "left": 0,
+ "right": 0,
+ },
+ null,
+ null,
+ undefined,
+ {
+ "pointerEvents": "none",
+ },
+ ]
}
>
-
-
- Route: 0
-
-
-
-
- Route: 0
-
-
+ Route: 0
+
@@ -12504,11 +13149,10 @@ exports[`renders custom icon and label with custom colors in non-shifting bottom
style={
[
{
- "flex": 1,
- "paddingVertical": 6,
+ "paddingVertical": 0,
},
{
- "paddingVertical": 0,
+ "flex": 1,
},
]
}
@@ -12516,38 +13160,70 @@ exports[`renders custom icon and label with custom colors in non-shifting bottom
+
-
-
- Route: 1
-
-
-
-
- Route: 1
-
-
+ Route: 1
+
@@ -12824,11 +13431,10 @@ exports[`renders custom icon and label with custom colors in non-shifting bottom
style={
[
{
- "flex": 1,
- "paddingVertical": 6,
+ "paddingVertical": 0,
},
{
- "paddingVertical": 0,
+ "flex": 1,
},
]
}
@@ -12836,38 +13442,70 @@ exports[`renders custom icon and label with custom colors in non-shifting bottom
+
-
-
- Route: 2
-
-
-
-
- Route: 2
-
-
+ Route: 2
+
@@ -13116,7 +13685,7 @@ exports[`renders custom icon and label with custom colors in non-shifting bottom
`;
-exports[`renders custom icon and label with custom colors in shifting bottom navigation 1`] = `
+exports[`renders horizontal items when itemLayout is horizontal 1`] = `
@@ -13191,22 +13764,33 @@ exports[`renders custom icon and label with custom colors in shifting bottom nav
collapsable={false}
onLayout={[Function]}
style={
- {
- "bottom": 0,
- "left": 0,
- "pointerEvents": "none",
- "right": 0,
- }
+ [
+ {
+ "bottom": 0,
+ "left": 0,
+ "right": 0,
+ },
+ null,
+ null,
+ undefined,
+ {
+ "pointerEvents": "none",
+ },
+ ]
}
>
-
-
+
+ }
+ >
-
-
- Route: 0
-
-
+ ],
+ ]
+ }
+ >
+ Route: 0
+
@@ -13544,11 +14133,12 @@ exports[`renders custom icon and label with custom colors in shifting bottom nav
style={
[
{
- "flex": 1,
- "paddingVertical": 6,
+ "paddingVertical": 0,
},
{
- "paddingVertical": 0,
+ "flexGrow": 0,
+ "flexShrink": 1,
+ "marginHorizontal": 4,
},
]
}
@@ -13556,43 +14146,72 @@ exports[`renders custom icon and label with custom colors in shifting bottom nav
+
-
-
- Route: 1
-
-
+ ],
+ ]
+ }
+ >
+ Route: 1
+
@@ -13813,11 +14419,12 @@ exports[`renders custom icon and label with custom colors in shifting bottom nav
style={
[
{
- "flex": 1,
- "paddingVertical": 6,
+ "paddingVertical": 0,
},
{
- "paddingVertical": 0,
+ "flexGrow": 0,
+ "flexShrink": 1,
+ "marginHorizontal": 4,
},
]
}
@@ -13825,43 +14432,72 @@ exports[`renders custom icon and label with custom colors in shifting bottom nav
+
inbox
-
-
-
+
+
-
-
- Route: 2
-
-
+ ],
+ ]
+ }
+ >
+ Route: 2
+
@@ -14092,10 +14715,9 @@ exports[`renders non-shifting bottom navigation 1`] = `
"position": "absolute",
"right": 0,
"top": 0,
- "zIndex": 1,
},
{
- "display": undefined,
+ "zIndex": 1,
},
]
}
@@ -14105,18 +14727,23 @@ exports[`renders non-shifting bottom navigation 1`] = `
collapsable={false}
renderToHardwareTextureAndroid={false}
style={
- {
- "flex": 1,
- "opacity": 1,
- "transform": [
- {
- "translateX": 0,
- },
- {
- "translateY": 0,
- },
- ],
- }
+ [
+ {
+ "flex": 1,
+ },
+ {
+ "display": "flex",
+ "opacity": 1,
+ "transform": [
+ {
+ "translateX": 0,
+ },
+ {
+ "translateY": 0,
+ },
+ ],
+ },
+ ]
}
>
@@ -14129,22 +14756,33 @@ exports[`renders non-shifting bottom navigation 1`] = `
collapsable={false}
onLayout={[Function]}
style={
- {
- "bottom": 0,
- "left": 0,
- "pointerEvents": "none",
- "right": 0,
- }
+ [
+ {
+ "bottom": 0,
+ "left": 0,
+ "right": 0,
+ },
+ null,
+ null,
+ undefined,
+ {
+ "pointerEvents": "none",
+ },
+ ]
}
>
-
-
- Route: 0
-
-
-
-
- Route: 0
-
-
+ Route: 0
+
@@ -14533,11 +15120,10 @@ exports[`renders non-shifting bottom navigation 1`] = `
style={
[
{
- "flex": 1,
- "paddingVertical": 6,
+ "paddingVertical": 0,
},
{
- "paddingVertical": 0,
+ "flex": 1,
},
]
}
@@ -14545,38 +15131,70 @@ exports[`renders non-shifting bottom navigation 1`] = `
+
-
-
- Route: 1
-
-
-
-
- Route: 1
-
-
+ Route: 1
+
@@ -14853,11 +15402,10 @@ exports[`renders non-shifting bottom navigation 1`] = `
style={
[
{
- "flex": 1,
- "paddingVertical": 6,
+ "paddingVertical": 0,
},
{
- "paddingVertical": 0,
+ "flex": 1,
},
]
}
@@ -14865,38 +15413,70 @@ exports[`renders non-shifting bottom navigation 1`] = `
+
-
-
- Route: 2
-
-
-
-
- Route: 2
-
-
+ Route: 2
+
@@ -15183,10 +15694,9 @@ exports[`renders shifting bottom navigation 1`] = `
"position": "absolute",
"right": 0,
"top": 0,
- "zIndex": 1,
},
{
- "display": undefined,
+ "zIndex": 1,
},
]
}
@@ -15196,18 +15706,23 @@ exports[`renders shifting bottom navigation 1`] = `
collapsable={false}
renderToHardwareTextureAndroid={false}
style={
- {
- "flex": 1,
- "opacity": 1,
- "transform": [
- {
- "translateX": 0,
- },
- {
- "translateY": 0,
- },
- ],
- }
+ [
+ {
+ "flex": 1,
+ },
+ {
+ "display": "flex",
+ "opacity": 1,
+ "transform": [
+ {
+ "translateX": 0,
+ },
+ {
+ "translateY": 0,
+ },
+ ],
+ },
+ ]
}
>
@@ -15220,22 +15735,33 @@ exports[`renders shifting bottom navigation 1`] = `
collapsable={false}
onLayout={[Function]}
style={
- {
- "bottom": 0,
- "left": 0,
- "pointerEvents": "none",
- "right": 0,
- }
+ [
+ {
+ "bottom": 0,
+ "left": 0,
+ "right": 0,
+ },
+ null,
+ null,
+ undefined,
+ {
+ "pointerEvents": "none",
+ },
+ ]
}
>
-
-
- Route: 0
-
-
+ ],
+ ]
+ }
+ >
+ Route: 0
+
@@ -15573,11 +16099,10 @@ exports[`renders shifting bottom navigation 1`] = `
style={
[
{
- "flex": 1,
- "paddingVertical": 6,
+ "paddingVertical": 0,
},
{
- "paddingVertical": 0,
+ "flex": 1,
},
]
}
@@ -15585,43 +16110,70 @@ exports[`renders shifting bottom navigation 1`] = `
+
-
-
- Route: 1
-
-
+ ],
+ ]
+ }
+ >
+ Route: 1
+
@@ -15842,11 +16381,10 @@ exports[`renders shifting bottom navigation 1`] = `
style={
[
{
- "flex": 1,
- "paddingVertical": 6,
+ "paddingVertical": 0,
},
{
- "paddingVertical": 0,
+ "flex": 1,
},
]
}
@@ -15854,43 +16392,70 @@ exports[`renders shifting bottom navigation 1`] = `
+
-
-
- Route: 2
-
-
+ ],
+ ]
+ }
+ >
+ Route: 2
+
@@ -16111,11 +16663,10 @@ exports[`renders shifting bottom navigation 1`] = `
style={
[
{
- "flex": 1,
- "paddingVertical": 6,
+ "paddingVertical": 0,
},
{
- "paddingVertical": 0,
+ "flex": 1,
},
]
}
@@ -16123,43 +16674,70 @@ exports[`renders shifting bottom navigation 1`] = `
+
-
-
- Route: 3
-
-
+ ],
+ ]
+ }
+ >
+ Route: 3
+
@@ -16380,11 +16945,10 @@ exports[`renders shifting bottom navigation 1`] = `
style={
[
{
- "flex": 1,
- "paddingVertical": 6,
+ "paddingVertical": 0,
},
{
- "paddingVertical": 0,
+ "flex": 1,
},
]
}
@@ -16392,43 +16956,70 @@ exports[`renders shifting bottom navigation 1`] = `
+
-
-
- Route: 4
-
-
+ ],
+ ]
+ }
+ >
+ Route: 4
+
diff --git a/src/index.tsx b/src/index.tsx
index f46d8e22d8..0cb8d609fa 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -69,6 +69,10 @@ export type {
Props as BottomNavigationProps,
BaseRoute as BottomNavigationRoute,
} from './components/BottomNavigation/BottomNavigation';
+export type {
+ BarProps as BottomNavigationBarProps,
+ ItemLayout as BottomNavigationItemLayout,
+} from './components/BottomNavigation/types';
export type { Props as ButtonProps } from './components/Button/Button';
export type { Props as CardProps } from './components/Card/Card';
export type { Props as CardActionsProps } from './components/Card/CardActions';