From afd58f2a4a57f7f7d0490172f7d7ddb8609abe95 Mon Sep 17 00:00:00 2001 From: Daniel Potor Date: Mon, 14 Sep 2026 15:12:38 +0300 Subject: [PATCH] refactor(bottom-navigation): align with MD3 navigation behavior --- docs/6.x/docs/guides/bottom-navigation.md | 3 +- docs/6.x/docs/guides/migration.md | 26 + .../extendedExamples/BottomNavigationBar.ts | 2 + .../Examples/BottomNavigationBarExample.tsx | 1 + .../src/Examples/BottomNavigationExample.tsx | 85 +- .../BottomNavigation/BottomNavigation.tsx | 490 +- .../BottomNavigation/BottomNavigationBar.tsx | 834 +- .../BottomNavigation/BottomNavigationItem.tsx | 397 + .../BottomNavigationRouteScreen.tsx | 43 +- .../BottomNavigationScene.tsx | 135 + .../BottomNavigationTouchable.tsx | 38 + src/components/BottomNavigation/SceneMap.tsx | 44 + src/components/BottomNavigation/tokens.ts | 41 + src/components/BottomNavigation/types.ts | 184 + src/components/BottomNavigation/utils.ts | 46 +- .../__tests__/BottomNavigation.test.tsx | 263 +- .../BottomNavigation.test.tsx.snap | 13150 ++++++++-------- src/index.tsx | 4 + 18 files changed, 8221 insertions(+), 7565 deletions(-) create mode 100644 src/components/BottomNavigation/BottomNavigationItem.tsx create mode 100644 src/components/BottomNavigation/BottomNavigationScene.tsx create mode 100644 src/components/BottomNavigation/BottomNavigationTouchable.tsx create mode 100644 src/components/BottomNavigation/SceneMap.tsx create mode 100644 src/components/BottomNavigation/tokens.ts create mode 100644 src/components/BottomNavigation/types.ts 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 ( ); }; @@ -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';