import * as React from 'react'; import { Animated, ColorValue, EasingFunction, Platform, StyleProp, StyleSheet, View, ViewStyle, } from 'react-native'; import useLatestCallback from 'use-latest-callback'; import BottomNavigationBar from './BottomNavigationBar'; import BottomNavigationRouteScreen from './BottomNavigationRouteScreen'; import { useInternalTheme } from '../../core/theming'; import type { ThemeProp } from '../../types'; import useAnimatedValueArray from '../../utils/useAnimatedValueArray'; import type { IconSource } from '../Icon'; import { Props as TouchableRippleProps } from '../TouchableRipple/TouchableRipple'; export type BaseRoute = { key: string; title?: string; focusedIcon?: IconSource; unfocusedIcon?: IconSource; badge?: string | number | boolean; /** * @deprecated In v5.x works only with theme version 2. */ color?: string; accessibilityLabel?: 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 * - `color`: color to use as background color for shifting bottom navigation @deprecatedProperty In v5.x works only with theme version 2. * - `badge`: badge to show on the tab icon, can be `true` to show a dot, `string` or `number` to show text. * - `accessibilityLabel`: 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; /** * 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 ; * } * } * ``` * * 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') * ``` */ 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: string; }) => React.ReactNode; /** * Callback which React Element to be used as tab label. */ renderLabel?: (props: { route: Route; focused: boolean; color: string; }) => 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.accessibilityLabel` 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 color for the tab, uses `route.color` by default. */ getColor?: (props: { route: Route }) => 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; /** * 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. */ sceneAnimationEnabled?: boolean; /** * The scene animation effect. Specify `'shifting'` for a different effect. * 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; /** * 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; }; /** * 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. */ labelMaxFontSizeMultiplier?: number; style?: StyleProp; activeIndicatorStyle?: StyleProp; /** * @optional */ theme?: ThemeProp; /** * TestID used for testing purposes */ testID?: 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. * * ## Usage * ```js * import * as React from 'react'; * import { BottomNavigation, Text } from 'react-native-paper'; * * const MusicRoute = () => Music; * * const AlbumsRoute = () => Albums; * * const RecentsRoute = () => Recents; * * const NotificationsRoute = () => Notifications; * * const MyComponent = () => { * const [index, setIndex] = React.useState(0); * const [routes] = React.useState([ * { 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' }, * ]); * * const renderScene = BottomNavigation.SceneMap({ * music: MusicRoute, * albums: AlbumsRoute, * recents: RecentsRoute, * notifications: NotificationsRoute, * }); * * return ( * * ); * }; * * export default MyComponent; * ``` */ const BottomNavigation = ({ navigationState, renderScene, renderIcon, renderLabel, renderTouchable, getLabelText, getBadge, getColor, getAccessibilityLabel, getTestID, activeColor, inactiveColor, keyboardHidesNavigationBar = Platform.OS === 'android', barStyle, labeled = true, style, activeIndicatorStyle, sceneAnimationEnabled = false, sceneAnimationType = 'opacity', sceneAnimationEasing, onTabPress, onTabLongPress, onIndexChange, shifting: shiftingProp, safeAreaInsets, labelMaxFontSizeMultiplier = 1, compact: compactProp, testID = 'bottom-navigation', theme: themeOverrides, getLazy = ({ route }: { route: Route }) => route.lazy, }: Props) => { const theme = useInternalTheme(themeOverrides); const { scale } = theme.animation; const compact = compactProp ?? !theme.isV3; let shifting = shiftingProp ?? (theme.isV3 ? false : navigationState.routes.length > 3); if (shifting && navigationState.routes.length < 2) { shifting = false; console.warn( 'BottomNavigation needs at least 2 tabs to run shifting animation' ); } const focusedKey = navigationState.routes[navigationState.index].key; /** * 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) ) ); /** * 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]); } 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: theme.isV3 || shifting ? 150 * scale : 0, 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); } }); } }); }, [ shifting, navigationState.routes, offsetsAnims, scale, tabsPositionAnims, sceneAnimationEasing, theme, ] ); 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>(); 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); } }); animateToIndex(navigationState.index); }, [navigationState.index, animateToIndex, offsetsAnims]); const handleTabPress = useLatestCallback( (event: { route: Route } & TabPressEvent) => { onTabPress?.(event); if (event.defaultPrevented) { return; } const index = navigationState.routes.findIndex( (route) => event.route.key === route.key ); if (index !== navigationState.index) { prevNavigationState.current = navigationState; onIndexChange(index); } } ); const jumpTo = useLatestCallback((key: string) => { const index = navigationState.routes.findIndex( (route) => route.key === key ); prevNavigationState.current = navigationState; onIndexChange(index); }); 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 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 })} ); })} ); }; /** * 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; }) => ( ); }; // @component ./BottomNavigationBar.tsx BottomNavigation.Bar = BottomNavigationBar; export default BottomNavigation; const styles = StyleSheet.create({ container: { flex: 1, overflow: 'hidden', }, content: { flex: 1, }, });