/* eslint-disable react/no-multi-comp */ import * as React from 'react'; import { View, Animated, TouchableWithoutFeedback, TouchableWithoutFeedbackProps, StyleSheet, StyleProp, Platform, Keyboard, ViewStyle, LayoutChangeEvent, } from 'react-native'; import SafeAreaView from 'react-native-safe-area-view'; import color from 'color'; import overlay from '../styles/overlay'; import Icon, { IconSource } from './Icon'; import Surface from './Surface'; import Badge from './Badge'; import TouchableRipple from './TouchableRipple/TouchableRipple'; import Text from './Typography/Text'; import { black, white } from '../styles/colors'; import { withTheme } from '../core/theming'; type Route = { key: string; title?: string; icon?: IconSource; badge?: string | number | boolean; color?: string; accessibilityLabel?: string; testID?: string; }; type NavigationState = { index: number; routes: Route[]; }; type TabPressEvent = { defaultPrevented: boolean; preventDefault(): void; }; type TouchableProps = TouchableWithoutFeedbackProps & { key: string; route: Route; children: React.ReactNode; borderless?: boolean; centered?: boolean; rippleColor?: string; }; type Props = { /** * Whether the shifting style is used, the active tab appears wider and the inactive tabs won't have a label. * By default, this is `true` when you have more than 3 tabs. */ shifting?: boolean; /** * Whether to show labels in tabs. When `false`, only icons will be displayed. */ labeled?: 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 * - `icon`: icon to use as the tab icon, can be a string, an image source or a react component * - `color`: color to use as background color for shifting bottom navigation * - `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: 'Music', icon: 'queue-music', color: '#3F51B5' }, * { key: 'albums', title: 'Albums', icon: 'album', color: '#009688' }, * { key: 'recents', title: 'Recents', icon: 'history', color: '#795548' }, * { key: 'purchased', title: 'Purchased', icon: 'shopping-cart', color: '#607D8B' }, * ] * } * ``` * * `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 `TouchableWithoutFeedback` with `View` on iOS. */ renderTouchable?: (props: TouchableProps) => React.ReactNode; /** * Get label text for the tab, uses `route.title` by default. Use `renderLabel` to replace label component. */ getLabelText?: (props: { route: Route }) => string; /** * 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 the id to locate this tab button in tests, uses `route.testID` by default. */ getTestID?: (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; /** * 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; /** * 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; /** * 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; /** * Style for the bottom navigation bar. * You can set a bottom padding here if you have a translucent navigation bar on Android: * * ```js * barStyle={{ paddingBottom: 48 }} * ``` */ barStyle?: StyleProp; style?: StyleProp; /** * @optional */ theme: ReactNativePaper.Theme; }; type State = { /** * Visibility of the navigation bar, visible state is 1 and invisible is 0. */ visible: Animated.Value; /** * Active state of individual tab items, active state is 1 and inactive state is 0. */ tabs: Animated.Value[]; /** * 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. */ offsets: Animated.Value[]; /** * Index of the currently active tab. Used for setting the background color. * Use don't use the color as an animated value directly, because `setValue` seems to be buggy with colors. */ index: Animated.Value; /** * Animation for the touch, used to determine it's scale and opacity. */ touch: Animated.Value; /** * Animation for the background color ripple, used to determine it's scale and opacity. */ ripple: Animated.Value; /** * Layout of the navigation bar. The width is used to determine the size and position of the ripple. */ layout: { height: number; width: number; measured: boolean }; /** * key of the currently active route. Used only for getDerivedStateFromProps. */ current: string; /** * List of keys of the loaded tabs, tabs will be loaded when navigated to. */ loaded: string[]; /** * Track whether the keyboard is visible to show and hide the navigation bar. */ keyboard: boolean; }; const MIN_RIPPLE_SCALE = 0.001; // Minimum scale is not 0 due to bug with animation const MIN_TAB_WIDTH = 96; const MAX_TAB_WIDTH = 168; const BAR_HEIGHT = 56; const FAR_FAR_AWAY = Platform.OS === 'web' ? 0 : 9999; const Touchable = ({ route: _0, style, children, borderless, centered, rippleColor, ...rest }: TouchableProps) => TouchableRipple.supported ? ( {children} ) : ( {children} ); class SceneComponent extends React.PureComponent { render() { const { component, ...rest } = this.props; return React.createElement(component, rest); } } /** * Bottom navigation provides quick navigation between top-level views of an app with a bottom navigation bar. * It is primarily designed for use on mobile. * * For integration with React Navigation, you can use [react-navigation-material-bottom-tab-navigator](https://github.com/react-navigation/react-navigation-material-bottom-tab-navigator). * * By default Bottom navigation 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/theming.html#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 MyComponent = () => { * const [index, setIndex] = React.useState(0); * const [routes] = React.useState([ * { key: 'music', title: 'Music', icon: 'queue-music' }, * { key: 'albums', title: 'Albums', icon: 'album' }, * { key: 'recents', title: 'Recents', icon: 'history' }, * ]); * * const renderScene = BottomNavigation.SceneMap({ * music: MusicRoute, * albums: AlbumsRoute, * recents: RecentsRoute, * }); * * return ( * * ); * }; * * export default MyComponent; * ``` */ class BottomNavigation extends React.Component { /** * 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. */ static SceneMap(scenes: { [key: string]: React.ComponentType<{ route: Route; jumpTo: (key: string) => void; }>; }) { return ({ route, jumpTo, }: { route: Route; jumpTo: (key: string) => void; }) => ( ); } static defaultProps = { labeled: true, keyboardHidesNavigationBar: true, sceneAnimationEnabled: false, }; static getDerivedStateFromProps( nextProps: Props, prevState: State ): Partial { const { index, routes } = nextProps.navigationState; // Re-create animated values if routes have been added/removed // Preserve previous animated values if they exist, so we don't break animations const tabs = routes.map( // focused === 1, unfocused === 0 (_: any, i: number) => prevState.tabs[i] || new Animated.Value(i === index ? 1 : 0) ); const offsets = routes.map( // offscreen === 1, normal === 0 (_: any, i: number) => prevState.offsets[i] || new Animated.Value(i === index ? 0 : 1) ); const nextState = { tabs, offsets, }; const focusedKey = routes[index].key; if (focusedKey === prevState.current) { return nextState; } return { ...nextState, // Store the current index in state so that we can later check if the index has changed current: focusedKey, // Set the current tab to be loaded if it was not loaded before loaded: prevState.loaded.includes(focusedKey) ? prevState.loaded : [...prevState.loaded, focusedKey], }; } constructor(props: Props) { super(props); const { routes, index } = this.props.navigationState; const focusedKey = routes[index].key; this.state = { visible: new Animated.Value(1), tabs: [], offsets: [], index: new Animated.Value(index), ripple: new Animated.Value(MIN_RIPPLE_SCALE), touch: new Animated.Value(MIN_RIPPLE_SCALE), layout: { height: 0, width: 0, measured: false }, current: focusedKey, loaded: [focusedKey], keyboard: false, }; } componentDidMount() { // Workaround for native animated bug in react-native@^0.57 // Context: https://github.com/callstack/react-native-paper/pull/637 this.animateToCurrentIndex(); if (Platform.OS === 'ios') { Keyboard.addListener('keyboardWillShow', this.handleKeyboardShow); Keyboard.addListener('keyboardWillHide', this.handleKeyboardHide); } else { Keyboard.addListener('keyboardDidShow', this.handleKeyboardShow); Keyboard.addListener('keyboardDidHide', this.handleKeyboardHide); } } componentDidUpdate(prevProps: Props) { if (prevProps.navigationState.index === this.props.navigationState.index) { return; } // Reset offsets of previous and current tabs before animation this.state.offsets.forEach((offset, i) => { if ( i === this.props.navigationState.index || i === prevProps.navigationState.index ) { offset.setValue(0); } }); this.animateToCurrentIndex(); } componentWillUnmount() { if (Platform.OS === 'ios') { Keyboard.removeListener('keyboardWillShow', this.handleKeyboardShow); Keyboard.removeListener('keyboardWillHide', this.handleKeyboardHide); } else { Keyboard.removeListener('keyboardDidShow', this.handleKeyboardShow); Keyboard.removeListener('keyboardDidHide', this.handleKeyboardHide); } } private handleKeyboardShow = () => { const { scale } = this.props.theme.animation; this.setState({ keyboard: true }, () => Animated.timing(this.state.visible, { toValue: 0, duration: 150 * scale, useNativeDriver: true, }).start() ); }; private handleKeyboardHide = () => { const { scale } = this.props.theme.animation; Animated.timing(this.state.visible, { toValue: 1, duration: 100 * scale, useNativeDriver: true, }).start(() => { this.setState({ keyboard: false }); }); }; private animateToCurrentIndex = () => { const shifting = this.isShifting(); const { navigationState, theme: { animation: { scale }, }, } = this.props; const { routes, index } = navigationState; // Reset the ripple to avoid glitch if it's currently animating this.state.ripple.setValue(MIN_RIPPLE_SCALE); Animated.parallel([ Animated.timing(this.state.ripple, { toValue: 1, duration: shifting ? 400 * scale : 0, useNativeDriver: true, }), ...routes.map((_, i) => Animated.timing(this.state.tabs[i], { toValue: i === index ? 1 : 0, duration: shifting ? 150 * scale : 0, useNativeDriver: true, }) ), ]).start(({ finished }) => { // Workaround a bug in native animations where this is reset after first animation this.state.tabs.map((tab, i) => tab.setValue(i === index ? 1 : 0)); // Update the index to change bar's background color and then hide the ripple this.state.index.setValue(index); this.state.ripple.setValue(MIN_RIPPLE_SCALE); 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 this.state.offsets.forEach((offset, i) => { if (i === index) { offset.setValue(0); } else { offset.setValue(1); } }); } }); }; private handleLayout = (e: LayoutChangeEvent) => { const { layout } = this.state; const { height, width } = e.nativeEvent.layout; if (height === layout.height && width === layout.width) { return; } this.setState({ layout: { height, width, measured: true, }, }); }; private handleTabPress = (index: number) => { const { navigationState, onTabPress, onIndexChange } = this.props; const event = { route: navigationState.routes[index], defaultPrevented: false, preventDefault: () => { event.defaultPrevented = true; }, }; onTabPress?.(event); if (event.defaultPrevented) { return; } if (index !== navigationState.index) { onIndexChange(index); } }; private jumpTo = (key: string) => { const index = this.props.navigationState.routes.findIndex( (route) => route.key === key ); this.props.onIndexChange(index); }; private isShifting = () => typeof this.props.shifting === 'boolean' ? this.props.shifting : this.props.navigationState.routes.length > 3; render() { const { navigationState, renderScene, renderIcon, renderLabel, renderTouchable = (props: TouchableProps) => , getLabelText = ({ route }: { route: Route }) => route.title, getBadge = ({ route }: { route: Route }) => route.badge, getColor = ({ route }: { route: Route }) => route.color, getAccessibilityLabel = ({ route }: { route: Route }) => route.accessibilityLabel, getTestID = ({ route }: { route: Route }) => route.testID, activeColor, inactiveColor, keyboardHidesNavigationBar, barStyle, labeled, style, theme, sceneAnimationEnabled, } = this.props; const { layout, loaded, index, visible, ripple, keyboard, tabs, offsets, } = this.state; const { routes } = navigationState; const { colors, dark: isDarkTheme, mode } = theme; const shifting = this.isShifting(); const { backgroundColor: customBackground, elevation = 4 }: ViewStyle = StyleSheet.flatten(barStyle) || {}; const approxBackgroundColor = customBackground ? customBackground : isDarkTheme && mode === 'adaptive' ? overlay(elevation, colors.surface) : colors.primary; const backgroundColor = shifting ? index.interpolate({ inputRange: routes.map((_, i) => i), //@ts-ignore outputRange: routes.map( (route) => getColor({ route }) || approxBackgroundColor ), }) : approxBackgroundColor; const isDark = !color(approxBackgroundColor).isLight(); const textColor = isDark ? white : black; const activeTintColor = typeof activeColor !== 'undefined' ? activeColor : textColor; const inactiveTintColor = typeof inactiveColor !== 'undefined' ? inactiveColor : color(textColor).alpha(0.5).rgb().string(); const touchColor = color(activeColor || activeTintColor) .alpha(0.12) .rgb() .string(); const maxTabWidth = routes.length > 3 ? MIN_TAB_WIDTH : MAX_TAB_WIDTH; const maxTabBarWidth = maxTabWidth * routes.length; const tabBarWidth = Math.min(layout.width, maxTabBarWidth); const tabWidth = tabBarWidth / routes.length; const rippleSize = layout.width / 4; return ( {routes.map((route, index) => { if (!loaded.includes(route.key)) { // Don't render a screen if we've never navigated to it return null; } const focused = navigationState.index === index; const opacity = sceneAnimationEnabled ? tabs[index] : focused ? 1 : 0; const top = offsets[index].interpolate({ inputRange: [0, 1], outputRange: [0, FAR_FAR_AWAY], }); return ( {renderScene({ route, jumpTo: this.jumpTo, })} ); })} ); } } export default withTheme(BottomNavigation); const styles = StyleSheet.create({ container: { flex: 1, overflow: 'hidden', }, content: { flex: 1, }, bar: { left: 0, right: 0, bottom: 0, elevation: 4, }, barContent: { alignItems: 'center', overflow: 'hidden', }, items: { flexDirection: 'row', width: '100%', }, 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, }, ripple: { position: 'absolute', }, iconContainer: { height: 24, width: 24, marginTop: 2, marginHorizontal: 12, alignSelf: 'center', }, iconWrapper: { ...StyleSheet.absoluteFillObject, alignItems: 'center', }, labelContainer: { height: 16, paddingBottom: 2, }, labelWrapper: { ...StyleSheet.absoluteFillObject, }, // eslint-disable-next-line react-native/no-color-literals label: { fontSize: 12, textAlign: 'center', backgroundColor: 'transparent', ...(Platform.OS === 'web' ? { whiteSpace: 'nowrap', alignSelf: 'center', } : null), }, badgeContainer: { position: 'absolute', left: 0, top: -2, }, });