From 3951136ca88231e6006e4cca1c09da6329f85382 Mon Sep 17 00:00:00 2001 From: Bartek Dybowski Date: Wed, 17 Jun 2026 09:12:11 +0200 Subject: [PATCH 01/15] refactor(navigation-bar): extract module + deprecate Bar alias Move BottomNavigationBar into a new NavigationBar module and expose it as a top-level `NavigationBar` export. `BottomNavigation.Bar` is kept as a deprecated alias re-exporting the same component. No behavior change. Re #4975 --- .../BottomNavigation/BottomNavigation.tsx | 5 + .../BottomNavigation/BottomNavigationBar.tsx | 866 +----------------- .../NavigationBar/NavigationBar.tsx | 862 +++++++++++++++++ src/components/NavigationBar/index.tsx | 2 + src/index.tsx | 5 + 5 files changed, 880 insertions(+), 860 deletions(-) create mode 100644 src/components/NavigationBar/NavigationBar.tsx create mode 100644 src/components/NavigationBar/index.tsx diff --git a/src/components/BottomNavigation/BottomNavigation.tsx b/src/components/BottomNavigation/BottomNavigation.tsx index 1f18b17aae..bf4b09b3dc 100644 --- a/src/components/BottomNavigation/BottomNavigation.tsx +++ b/src/components/BottomNavigation/BottomNavigation.tsx @@ -612,6 +612,11 @@ BottomNavigation.SceneMap = (scenes: { ); }; +/** + * @deprecated Use the top-level `NavigationBar` export instead. + * `BottomNavigation.Bar` is the M3 "original" navigation bar, superseded by the + * flexible `NavigationBar`. Kept as an alias for backwards compatibility. + */ // @component ./BottomNavigationBar.tsx BottomNavigation.Bar = BottomNavigationBar; diff --git a/src/components/BottomNavigation/BottomNavigationBar.tsx b/src/components/BottomNavigation/BottomNavigationBar.tsx index 83998977b9..f1b16dc4f2 100644 --- a/src/components/BottomNavigation/BottomNavigationBar.tsx +++ b/src/components/BottomNavigation/BottomNavigationBar.tsx @@ -1,862 +1,8 @@ -import * as React from 'react'; -import { Animated, Platform, StyleSheet, Pressable, View } from 'react-native'; -import type { - ColorValue, - EasingFunction, - StyleProp, - ViewStyle, -} from 'react-native'; - -import { useSafeAreaInsets } from 'react-native-safe-area-context'; - -import { - getActiveTintColor, - getInactiveTintColor, - getLabelColor, -} from './utils'; -import { useInternalTheme } from '../../core/theming'; -import type { Theme, ThemeProp } from '../../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 Surface from '../Surface'; -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; - 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 - * - `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.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.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 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/). - * - * ## Usage - * ### without React Navigation - * ```js - * import React from 'react'; - * 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 ( - * - * Home! - * - * ); - * } - * - * function SettingsScreen() { - * return ( - * - * Settings! - * - * ); - * } - * - * export default function MyComponent() { - * const [index, setIndex] = useState(0); - * - * const routes = [ - * { key: 'home', title: 'Home', icon: 'home' }, - * { key: 'settings', title: 'Settings', icon: 'cog' }, - * ]; - - * const renderScene = ({ route }) => { - * switch (route.key) { - * case 'home': - * return ; - * case 'settings': - * return ; - * default: - * return null; - * } - * }; - * - * return ( - * - * {renderScene({ route: routes[index] })} - * { - * const newIndex = routes.findIndex((r) => r.key === route.key); - * if (newIndex !== -1) { - * setIndex(newIndex); - * } - * }} - * renderIcon={({ route, color }) => ( - * - * )} - * getLabelText={({ route }) => route.title} - * /> - * - * ); - * } - * ``` + * @deprecated Use `NavigationBar` instead. `BottomNavigation.Bar` is the M3 + * "original" navigation bar and has been superseded by the flexible + * `NavigationBar`. This module re-exports `NavigationBar` for backwards + * compatibility and will be removed in a future major version. */ -const BottomNavigationBar = ({ - navigationState, - renderIcon, - renderLabel, - renderTouchable = ({ key, ...props }: TouchableProps) => ( - - ), - getLabelText = ({ route }: { route: Route }) => route.title, - getBadge = ({ route }: { route: Route }) => route.badge, - getAccessibilityLabel = ({ route }: { route: Route }) => - route.accessibilityLabel, - getTestID = ({ route }: { route: Route }) => route.testID, - activeColor, - inactiveColor, - keyboardHidesNavigationBar = Platform.OS === 'android', - style, - activeIndicatorStyle, - labeled = true, - animationEasing, - onTabPress, - onTabLongPress, - shifting: shiftingProp, - safeAreaInsets, - labelMaxFontSizeMultiplier = 1, - compact: compactProp, - testID = 'bottom-navigation-bar', - theme: themeOverrides, -}: Props) => { - const theme = useInternalTheme(themeOverrides); - const { colors } = theme as Theme; - const { bottom, left, right } = useSafeAreaInsets(); - const { scale } = theme.animation; - const compact = compactProp ?? false; - let 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 [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(() => { - 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); - }); - }, [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 - }, []); - - useIsKeyboardShown({ - onShow: handleKeyboardShow, - onHide: handleKeyboardHide, - }); - - React.useEffect(() => { - animateToIndex(navigationState.index); - }, [navigationState.index, animateToIndex]); - - const eventForIndex = (index: number) => { - const event = { - route: navigationState.routes[index], - defaultPrevented: false, - preventDefault: () => { - event.defaultPrevented = true; - }, - }; - - return event; - }; - - const { routes } = navigationState; - - const { backgroundColor: customBackground } = (StyleSheet.flatten(style) || - {}) as { - elevation?: number; - backgroundColor?: ColorValue; - }; - - const backgroundColor = customBackground || colors.surfaceContainer; - - const activeTintColor = getActiveTintColor({ - activeColor, - theme, - }); - - const inactiveTintColor = getInactiveTintColor({ - inactiveColor, - theme, - }); - - const maxTabWidth = routes.length > 3 ? MIN_TAB_WIDTH : MAX_TAB_WIDTH; - const maxTabBarWidth = maxTabWidth * routes.length; - - const insets = { - left: safeAreaInsets?.left ?? left, - right: safeAreaInsets?.right ?? right, - bottom: safeAreaInsets?.bottom ?? bottom, - }; - - return ( - - ); -}; - -BottomNavigationBar.displayName = 'BottomNavigation.Bar'; - -export default BottomNavigationBar; - -const styles = StyleSheet.create({ - bar: { - left: 0, - right: 0, - bottom: 0, - }, - barContent: { - alignItems: 'center', - overflow: 'hidden', - }, - items: { - flexDirection: 'row', - ...(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, - }, - 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, - 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', - }, -}); +export { default } from '../NavigationBar/NavigationBar'; +export type { Props, BaseRoute } from '../NavigationBar/NavigationBar'; diff --git a/src/components/NavigationBar/NavigationBar.tsx b/src/components/NavigationBar/NavigationBar.tsx new file mode 100644 index 0000000000..decd29fc60 --- /dev/null +++ b/src/components/NavigationBar/NavigationBar.tsx @@ -0,0 +1,862 @@ +import * as React from 'react'; +import { Animated, Platform, StyleSheet, Pressable, View } from 'react-native'; +import type { + ColorValue, + EasingFunction, + StyleProp, + ViewStyle, +} from 'react-native'; + +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { useInternalTheme } from '../../core/theming'; +import type { Theme, ThemeProp } from '../../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 { + getActiveTintColor, + getInactiveTintColor, + getLabelColor, +} from '../BottomNavigation/utils'; +import Icon from '../Icon'; +import type { IconSource } from '../Icon'; +import Surface from '../Surface'; +import TouchableRipple from '../TouchableRipple/TouchableRipple'; +import type { Props as TouchableRippleProps } from '../TouchableRipple/TouchableRipple'; +import Text from '../Typography/Text'; + +export type BaseRoute = { + key: string; + title?: string; + focusedIcon?: IconSource; + unfocusedIcon?: IconSource; + badge?: string | number | boolean; + 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 + * - `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.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.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 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/). + * + * ## Usage + * ### without React Navigation + * ```js + * import React from 'react'; + * 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 ( + * + * Home! + * + * ); + * } + * + * function SettingsScreen() { + * return ( + * + * Settings! + * + * ); + * } + * + * export default function MyComponent() { + * const [index, setIndex] = useState(0); + * + * const routes = [ + * { key: 'home', title: 'Home', icon: 'home' }, + * { key: 'settings', title: 'Settings', icon: 'cog' }, + * ]; + + * const renderScene = ({ route }) => { + * switch (route.key) { + * case 'home': + * return ; + * case 'settings': + * return ; + * default: + * return null; + * } + * }; + * + * return ( + * + * {renderScene({ route: routes[index] })} + * { + * const newIndex = routes.findIndex((r) => r.key === route.key); + * if (newIndex !== -1) { + * setIndex(newIndex); + * } + * }} + * renderIcon={({ route, color }) => ( + * + * )} + * getLabelText={({ route }) => route.title} + * /> + * + * ); + * } + * ``` + */ +const NavigationBar = ({ + navigationState, + renderIcon, + renderLabel, + renderTouchable = ({ key, ...props }: TouchableProps) => ( + + ), + getLabelText = ({ route }: { route: Route }) => route.title, + getBadge = ({ route }: { route: Route }) => route.badge, + getAccessibilityLabel = ({ route }: { route: Route }) => + route.accessibilityLabel, + getTestID = ({ route }: { route: Route }) => route.testID, + activeColor, + inactiveColor, + keyboardHidesNavigationBar = Platform.OS === 'android', + style, + activeIndicatorStyle, + labeled = true, + animationEasing, + onTabPress, + onTabLongPress, + shifting: shiftingProp, + safeAreaInsets, + labelMaxFontSizeMultiplier = 1, + compact: compactProp, + testID = 'bottom-navigation-bar', + theme: themeOverrides, +}: Props) => { + const theme = useInternalTheme(themeOverrides); + const { colors } = theme as Theme; + const { bottom, left, right } = useSafeAreaInsets(); + const { scale } = theme.animation; + const compact = compactProp ?? false; + let 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 [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(() => { + 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); + }); + }, [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 + }, []); + + useIsKeyboardShown({ + onShow: handleKeyboardShow, + onHide: handleKeyboardHide, + }); + + React.useEffect(() => { + animateToIndex(navigationState.index); + }, [navigationState.index, animateToIndex]); + + const eventForIndex = (index: number) => { + const event = { + route: navigationState.routes[index], + defaultPrevented: false, + preventDefault: () => { + event.defaultPrevented = true; + }, + }; + + return event; + }; + + const { routes } = navigationState; + + const { backgroundColor: customBackground } = (StyleSheet.flatten(style) || + {}) as { + elevation?: number; + backgroundColor?: ColorValue; + }; + + const backgroundColor = customBackground || colors.surfaceContainer; + + const activeTintColor = getActiveTintColor({ + activeColor, + theme, + }); + + const inactiveTintColor = getInactiveTintColor({ + inactiveColor, + theme, + }); + + const maxTabWidth = routes.length > 3 ? MIN_TAB_WIDTH : MAX_TAB_WIDTH; + const maxTabBarWidth = maxTabWidth * routes.length; + + const insets = { + left: safeAreaInsets?.left ?? left, + right: safeAreaInsets?.right ?? right, + bottom: safeAreaInsets?.bottom ?? bottom, + }; + + return ( + + ); +}; + +NavigationBar.displayName = 'NavigationBar'; + +export default NavigationBar; + +const styles = StyleSheet.create({ + bar: { + left: 0, + right: 0, + bottom: 0, + }, + barContent: { + alignItems: 'center', + overflow: 'hidden', + }, + items: { + flexDirection: 'row', + ...(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, + }, + 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, + 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', + }, +}); diff --git a/src/components/NavigationBar/index.tsx b/src/components/NavigationBar/index.tsx new file mode 100644 index 0000000000..3567b88e01 --- /dev/null +++ b/src/components/NavigationBar/index.tsx @@ -0,0 +1,2 @@ +export { default } from './NavigationBar'; +export type { Props, BaseRoute } from './NavigationBar'; diff --git a/src/index.tsx b/src/index.tsx index 8863e2fa20..3bc5390de0 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -37,6 +37,7 @@ export { default as Icon } from './components/Icon'; export { default as IconButton } from './components/IconButton/IconButton'; export { default as Menu } from './components/Menu/Menu'; export { default as Modal } from './components/Modal'; +export { default as NavigationBar } from './components/NavigationBar'; export { default as Portal } from './components/Portal/Portal'; export { default as ProgressBar } from './components/ProgressBar'; export { default as RadioButton } from './components/RadioButton'; @@ -115,6 +116,10 @@ export type { Props as ListSubheaderProps } from './components/List/ListSubheade export type { Props as MenuProps } from './components/Menu/Menu'; export type { Props as MenuItemProps } from './components/Menu/MenuItem'; export type { Props as ModalProps } from './components/Modal'; +export type { + Props as NavigationBarProps, + BaseRoute as NavigationBarRoute, +} from './components/NavigationBar'; export type { Props as PortalProps } from './components/Portal/Portal'; export type { Props as PortalHostProps } from './components/Portal/PortalHost'; export type { Props as ProgressBarProps } from './components/ProgressBar'; From b07f21a905f179dcdc8b7a795eac18a803fe6dcf Mon Sep 17 00:00:00 2001 From: Bartek Dybowski Date: Wed, 17 Jun 2026 10:12:31 +0200 Subject: [PATCH 02/15] feat(navigation-bar): apply MD3 token-based colors and sizing Extract NavigationBar/tokens.ts with resolved M3 spec values. Fix bar height (56->64), no-label container height (80->64) and active label color (onSurface->secondary, per M3 / Compose Material3 1.4.0). Re #4975 --- src/components/BottomNavigation/utils.ts | 3 +- .../NavigationBar/NavigationBar.tsx | 35 +-- src/components/NavigationBar/tokens.ts | 39 +++ .../__tests__/BottomNavigation.test.tsx | 2 +- .../BottomNavigation.test.tsx.snap | 243 +++++++++--------- 5 files changed, 181 insertions(+), 141 deletions(-) create mode 100644 src/components/NavigationBar/tokens.ts diff --git a/src/components/BottomNavigation/utils.ts b/src/components/BottomNavigation/utils.ts index ede88105e4..60f0545d69 100644 --- a/src/components/BottomNavigation/utils.ts +++ b/src/components/BottomNavigation/utils.ts @@ -47,7 +47,8 @@ export const getLabelColor = ({ } if (focused) { - return colors.onSurface; + // M3 active label color is `secondary` (changed from `onSurface`). + return colors.secondary; } return colors.onSurfaceVariant; }; diff --git a/src/components/NavigationBar/NavigationBar.tsx b/src/components/NavigationBar/NavigationBar.tsx index decd29fc60..3d138fd687 100644 --- a/src/components/NavigationBar/NavigationBar.tsx +++ b/src/components/NavigationBar/NavigationBar.tsx @@ -9,6 +9,16 @@ import type { import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { + BAR_HEIGHT, + ICON_LABEL_GAP, + INDICATOR_BORDER_RADIUS, + INDICATOR_HEIGHT, + INDICATOR_WIDTH, + MAX_TAB_WIDTH, + MIN_TAB_WIDTH, + NO_LABEL_BAR_HEIGHT, +} from './tokens'; import { useInternalTheme } from '../../core/theming'; import type { Theme, ThemeProp } from '../../types'; import useAnimatedValue from '../../utils/useAnimatedValue'; @@ -197,11 +207,6 @@ export type Props = { 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, @@ -807,9 +812,9 @@ const styles = StyleSheet.create({ alignSelf: 'center', }, v3IconContainer: { - height: 32, - width: 32, - marginBottom: 4, + height: INDICATOR_HEIGHT, + width: INDICATOR_HEIGHT, + marginBottom: ICON_LABEL_GAP, marginTop: 0, justifyContent: 'center', }, @@ -830,7 +835,6 @@ const styles = StyleSheet.create({ // eslint-disable-next-line react-native/no-color-literals label: { fontSize: 12, - height: BAR_HEIGHT, textAlign: 'center', backgroundColor: 'transparent', ...(Platform.OS === 'web' @@ -845,18 +849,19 @@ const styles = StyleSheet.create({ left: 0, }, v3TouchableContainer: { - paddingTop: 12, - paddingBottom: 16, + height: BAR_HEIGHT, + justifyContent: 'center', + alignItems: 'center', }, v3NoLabelContainer: { - height: 80, + height: NO_LABEL_BAR_HEIGHT, justifyContent: 'center', alignItems: 'center', }, outline: { - width: OUTLINE_WIDTH, - height: OUTLINE_WIDTH / 2, - borderRadius: OUTLINE_WIDTH / 4, + width: INDICATOR_WIDTH, + height: INDICATOR_HEIGHT, + borderRadius: INDICATOR_BORDER_RADIUS, alignSelf: 'center', }, }); diff --git a/src/components/NavigationBar/tokens.ts b/src/components/NavigationBar/tokens.ts new file mode 100644 index 0000000000..cdcca922b3 --- /dev/null +++ b/src/components/NavigationBar/tokens.ts @@ -0,0 +1,39 @@ +import type { ColorRole, TypescaleKey } from '../../theme/types'; + +/** + * Resolved M3 "flexible navigation bar" spec values. + * @see https://m3.material.io/components/navigation-bar/specs + */ + +// Container heights (dp). The flexible navigation bar is shorter than the +// original navigation bar (which was 80dp). +export const BAR_HEIGHT = 64; +export const NO_LABEL_BAR_HEIGHT = 64; + +// Active indicator / state-layer pill (dp). Matches the M3 `large` corner (16). +export const INDICATOR_WIDTH = 64; +export const INDICATOR_HEIGHT = 32; +export const INDICATOR_BORDER_RADIUS = 16; + +// Icon + spacing (dp). +export const ICON_SIZE = 24; +export const ICON_LABEL_GAP = 4; + +// Tab width clamps used by the `compact` layout (dp). +export const MIN_TAB_WIDTH = 96; +export const MAX_TAB_WIDTH = 168; + +export const LABEL_TYPESCALE: TypescaleKey = 'labelMedium'; + +/** + * Color roles per the M3 spec. `activeLabel` is `secondary` (changed from + * `onSurface` in Material 3 / Compose Material3 1.4.0). + */ +export const colorRoles = { + container: 'surfaceContainer', + activeIcon: 'onSecondaryContainer', + activeIndicator: 'secondaryContainer', + activeLabel: 'secondary', + inactiveIcon: 'onSurfaceVariant', + inactiveLabel: 'onSurfaceVariant', +} as const satisfies Record; diff --git a/src/components/__tests__/BottomNavigation.test.tsx b/src/components/__tests__/BottomNavigation.test.tsx index 4effa0b9c0..1843b52dfa 100644 --- a/src/components/__tests__/BottomNavigation.test.tsx +++ b/src/components/__tests__/BottomNavigation.test.tsx @@ -516,7 +516,7 @@ describe('getLabelColor', () => { 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: false, diff --git a/src/components/__tests__/__snapshots__/BottomNavigation.test.tsx.snap b/src/components/__tests__/__snapshots__/BottomNavigation.test.tsx.snap index 7b5d8ad45e..14d4d82930 100644 --- a/src/components/__tests__/__snapshots__/BottomNavigation.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/BottomNavigation.test.tsx.snap @@ -182,8 +182,9 @@ exports[`allows customizing Route's type via generics 1`] = ` pointerEvents="none" style={ { - "paddingBottom": 16, - "paddingTop": 12, + "alignItems": "center", + "height": 64, + "justifyContent": "center", } } > @@ -328,11 +329,10 @@ exports[`allows customizing Route's type via generics 1`] = ` { "backgroundColor": "transparent", "fontSize": 12, - "height": 56, "textAlign": "center", }, { - "color": "rgba(29, 27, 32, 1)", + "color": "rgba(98, 91, 113, 1)", "fontFamily": "System", "fontSize": 12, "fontWeight": "500", @@ -384,11 +384,10 @@ exports[`allows customizing Route's type via generics 1`] = ` { "backgroundColor": "transparent", "fontSize": 12, - "height": 56, "textAlign": "center", }, { - "color": "rgba(29, 27, 32, 1)", + "color": "rgba(98, 91, 113, 1)", "fontFamily": "System", "fontSize": 12, "fontWeight": "500", @@ -453,8 +452,9 @@ exports[`allows customizing Route's type via generics 1`] = ` pointerEvents="none" style={ { - "paddingBottom": 16, - "paddingTop": 12, + "alignItems": "center", + "height": 64, + "justifyContent": "center", } } > @@ -582,7 +582,6 @@ exports[`allows customizing Route's type via generics 1`] = ` { "backgroundColor": "transparent", "fontSize": 12, - "height": 56, "textAlign": "center", }, { @@ -638,7 +637,6 @@ exports[`allows customizing Route's type via generics 1`] = ` { "backgroundColor": "transparent", "fontSize": 12, - "height": 56, "textAlign": "center", }, { @@ -850,7 +848,7 @@ exports[`hides labels in non-shifting bottom navigation 1`] = ` style={ { "alignItems": "center", - "height": 80, + "height": 64, "justifyContent": "center", } } @@ -1061,7 +1059,7 @@ exports[`hides labels in non-shifting bottom navigation 1`] = ` style={ { "alignItems": "center", - "height": 80, + "height": 64, "justifyContent": "center", } } @@ -1255,7 +1253,7 @@ exports[`hides labels in non-shifting bottom navigation 1`] = ` style={ { "alignItems": "center", - "height": 80, + "height": 64, "justifyContent": "center", } } @@ -1591,7 +1589,7 @@ exports[`hides labels in shifting bottom navigation 1`] = ` style={ { "alignItems": "center", - "height": 80, + "height": 64, "justifyContent": "center", } } @@ -1802,7 +1800,7 @@ exports[`hides labels in shifting bottom navigation 1`] = ` style={ { "alignItems": "center", - "height": 80, + "height": 64, "justifyContent": "center", } } @@ -1996,7 +1994,7 @@ exports[`hides labels in shifting bottom navigation 1`] = ` style={ { "alignItems": "center", - "height": 80, + "height": 64, "justifyContent": "center", } } @@ -2463,8 +2461,9 @@ exports[`renders bottom navigation with getLazy 1`] = ` pointerEvents="none" style={ { - "paddingBottom": 16, - "paddingTop": 12, + "alignItems": "center", + "height": 64, + "justifyContent": "center", } } > @@ -2669,11 +2668,10 @@ exports[`renders bottom navigation with getLazy 1`] = ` { "backgroundColor": "transparent", "fontSize": 12, - "height": 56, "textAlign": "center", }, { - "color": "rgba(29, 27, 32, 1)", + "color": "rgba(98, 91, 113, 1)", "fontFamily": "System", "fontSize": 12, "fontWeight": "500", @@ -2725,11 +2723,10 @@ exports[`renders bottom navigation with getLazy 1`] = ` { "backgroundColor": "transparent", "fontSize": 12, - "height": 56, "textAlign": "center", }, { - "color": "rgba(29, 27, 32, 1)", + "color": "rgba(98, 91, 113, 1)", "fontFamily": "System", "fontSize": 12, "fontWeight": "500", @@ -2794,8 +2791,9 @@ exports[`renders bottom navigation with getLazy 1`] = ` pointerEvents="none" style={ { - "paddingBottom": 16, - "paddingTop": 12, + "alignItems": "center", + "height": 64, + "justifyContent": "center", } } > @@ -2983,7 +2981,6 @@ exports[`renders bottom navigation with getLazy 1`] = ` { "backgroundColor": "transparent", "fontSize": 12, - "height": 56, "textAlign": "center", }, { @@ -3039,7 +3036,6 @@ exports[`renders bottom navigation with getLazy 1`] = ` { "backgroundColor": "transparent", "fontSize": 12, - "height": 56, "textAlign": "center", }, { @@ -3108,8 +3104,9 @@ exports[`renders bottom navigation with getLazy 1`] = ` pointerEvents="none" style={ { - "paddingBottom": 16, - "paddingTop": 12, + "alignItems": "center", + "height": 64, + "justifyContent": "center", } } > @@ -3297,7 +3294,6 @@ exports[`renders bottom navigation with getLazy 1`] = ` { "backgroundColor": "transparent", "fontSize": 12, - "height": 56, "textAlign": "center", }, { @@ -3353,7 +3349,6 @@ exports[`renders bottom navigation with getLazy 1`] = ` { "backgroundColor": "transparent", "fontSize": 12, - "height": 56, "textAlign": "center", }, { @@ -3422,8 +3417,9 @@ exports[`renders bottom navigation with getLazy 1`] = ` pointerEvents="none" style={ { - "paddingBottom": 16, - "paddingTop": 12, + "alignItems": "center", + "height": 64, + "justifyContent": "center", } } > @@ -3611,7 +3607,6 @@ exports[`renders bottom navigation with getLazy 1`] = ` { "backgroundColor": "transparent", "fontSize": 12, - "height": 56, "textAlign": "center", }, { @@ -3667,7 +3662,6 @@ exports[`renders bottom navigation with getLazy 1`] = ` { "backgroundColor": "transparent", "fontSize": 12, - "height": 56, "textAlign": "center", }, { @@ -3736,8 +3730,9 @@ exports[`renders bottom navigation with getLazy 1`] = ` pointerEvents="none" style={ { - "paddingBottom": 16, - "paddingTop": 12, + "alignItems": "center", + "height": 64, + "justifyContent": "center", } } > @@ -3925,7 +3920,6 @@ exports[`renders bottom navigation with getLazy 1`] = ` { "backgroundColor": "transparent", "fontSize": 12, - "height": 56, "textAlign": "center", }, { @@ -3981,7 +3975,6 @@ exports[`renders bottom navigation with getLazy 1`] = ` { "backgroundColor": "transparent", "fontSize": 12, - "height": 56, "textAlign": "center", }, { @@ -4192,8 +4185,9 @@ exports[`renders bottom navigation with scene animation 1`] = ` pointerEvents="none" style={ { - "paddingBottom": 16, - "paddingTop": 12, + "alignItems": "center", + "height": 64, + "justifyContent": "center", } } > @@ -4403,11 +4397,10 @@ exports[`renders bottom navigation with scene animation 1`] = ` { "backgroundColor": "transparent", "fontSize": 12, - "height": 56, "textAlign": "center", }, { - "color": "rgba(29, 27, 32, 1)", + "color": "rgba(98, 91, 113, 1)", "fontFamily": "System", "fontSize": 12, "fontWeight": "500", @@ -4472,8 +4465,9 @@ exports[`renders bottom navigation with scene animation 1`] = ` pointerEvents="none" style={ { - "paddingBottom": 16, - "paddingTop": 12, + "alignItems": "center", + "height": 64, + "justifyContent": "center", } } > @@ -4666,7 +4660,6 @@ exports[`renders bottom navigation with scene animation 1`] = ` { "backgroundColor": "transparent", "fontSize": 12, - "height": 56, "textAlign": "center", }, { @@ -4735,8 +4728,9 @@ exports[`renders bottom navigation with scene animation 1`] = ` pointerEvents="none" style={ { - "paddingBottom": 16, - "paddingTop": 12, + "alignItems": "center", + "height": 64, + "justifyContent": "center", } } > @@ -4929,7 +4923,6 @@ exports[`renders bottom navigation with scene animation 1`] = ` { "backgroundColor": "transparent", "fontSize": 12, - "height": 56, "textAlign": "center", }, { @@ -4998,8 +4991,9 @@ exports[`renders bottom navigation with scene animation 1`] = ` pointerEvents="none" style={ { - "paddingBottom": 16, - "paddingTop": 12, + "alignItems": "center", + "height": 64, + "justifyContent": "center", } } > @@ -5192,7 +5186,6 @@ exports[`renders bottom navigation with scene animation 1`] = ` { "backgroundColor": "transparent", "fontSize": 12, - "height": 56, "textAlign": "center", }, { @@ -5261,8 +5254,9 @@ exports[`renders bottom navigation with scene animation 1`] = ` pointerEvents="none" style={ { - "paddingBottom": 16, - "paddingTop": 12, + "alignItems": "center", + "height": 64, + "justifyContent": "center", } } > @@ -5455,7 +5449,6 @@ exports[`renders bottom navigation with scene animation 1`] = ` { "backgroundColor": "transparent", "fontSize": 12, - "height": 56, "textAlign": "center", }, { @@ -5666,8 +5659,9 @@ exports[`renders custom icon and label in non-shifting bottom navigation 1`] = ` pointerEvents="none" style={ { - "paddingBottom": 16, - "paddingTop": 12, + "alignItems": "center", + "height": 64, + "justifyContent": "center", } } > @@ -5790,7 +5784,7 @@ exports[`renders custom icon and label in non-shifting bottom navigation 1`] = ` } > Route: 0 @@ -5809,7 +5803,7 @@ exports[`renders custom icon and label in non-shifting bottom navigation 1`] = ` } > Route: 0 @@ -5864,8 +5858,9 @@ exports[`renders custom icon and label in non-shifting bottom navigation 1`] = ` pointerEvents="none" style={ { - "paddingBottom": 16, - "paddingTop": 12, + "alignItems": "center", + "height": 64, + "justifyContent": "center", } } > @@ -6045,8 +6040,9 @@ exports[`renders custom icon and label in non-shifting bottom navigation 1`] = ` pointerEvents="none" style={ { - "paddingBottom": 16, - "paddingTop": 12, + "alignItems": "center", + "height": 64, + "justifyContent": "center", } } > @@ -6368,8 +6364,9 @@ exports[`renders custom icon and label in shifting bottom navigation 1`] = ` pointerEvents="none" style={ { - "paddingBottom": 16, - "paddingTop": 12, + "alignItems": "center", + "height": 64, + "justifyContent": "center", } } > @@ -6497,7 +6494,7 @@ exports[`renders custom icon and label in shifting bottom navigation 1`] = ` } > Route: 0 @@ -6552,8 +6549,9 @@ exports[`renders custom icon and label in shifting bottom navigation 1`] = ` pointerEvents="none" style={ { - "paddingBottom": 16, - "paddingTop": 12, + "alignItems": "center", + "height": 64, + "justifyContent": "center", } } > @@ -6719,8 +6717,9 @@ exports[`renders custom icon and label in shifting bottom navigation 1`] = ` pointerEvents="none" style={ { - "paddingBottom": 16, - "paddingTop": 12, + "alignItems": "center", + "height": 64, + "justifyContent": "center", } } > @@ -6886,8 +6885,9 @@ exports[`renders custom icon and label in shifting bottom navigation 1`] = ` pointerEvents="none" style={ { - "paddingBottom": 16, - "paddingTop": 12, + "alignItems": "center", + "height": 64, + "justifyContent": "center", } } > @@ -7053,8 +7053,9 @@ exports[`renders custom icon and label in shifting bottom navigation 1`] = ` pointerEvents="none" style={ { - "paddingBottom": 16, - "paddingTop": 12, + "alignItems": "center", + "height": 64, + "justifyContent": "center", } } > @@ -7362,8 +7363,9 @@ exports[`renders custom icon and label with custom colors in non-shifting bottom pointerEvents="none" style={ { - "paddingBottom": 16, - "paddingTop": 12, + "alignItems": "center", + "height": 64, + "justifyContent": "center", } } > @@ -7568,7 +7570,6 @@ exports[`renders custom icon and label with custom colors in non-shifting bottom { "backgroundColor": "transparent", "fontSize": 12, - "height": 56, "textAlign": "center", }, { @@ -7624,7 +7625,6 @@ exports[`renders custom icon and label with custom colors in non-shifting bottom { "backgroundColor": "transparent", "fontSize": 12, - "height": 56, "textAlign": "center", }, { @@ -7693,8 +7693,9 @@ exports[`renders custom icon and label with custom colors in non-shifting bottom pointerEvents="none" style={ { - "paddingBottom": 16, - "paddingTop": 12, + "alignItems": "center", + "height": 64, + "justifyContent": "center", } } > @@ -7882,7 +7883,6 @@ exports[`renders custom icon and label with custom colors in non-shifting bottom { "backgroundColor": "transparent", "fontSize": 12, - "height": 56, "textAlign": "center", }, { @@ -7938,7 +7938,6 @@ exports[`renders custom icon and label with custom colors in non-shifting bottom { "backgroundColor": "transparent", "fontSize": 12, - "height": 56, "textAlign": "center", }, { @@ -8007,8 +8006,9 @@ exports[`renders custom icon and label with custom colors in non-shifting bottom pointerEvents="none" style={ { - "paddingBottom": 16, - "paddingTop": 12, + "alignItems": "center", + "height": 64, + "justifyContent": "center", } } > @@ -8196,7 +8196,6 @@ exports[`renders custom icon and label with custom colors in non-shifting bottom { "backgroundColor": "transparent", "fontSize": 12, - "height": 56, "textAlign": "center", }, { @@ -8252,7 +8251,6 @@ exports[`renders custom icon and label with custom colors in non-shifting bottom { "backgroundColor": "transparent", "fontSize": 12, - "height": 56, "textAlign": "center", }, { @@ -8463,8 +8461,9 @@ exports[`renders custom icon and label with custom colors in shifting bottom nav pointerEvents="none" style={ { - "paddingBottom": 16, - "paddingTop": 12, + "alignItems": "center", + "height": 64, + "justifyContent": "center", } } > @@ -8674,7 +8673,6 @@ exports[`renders custom icon and label with custom colors in shifting bottom nav { "backgroundColor": "transparent", "fontSize": 12, - "height": 56, "textAlign": "center", }, { @@ -8743,8 +8741,9 @@ exports[`renders custom icon and label with custom colors in shifting bottom nav pointerEvents="none" style={ { - "paddingBottom": 16, - "paddingTop": 12, + "alignItems": "center", + "height": 64, + "justifyContent": "center", } } > @@ -8937,7 +8936,6 @@ exports[`renders custom icon and label with custom colors in shifting bottom nav { "backgroundColor": "transparent", "fontSize": 12, - "height": 56, "textAlign": "center", }, { @@ -9006,8 +9004,9 @@ exports[`renders custom icon and label with custom colors in shifting bottom nav pointerEvents="none" style={ { - "paddingBottom": 16, - "paddingTop": 12, + "alignItems": "center", + "height": 64, + "justifyContent": "center", } } > @@ -9200,7 +9199,6 @@ exports[`renders custom icon and label with custom colors in shifting bottom nav { "backgroundColor": "transparent", "fontSize": 12, - "height": 56, "textAlign": "center", }, { @@ -9411,8 +9409,9 @@ exports[`renders non-shifting bottom navigation 1`] = ` pointerEvents="none" style={ { - "paddingBottom": 16, - "paddingTop": 12, + "alignItems": "center", + "height": 64, + "justifyContent": "center", } } > @@ -9617,11 +9616,10 @@ exports[`renders non-shifting bottom navigation 1`] = ` { "backgroundColor": "transparent", "fontSize": 12, - "height": 56, "textAlign": "center", }, { - "color": "rgba(29, 27, 32, 1)", + "color": "rgba(98, 91, 113, 1)", "fontFamily": "System", "fontSize": 12, "fontWeight": "500", @@ -9673,11 +9671,10 @@ exports[`renders non-shifting bottom navigation 1`] = ` { "backgroundColor": "transparent", "fontSize": 12, - "height": 56, "textAlign": "center", }, { - "color": "rgba(29, 27, 32, 1)", + "color": "rgba(98, 91, 113, 1)", "fontFamily": "System", "fontSize": 12, "fontWeight": "500", @@ -9742,8 +9739,9 @@ exports[`renders non-shifting bottom navigation 1`] = ` pointerEvents="none" style={ { - "paddingBottom": 16, - "paddingTop": 12, + "alignItems": "center", + "height": 64, + "justifyContent": "center", } } > @@ -9931,7 +9929,6 @@ exports[`renders non-shifting bottom navigation 1`] = ` { "backgroundColor": "transparent", "fontSize": 12, - "height": 56, "textAlign": "center", }, { @@ -9987,7 +9984,6 @@ exports[`renders non-shifting bottom navigation 1`] = ` { "backgroundColor": "transparent", "fontSize": 12, - "height": 56, "textAlign": "center", }, { @@ -10056,8 +10052,9 @@ exports[`renders non-shifting bottom navigation 1`] = ` pointerEvents="none" style={ { - "paddingBottom": 16, - "paddingTop": 12, + "alignItems": "center", + "height": 64, + "justifyContent": "center", } } > @@ -10245,7 +10242,6 @@ exports[`renders non-shifting bottom navigation 1`] = ` { "backgroundColor": "transparent", "fontSize": 12, - "height": 56, "textAlign": "center", }, { @@ -10301,7 +10297,6 @@ exports[`renders non-shifting bottom navigation 1`] = ` { "backgroundColor": "transparent", "fontSize": 12, - "height": 56, "textAlign": "center", }, { @@ -10512,8 +10507,9 @@ exports[`renders shifting bottom navigation 1`] = ` pointerEvents="none" style={ { - "paddingBottom": 16, - "paddingTop": 12, + "alignItems": "center", + "height": 64, + "justifyContent": "center", } } > @@ -10723,11 +10719,10 @@ exports[`renders shifting bottom navigation 1`] = ` { "backgroundColor": "transparent", "fontSize": 12, - "height": 56, "textAlign": "center", }, { - "color": "rgba(29, 27, 32, 1)", + "color": "rgba(98, 91, 113, 1)", "fontFamily": "System", "fontSize": 12, "fontWeight": "500", @@ -10792,8 +10787,9 @@ exports[`renders shifting bottom navigation 1`] = ` pointerEvents="none" style={ { - "paddingBottom": 16, - "paddingTop": 12, + "alignItems": "center", + "height": 64, + "justifyContent": "center", } } > @@ -10986,7 +10982,6 @@ exports[`renders shifting bottom navigation 1`] = ` { "backgroundColor": "transparent", "fontSize": 12, - "height": 56, "textAlign": "center", }, { @@ -11055,8 +11050,9 @@ exports[`renders shifting bottom navigation 1`] = ` pointerEvents="none" style={ { - "paddingBottom": 16, - "paddingTop": 12, + "alignItems": "center", + "height": 64, + "justifyContent": "center", } } > @@ -11249,7 +11245,6 @@ exports[`renders shifting bottom navigation 1`] = ` { "backgroundColor": "transparent", "fontSize": 12, - "height": 56, "textAlign": "center", }, { @@ -11318,8 +11313,9 @@ exports[`renders shifting bottom navigation 1`] = ` pointerEvents="none" style={ { - "paddingBottom": 16, - "paddingTop": 12, + "alignItems": "center", + "height": 64, + "justifyContent": "center", } } > @@ -11512,7 +11508,6 @@ exports[`renders shifting bottom navigation 1`] = ` { "backgroundColor": "transparent", "fontSize": 12, - "height": 56, "textAlign": "center", }, { @@ -11581,8 +11576,9 @@ exports[`renders shifting bottom navigation 1`] = ` pointerEvents="none" style={ { - "paddingBottom": 16, - "paddingTop": 12, + "alignItems": "center", + "height": 64, + "justifyContent": "center", } } > @@ -11775,7 +11771,6 @@ exports[`renders shifting bottom navigation 1`] = ` { "backgroundColor": "transparent", "fontSize": 12, - "height": 56, "textAlign": "center", }, { From 44eeaf8ca0fca6f86867d29596f4c5f62720a145 Mon Sep 17 00:00:00 2001 From: Bartek Dybowski Date: Wed, 17 Jun 2026 10:37:17 +0200 Subject: [PATCH 03/15] refactor(navigation-bar): remove MD2 shifting mode from the bar Remove the `shifting` prop and all its branches from NavigationBar; it is a Material Design 2 pattern with no MD3 equivalent. The wrapper no longer forwards it and keeps `shifting` as a deprecated no-op prop. Scene transition animation (sceneAnimationType) is unaffected. Re #4975 --- .../BottomNavigation/BottomNavigation.tsx | 19 +- .../NavigationBar/NavigationBar.tsx | 135 +-- .../__tests__/BottomNavigation.test.tsx | 23 +- .../BottomNavigation.test.tsx.snap | 974 +++++++++++++++--- 4 files changed, 904 insertions(+), 247 deletions(-) diff --git a/src/components/BottomNavigation/BottomNavigation.tsx b/src/components/BottomNavigation/BottomNavigation.tsx index bf4b09b3dc..f68705f604 100644 --- a/src/components/BottomNavigation/BottomNavigation.tsx +++ b/src/components/BottomNavigation/BottomNavigation.tsx @@ -49,11 +49,10 @@ type TouchableProps = TouchableRippleProps & { 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. + * @deprecated The `shifting` style is a Material Design 2 pattern that is not + * part of Material Design 3 and no longer has any effect. It will be removed + * in a future version. To animate scene transitions, use `sceneAnimationType` + * and `sceneAnimationEnabled` instead. */ shifting?: boolean; /** @@ -330,7 +329,6 @@ const BottomNavigation = ({ onTabPress, onTabLongPress, onIndexChange, - shifting: shiftingProp, safeAreaInsets, labelMaxFontSizeMultiplier = 1, compact: compactProp, @@ -341,14 +339,6 @@ const BottomNavigation = ({ 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 focusedKey = navigationState.routes[navigationState.index].key; @@ -574,7 +564,6 @@ const BottomNavigation = ({ animationEasing={sceneAnimationEasing} onTabPress={handleTabPress} onTabLongPress={onTabLongPress} - shifting={shifting} safeAreaInsets={safeAreaInsets} labelMaxFontSizeMultiplier={labelMaxFontSizeMultiplier} compact={compact} diff --git a/src/components/NavigationBar/NavigationBar.tsx b/src/components/NavigationBar/NavigationBar.tsx index 3d138fd687..3eacd7bfae 100644 --- a/src/components/NavigationBar/NavigationBar.tsx +++ b/src/components/NavigationBar/NavigationBar.tsx @@ -69,14 +69,6 @@ type TouchableProps = TouchableRippleProps & { }; 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. */ @@ -322,7 +314,6 @@ const NavigationBar = ({ animationEasing, onTabPress, onTabLongPress, - shifting: shiftingProp, safeAreaInsets, labelMaxFontSizeMultiplier = 1, compact: compactProp, @@ -334,14 +325,6 @@ const NavigationBar = ({ const { bottom, left, right } = useSafeAreaInsets(); const { scale } = theme.animation; const compact = compactProp ?? false; - let 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. @@ -519,31 +502,11 @@ const NavigationBar = ({ const focused = navigationState.index === index; const active = tabsAnims[index]; - // Move down the icon to account for no-label in shifting and smaller label in non-shifting. - const translateY = labeled - ? shifting - ? active.interpolate({ - inputRange: [0, 1], - outputRange: [7, 0], - }) - : 0 - : 7; - - // We render the active icon and label on top of inactive ones and cross-fade them on change. - // This trick gives the illusion that we are animating between active and inactive colors. - // This is to ensure that we can use native driver, as colors cannot be animated with native driver. - const activeOpacity = active; - const inactiveOpacity = active.interpolate({ - inputRange: [0, 1], - outputRange: [1, 0], - }); - - const v3ActiveOpacity = focused ? 1 : 0; - const v3InactiveOpacity = shifting - ? inactiveOpacity - : focused - ? 0 - : 1; + // We render the active icon and label on top of the inactive ones + // and toggle their opacity on change, so the active/inactive colors + // swap without animating color (which the native driver can't do). + const activeOpacity = focused ? 1 : 0; + const inactiveOpacity = focused ? 0 : 1; // Scale horizontally the outline pill const outlineScale = focused @@ -577,8 +540,6 @@ const NavigationBar = ({ : 0, }; - const isLegacyOrV3Shifting = shifting && labeled; - const font = (theme as Theme).fonts.labelMedium; return renderTouchable({ @@ -604,13 +565,7 @@ const NavigationBar = ({ } > {focused && ( ({ styles.iconWrapper, styles.v3IconWrapper, { - opacity: isLegacyOrV3Shifting - ? activeOpacity - : v3ActiveOpacity, + opacity: activeOpacity, }, ]} > @@ -658,9 +611,7 @@ const NavigationBar = ({ styles.iconWrapper, styles.v3IconWrapper, { - opacity: isLegacyOrV3Shifting - ? inactiveOpacity - : v3InactiveOpacity, + opacity: inactiveOpacity, }, ]} > @@ -698,9 +649,7 @@ const NavigationBar = ({ style={[ styles.labelWrapper, { - opacity: isLegacyOrV3Shifting - ? activeOpacity - : v3ActiveOpacity, + opacity: activeOpacity, }, ]} > @@ -726,41 +675,37 @@ const NavigationBar = ({ )} - {shifting ? null : ( - - {renderLabel ? ( - renderLabel({ - route, - focused: false, - color: inactiveLabelColor, - }) - ) : ( - - {getLabelText({ route })} - - )} - - )} + + {renderLabel ? ( + renderLabel({ + route, + focused: false, + color: inactiveLabelColor, + }) + ) : ( + + {getLabelText({ route })} + + )} + ) : null} diff --git a/src/components/__tests__/BottomNavigation.test.tsx b/src/components/__tests__/BottomNavigation.test.tsx index 1843b52dfa..9e97330d5b 100644 --- a/src/components/__tests__/BottomNavigation.test.tsx +++ b/src/components/__tests__/BottomNavigation.test.tsx @@ -137,11 +137,13 @@ it('calls onIndexChange', () => { renderScene={({ route }) => route.title} /> ); + // Both the active and inactive labels render per tab, so target the last + // match (the label inside the tab) to fire the tab press. // pressing same index as active navigation state does not call onIndexChange - fireEvent(tree.getByText('Route: 0'), 'onPress'); + fireEvent(tree.getAllByText('Route: 0').at(-1)!, 'onPress'); expect(onIndexChange).not.toHaveBeenCalled(); - fireEvent(tree.getByText('Route: 1'), 'onPress'); + fireEvent(tree.getAllByText('Route: 1').at(-1)!, 'onPress'); expect(onIndexChange).toHaveBeenCalledTimes(1); }); @@ -158,7 +160,7 @@ it('calls onTabPress', () => { renderScene={({ route }) => route.title} /> ); - fireEvent(tree.getByText('Route: 1'), 'onPress'); + fireEvent(tree.getAllByText('Route: 1').at(-1)!, 'onPress'); expect(onTabPress).toHaveBeenCalled(); expect(onTabPress).toHaveBeenCalledTimes(1); expect(onTabPress).toHaveBeenLastCalledWith( @@ -185,7 +187,7 @@ it('calls onTabLongPress', () => { renderScene={({ route }) => route.title} /> ); - fireEvent(tree.getByText('Route: 2'), 'onLongPress'); + fireEvent(tree.getAllByText('Route: 2').at(-1)!, 'onLongPress'); expect(onTabLongPress).toHaveBeenCalled(); expect(onTabLongPress).toHaveBeenCalledTimes(1); expect(onTabLongPress).toHaveBeenLastCalledWith( @@ -212,21 +214,22 @@ it('renders non-shifting bottom navigation', () => { expect(tree).toMatchSnapshot(); }); -it('does not crash when shifting is true and the number of tabs in the navigationState is less than 2', () => { - jest.spyOn(console, 'warn').mockImplementation(() => {}); +it('does not warn or crash when the deprecated shifting prop is passed with fewer than 2 tabs', () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); - render( + const { getByTestId } = render( route.title} + testID="bottom-navigation" /> ); - expect(console.warn).toHaveBeenCalledWith( - 'BottomNavigation needs at least 2 tabs to run shifting animation' - ); + // `shifting` is a deprecated no-op, so it no longer warns about tab count. + expect(getByTestId('bottom-navigation-bar')).toBeDefined(); + expect(warn).not.toHaveBeenCalled(); jest.restoreAllMocks(); }); diff --git a/src/components/__tests__/__snapshots__/BottomNavigation.test.tsx.snap b/src/components/__tests__/__snapshots__/BottomNavigation.test.tsx.snap index 14d4d82930..9b5edd0bf1 100644 --- a/src/components/__tests__/__snapshots__/BottomNavigation.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/BottomNavigation.test.tsx.snap @@ -4201,11 +4201,6 @@ exports[`renders bottom navigation with scene animation 1`] = ` "marginBottom": 4, "marginHorizontal": 12, "marginTop": 0, - "transform": [ - { - "translateY": 0, - }, - ], "width": 32, } } @@ -4415,6 +4410,61 @@ exports[`renders bottom navigation with scene animation 1`] = ` Route: 0 + + + Route: 0 + + @@ -4481,11 +4531,6 @@ exports[`renders bottom navigation with scene animation 1`] = ` "marginBottom": 4, "marginHorizontal": 12, "marginTop": 0, - "transform": [ - { - "translateY": 7, - }, - ], "width": 32, } } @@ -4678,6 +4723,61 @@ exports[`renders bottom navigation with scene animation 1`] = ` Route: 1 + + + Route: 1 + + @@ -4744,11 +4844,6 @@ exports[`renders bottom navigation with scene animation 1`] = ` "marginBottom": 4, "marginHorizontal": 12, "marginTop": 0, - "transform": [ - { - "translateY": 7, - }, - ], "width": 32, } } @@ -4941,6 +5036,61 @@ exports[`renders bottom navigation with scene animation 1`] = ` Route: 2 + + + Route: 2 + + @@ -5007,11 +5157,6 @@ exports[`renders bottom navigation with scene animation 1`] = ` "marginBottom": 4, "marginHorizontal": 12, "marginTop": 0, - "transform": [ - { - "translateY": 7, - }, - ], "width": 32, } } @@ -5204,6 +5349,61 @@ exports[`renders bottom navigation with scene animation 1`] = ` Route: 3 + + + Route: 3 + + @@ -5270,11 +5470,6 @@ exports[`renders bottom navigation with scene animation 1`] = ` "marginBottom": 4, "marginHorizontal": 12, "marginTop": 0, - "transform": [ - { - "translateY": 7, - }, - ], "width": 32, } } @@ -5467,32 +5662,87 @@ exports[`renders bottom navigation with scene animation 1`] = ` Route: 4 - - - - - - - - -`; - -exports[`renders custom icon and label in non-shifting bottom navigation 1`] = ` - - + + Route: 4 + + + + + + + + + + +`; + +exports[`renders custom icon and label in non-shifting bottom navigation 1`] = ` + + + + + Route: 0 + + @@ -6565,11 +6829,6 @@ exports[`renders custom icon and label in shifting bottom navigation 1`] = ` "marginBottom": 4, "marginHorizontal": 12, "marginTop": 0, - "transform": [ - { - "translateY": 7, - }, - ], "width": 32, } } @@ -6667,6 +6926,25 @@ exports[`renders custom icon and label in shifting bottom navigation 1`] = ` Route: 1 + + + Route: 1 + + @@ -6733,11 +7011,6 @@ exports[`renders custom icon and label in shifting bottom navigation 1`] = ` "marginBottom": 4, "marginHorizontal": 12, "marginTop": 0, - "transform": [ - { - "translateY": 7, - }, - ], "width": 32, } } @@ -6835,6 +7108,25 @@ exports[`renders custom icon and label in shifting bottom navigation 1`] = ` Route: 2 + + + Route: 2 + + @@ -6901,11 +7193,6 @@ exports[`renders custom icon and label in shifting bottom navigation 1`] = ` "marginBottom": 4, "marginHorizontal": 12, "marginTop": 0, - "transform": [ - { - "translateY": 7, - }, - ], "width": 32, } } @@ -7003,6 +7290,25 @@ exports[`renders custom icon and label in shifting bottom navigation 1`] = ` Route: 3 + + + Route: 3 + + @@ -7069,11 +7375,6 @@ exports[`renders custom icon and label in shifting bottom navigation 1`] = ` "marginBottom": 4, "marginHorizontal": 12, "marginTop": 0, - "transform": [ - { - "translateY": 7, - }, - ], "width": 32, } } @@ -7171,6 +7472,25 @@ exports[`renders custom icon and label in shifting bottom navigation 1`] = ` Route: 4 + + + Route: 4 + + @@ -8477,11 +8797,6 @@ exports[`renders custom icon and label with custom colors in shifting bottom nav "marginBottom": 4, "marginHorizontal": 12, "marginTop": 0, - "transform": [ - { - "translateY": 0, - }, - ], "width": 32, } } @@ -8691,6 +9006,61 @@ exports[`renders custom icon and label with custom colors in shifting bottom nav Route: 0 + + + Route: 0 + + @@ -8757,11 +9127,6 @@ exports[`renders custom icon and label with custom colors in shifting bottom nav "marginBottom": 4, "marginHorizontal": 12, "marginTop": 0, - "transform": [ - { - "translateY": 7, - }, - ], "width": 32, } } @@ -8954,17 +9319,72 @@ exports[`renders custom icon and label with custom colors in shifting bottom nav Route: 1 - - - - + + Route: 1 + + + + + + + + + Route: 2 + + @@ -10523,11 +10993,6 @@ exports[`renders shifting bottom navigation 1`] = ` "marginBottom": 4, "marginHorizontal": 12, "marginTop": 0, - "transform": [ - { - "translateY": 0, - }, - ], "width": 32, } } @@ -10737,6 +11202,61 @@ exports[`renders shifting bottom navigation 1`] = ` Route: 0 + + + Route: 0 + + @@ -10803,11 +11323,6 @@ exports[`renders shifting bottom navigation 1`] = ` "marginBottom": 4, "marginHorizontal": 12, "marginTop": 0, - "transform": [ - { - "translateY": 7, - }, - ], "width": 32, } } @@ -11000,6 +11515,61 @@ exports[`renders shifting bottom navigation 1`] = ` Route: 1 + + + Route: 1 + + @@ -11066,11 +11636,6 @@ exports[`renders shifting bottom navigation 1`] = ` "marginBottom": 4, "marginHorizontal": 12, "marginTop": 0, - "transform": [ - { - "translateY": 7, - }, - ], "width": 32, } } @@ -11263,6 +11828,61 @@ exports[`renders shifting bottom navigation 1`] = ` Route: 2 + + + Route: 2 + + @@ -11329,11 +11949,6 @@ exports[`renders shifting bottom navigation 1`] = ` "marginBottom": 4, "marginHorizontal": 12, "marginTop": 0, - "transform": [ - { - "translateY": 7, - }, - ], "width": 32, } } @@ -11526,6 +12141,61 @@ exports[`renders shifting bottom navigation 1`] = ` Route: 3 + + + Route: 3 + + @@ -11592,11 +12262,6 @@ exports[`renders shifting bottom navigation 1`] = ` "marginBottom": 4, "marginHorizontal": 12, "marginTop": 0, - "transform": [ - { - "translateY": 7, - }, - ], "width": 32, } } @@ -11789,6 +12454,61 @@ exports[`renders shifting bottom navigation 1`] = ` Route: 4 + + + Route: 4 + + From 9196bacc108dff049b554bc4537a596f01dfdc69 Mon Sep 17 00:00:00 2001 From: Bartek Dybowski Date: Wed, 17 Jun 2026 11:00:44 +0200 Subject: [PATCH 04/15] feat(navigation-bar): add visible MD3 state layers Extract NavigationBarItem and render a pill-shaped state-layer overlay driven by hover/focus/press interaction state via getStateLayer (8% hover, 10% focus/press), replacing the suppressed ripple feedback. Re #4975 --- .../NavigationBar/NavigationBar.tsx | 503 ++++---- .../__tests__/BottomNavigation.test.tsx | 38 + .../BottomNavigation.test.tsx.snap | 1114 ++++++++++++++++- 3 files changed, 1427 insertions(+), 228 deletions(-) diff --git a/src/components/NavigationBar/NavigationBar.tsx b/src/components/NavigationBar/NavigationBar.tsx index 3eacd7bfae..ee6c5cc40f 100644 --- a/src/components/NavigationBar/NavigationBar.tsx +++ b/src/components/NavigationBar/NavigationBar.tsx @@ -11,6 +11,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { BAR_HEIGHT, + colorRoles, ICON_LABEL_GAP, INDICATOR_BORDER_RADIUS, INDICATOR_HEIGHT, @@ -20,6 +21,7 @@ import { NO_LABEL_BAR_HEIGHT, } from './tokens'; import { useInternalTheme } from '../../core/theming'; +import { getStateLayer } from '../../theme/utils/state'; import type { Theme, ThemeProp } from '../../types'; import useAnimatedValue from '../../utils/useAnimatedValue'; import useAnimatedValueArray from '../../utils/useAnimatedValueArray'; @@ -66,6 +68,10 @@ type TouchableProps = TouchableRippleProps & { borderless?: boolean; centered?: boolean; rippleColor?: ColorValue; + onHoverIn?: () => void; + onHoverOut?: () => void; + onFocus?: () => void; + onBlur?: () => void; }; export type Props = { @@ -225,6 +231,256 @@ const Touchable = ({ ); +type ItemProps = { + route: Route; + focused: boolean; + active: Animated.Value; + labeled: boolean; + activeTintColor: ColorValue; + inactiveTintColor: ColorValue; + activeColor?: string; + inactiveColor?: string; + renderIcon?: Props['renderIcon']; + renderLabel?: Props['renderLabel']; + renderTouchable: NonNullable['renderTouchable']>; + getLabelText: NonNullable['getLabelText']>; + getBadge: NonNullable['getBadge']>; + getTestID: NonNullable['getTestID']>; + getAccessibilityLabel: NonNullable['getAccessibilityLabel']>; + onPress: () => void; + onLongPress: () => void; + activeIndicatorStyle?: StyleProp; + labelMaxFontSizeMultiplier?: number; + theme: Theme; +}; + +const NavigationBarItem = ({ + route, + focused, + active, + labeled, + activeTintColor, + inactiveTintColor, + activeColor, + inactiveColor, + renderIcon, + renderLabel, + renderTouchable, + getLabelText, + getBadge, + getTestID, + getAccessibilityLabel, + onPress, + onLongPress, + activeIndicatorStyle, + labelMaxFontSizeMultiplier = 1, + theme, +}: ItemProps) => { + const { colors } = theme; + + const [hovered, setHovered] = React.useState(false); + const [keyboardFocused, setKeyboardFocused] = React.useState(false); + const [pressed, setPressed] = React.useState(false); + + // We render the active icon and label on top of the inactive ones and toggle + // their opacity on change, so the active/inactive colors swap without + // animating color (which the native driver can't do). + const activeOpacity = focused ? 1 : 0; + const inactiveOpacity = focused ? 0 : 1; + + // Scale horizontally the active-indicator pill. + const outlineScale = focused + ? active.interpolate({ + inputRange: [0, 1], + outputRange: [0.5, 1], + }) + : 0; + + const badge = getBadge({ route }); + + const activeLabelColor = getLabelColor({ + tintColor: activeTintColor, + hasColor: Boolean(activeColor), + focused, + theme, + }); + + const inactiveLabelColor = getLabelColor({ + tintColor: inactiveTintColor, + hasColor: Boolean(inactiveColor), + focused, + theme, + }); + + const badgeStyle = { + top: typeof badge === 'boolean' ? 4 : 2, + right: + badge != null && typeof badge !== 'boolean' + ? String(badge).length * -2 + : 0, + }; + + const font = theme.fonts.labelMedium; + + // MD3 state layer: visible on hover (8%) and focus/press (10%), shaped like + // the active-indicator pill. Active items use the on-secondary-container + // role, inactive items the on-surface-variant role. + const stateLayerRole = focused + ? colorRoles.activeIcon + : colorRoles.inactiveIcon; + const stateLayer = pressed + ? getStateLayer(theme, stateLayerRole, 'pressed') + : keyboardFocused + ? getStateLayer(theme, stateLayerRole, 'focused') + : hovered + ? getStateLayer(theme, stateLayerRole, 'hovered') + : null; + + const itemTestID = getTestID({ route }); + + return renderTouchable({ + key: route.key, + route, + borderless: true, + centered: true, + rippleColor: 'transparent', + onPress, + onLongPress, + onPressIn: () => setPressed(true), + onPressOut: () => setPressed(false), + onHoverIn: () => setHovered(true), + onHoverOut: () => setHovered(false), + onFocus: () => setKeyboardFocused(true), + onBlur: () => setKeyboardFocused(false), + testID: itemTestID, + accessibilityLabel: getAccessibilityLabel({ route }), + accessibilityRole: Platform.OS === 'ios' ? 'button' : 'tab', + accessibilityState: { selected: focused }, + style: [styles.item, styles.v3Item], + children: ( + + + {focused && ( + + )} + + + + + {renderIcon ? ( + renderIcon({ route, focused: true, color: activeTintColor }) + ) : ( + + )} + + + {renderIcon ? ( + renderIcon({ route, focused: false, color: inactiveTintColor }) + ) : ( + + )} + + + {typeof badge === 'boolean' ? ( + + ) : ( + + {badge} + + )} + + + {labeled ? ( + + + {renderLabel ? ( + renderLabel({ route, focused: true, color: activeLabelColor }) + ) : ( + + {getLabelText({ route })} + + )} + + + {renderLabel ? ( + renderLabel({ + route, + focused: false, + color: inactiveLabelColor, + }) + ) : ( + + {getLabelText({ route })} + + )} + + + ) : null} + + ), + }); +}; + /** * A navigation bar which can easily be integrated with [React Navigation's Bottom Tabs Navigator](https://reactnavigation.org/docs/bottom-tab-navigator/). * @@ -500,217 +756,32 @@ const NavigationBar = ({ > {routes.map((route, index) => { const focused = navigationState.index === index; - const active = tabsAnims[index]; - - // We render the active icon and label on top of the inactive ones - // and toggle their opacity on change, so the active/inactive colors - // swap without animating color (which the native driver can't do). - const activeOpacity = focused ? 1 : 0; - const inactiveOpacity = focused ? 0 : 1; - - // Scale horizontally the outline pill - const outlineScale = focused - ? active.interpolate({ - inputRange: [0, 1], - outputRange: [0.5, 1], - }) - : 0; - - const badge = getBadge({ route }); - - const activeLabelColor = getLabelColor({ - tintColor: activeTintColor, - hasColor: Boolean(activeColor), - focused, - theme, - }); - - const inactiveLabelColor = getLabelColor({ - tintColor: inactiveTintColor, - hasColor: Boolean(inactiveColor), - focused, - theme, - }); - - const badgeStyle = { - top: typeof badge === 'boolean' ? 4 : 2, - right: - badge != null && typeof badge !== 'boolean' - ? String(badge).length * -2 - : 0, - }; - - const font = (theme as Theme).fonts.labelMedium; - - return renderTouchable({ - key: route.key, - route, - borderless: true, - centered: true, - rippleColor: 'transparent', - onPress: () => onTabPress(eventForIndex(index)), - onLongPress: () => onTabLongPress?.(eventForIndex(index)), - testID: getTestID({ route }), - accessibilityLabel: getAccessibilityLabel({ route }), - accessibilityRole: Platform.OS === 'ios' ? 'button' : 'tab', - accessibilityState: { selected: focused }, - style: [styles.item, styles.v3Item], - children: ( - - - {focused && ( - - )} - - {renderIcon ? ( - renderIcon({ - route, - focused: true, - color: activeTintColor, - }) - ) : ( - - )} - - - {renderIcon ? ( - renderIcon({ - route, - focused: false, - color: inactiveTintColor, - }) - ) : ( - - )} - - - {typeof badge === 'boolean' ? ( - - ) : ( - - {badge} - - )} - - - {labeled ? ( - - - {renderLabel ? ( - renderLabel({ - route, - focused: true, - color: activeLabelColor, - }) - ) : ( - - {getLabelText({ route })} - - )} - - - {renderLabel ? ( - renderLabel({ - route, - focused: false, - color: inactiveLabelColor, - }) - ) : ( - - {getLabelText({ route })} - - )} - - - ) : null} - - ), - }); + + return ( + onTabPress(eventForIndex(index))} + onLongPress={() => onTabLongPress?.(eventForIndex(index))} + activeIndicatorStyle={activeIndicatorStyle} + labelMaxFontSizeMultiplier={labelMaxFontSizeMultiplier} + theme={theme as Theme} + /> + ); })} @@ -809,4 +880,14 @@ const styles = StyleSheet.create({ borderRadius: INDICATOR_BORDER_RADIUS, alignSelf: 'center', }, + stateLayerWrapper: { + ...StyleSheet.absoluteFill, + alignItems: 'center', + justifyContent: 'center', + }, + stateLayer: { + width: INDICATOR_WIDTH, + height: INDICATOR_HEIGHT, + borderRadius: INDICATOR_BORDER_RADIUS, + }, }); diff --git a/src/components/__tests__/BottomNavigation.test.tsx b/src/components/__tests__/BottomNavigation.test.tsx index 9e97330d5b..e659f9284c 100644 --- a/src/components/__tests__/BottomNavigation.test.tsx +++ b/src/components/__tests__/BottomNavigation.test.tsx @@ -14,6 +14,7 @@ import { getLabelColor, } from '../BottomNavigation/utils'; import Icon from '../Icon'; +import NavigationBar from '../NavigationBar/NavigationBar'; const styles = StyleSheet.create({ backgroundColor: { @@ -482,6 +483,43 @@ it('does not render legacy ripple overlay when shifting is disabled', () => { expect(queryByTestId('bottom-navigation-bar-content-ripple')).toBeNull(); }); +it('renders MD3 state layers on hover, focus and press', () => { + const navigationState = { + index: 0, + routes: [ + { key: 'a', title: 'Route: 0', focusedIcon: 'magnify', testID: 'tab-a' }, + { key: 'b', title: 'Route: 1', focusedIcon: 'camera', testID: 'tab-b' }, + ], + }; + + const { getByTestId } = render( + + ); + + const layerOpacity = () => + StyleSheet.flatten(getByTestId('tab-b-state-layer').props.style).opacity; + + // Idle: no visible state layer. + expect(layerOpacity()).toBeUndefined(); + + // Hovered: 8% state layer. + fireEvent(getByTestId('tab-b'), 'hoverIn'); + expect(layerOpacity()).toBe(0.08); + fireEvent(getByTestId('tab-b'), 'hoverOut'); + expect(layerOpacity()).toBeUndefined(); + + // Focused: 10% state layer. + fireEvent(getByTestId('tab-b'), 'focus'); + expect(layerOpacity()).toBe(0.1); + fireEvent(getByTestId('tab-b'), 'blur'); + + // Pressed: 10% state layer. + fireEvent(getByTestId('tab-b'), 'pressIn'); + expect(layerOpacity()).toBe(0.1); + fireEvent(getByTestId('tab-b'), 'pressOut'); + expect(layerOpacity()).toBeUndefined(); +}); + describe('getActiveTintColor', () => { it.each` activeColor | expected diff --git a/src/components/__tests__/__snapshots__/BottomNavigation.test.tsx.snap b/src/components/__tests__/__snapshots__/BottomNavigation.test.tsx.snap index 9b5edd0bf1..295965ab49 100644 --- a/src/components/__tests__/__snapshots__/BottomNavigation.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/BottomNavigation.test.tsx.snap @@ -219,6 +219,33 @@ exports[`allows customizing Route's type via generics 1`] = ` } } /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + } + > + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Date: Wed, 17 Jun 2026 11:18:02 +0200 Subject: [PATCH 05/15] feat(navigation-bar): drive motion from tokens and spring the indicator Replace hardcoded durations/easings with motion tokens and animate the active indicator with the M3-Expressive spatial spring (Animated.spring). A custom animationEasing still opts into timed movement; reduce-motion jumps instantly. Re #4975 --- .../NavigationBar/NavigationBar.tsx | 57 ++++++++++++++----- 1 file changed, 42 insertions(+), 15 deletions(-) diff --git a/src/components/NavigationBar/NavigationBar.tsx b/src/components/NavigationBar/NavigationBar.tsx index ee6c5cc40f..c3eab191f5 100644 --- a/src/components/NavigationBar/NavigationBar.tsx +++ b/src/components/NavigationBar/NavigationBar.tsx @@ -1,5 +1,12 @@ import * as React from 'react'; -import { Animated, Platform, StyleSheet, Pressable, View } from 'react-native'; +import { + Animated, + Easing, + Platform, + StyleSheet, + Pressable, + View, +} from 'react-native'; import type { ColorValue, EasingFunction, @@ -21,6 +28,7 @@ import { NO_LABEL_BAR_HEIGHT, } from './tokens'; import { useInternalTheme } from '../../core/theming'; +import { toRawSpring } from '../../theme/tokens/sys/motion'; import { getStateLayer } from '../../theme/utils/state'; import type { Theme, ThemeProp } from '../../types'; import useAnimatedValue from '../../utils/useAnimatedValue'; @@ -577,7 +585,7 @@ const NavigationBar = ({ theme: themeOverrides, }: Props) => { const theme = useInternalTheme(themeOverrides); - const { colors } = theme as Theme; + const { colors, motion } = theme as Theme; const { bottom, left, right } = useSafeAreaInsets(); const { scale } = theme.animation; const compact = compactProp ?? false; @@ -611,38 +619,57 @@ const NavigationBar = ({ setKeyboardVisible(true); Animated.timing(visibleAnim, { toValue: 0, - duration: 150 * scale, + // The bar slides out, so accelerate (exit). + duration: motion.duration.short3 * scale, + easing: Easing.bezier(...motion.easing.standardAccelerate), useNativeDriver: true, }).start(); - }, [scale, visibleAnim]); + }, [motion, scale, visibleAnim]); const handleKeyboardHide = React.useCallback(() => { Animated.timing(visibleAnim, { toValue: 1, - duration: 100 * scale, + // The bar slides back in, so decelerate (enter). + duration: motion.duration.short2 * scale, + easing: Easing.bezier(...motion.easing.standardDecelerate), useNativeDriver: true, }).start(() => { setKeyboardVisible(false); }); - }, [scale, visibleAnim]); + }, [motion, scale, visibleAnim]); const animateToIndex = React.useCallback( (index: number) => { + // When animations are disabled (e.g. reduce motion), jump to the value. + if (scale === 0) { + tabsAnims.forEach((tab, i) => tab.setValue(i === index ? 1 : 0)); + return; + } + Animated.parallel( - navigationState.routes.map((_, i) => - Animated.timing(tabsAnims[i], { - toValue: i === index ? 1 : 0, - duration: 150 * scale, - useNativeDriver: true, - easing: animationEasing, - }) - ) + navigationState.routes.map((_, i) => { + const toValue = i === index ? 1 : 0; + // Spring the active indicator for the M3-Expressive selection motion. + // A custom `animationEasing` opts back into timed (eased) movement. + return animationEasing + ? Animated.timing(tabsAnims[i], { + toValue, + duration: motion.duration.short4 * scale, + easing: animationEasing, + useNativeDriver: true, + }) + : Animated.spring(tabsAnims[i], { + toValue, + ...toRawSpring(motion.spring.fast.spatial), + useNativeDriver: true, + }); + }) ).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] + [scale, navigationState.routes, tabsAnims, animationEasing, motion] ); React.useEffect(() => { From 77d1704d6711c61ea1c9b0bb74eecb1b55ce5b56 Mon Sep 17 00:00:00 2001 From: Bartek Dybowski Date: Wed, 17 Jun 2026 11:35:07 +0200 Subject: [PATCH 06/15] feat(navigation-bar): add flexible horizontal variant Add a `variant` prop ('stacked' | 'horizontal'). The horizontal arrangement places the icon beside the label inside a content-hugging indicator pill, for medium-width windows. It is a no-op without labels. The indicator springs its opacity/scale in place. Re #4975 --- .../NavigationBar/NavigationBar.tsx | 365 +++++++--- .../__tests__/BottomNavigation.test.tsx | 27 + .../BottomNavigation.test.tsx.snap | 665 ++++++++++++++++++ 3 files changed, 940 insertions(+), 117 deletions(-) diff --git a/src/components/NavigationBar/NavigationBar.tsx b/src/components/NavigationBar/NavigationBar.tsx index c3eab191f5..724ef62950 100644 --- a/src/components/NavigationBar/NavigationBar.tsx +++ b/src/components/NavigationBar/NavigationBar.tsx @@ -87,6 +87,15 @@ export type Props = { * Whether to show labels in tabs. When `false`, only icons will be displayed. */ labeled?: boolean; + /** + * The item layout variant of the flexible navigation bar. + * + * - `stacked` (default): the icon sits above the label. + * - `horizontal`: the icon sits beside the label and the active indicator + * hugs both. Recommended for medium-width windows (e.g. foldables and + * tablets). Has no effect when `labeled` is `false`. + */ + variant?: 'stacked' | 'horizontal'; /** * Whether tabs should be spread across the entire width. */ @@ -244,6 +253,7 @@ type ItemProps = { focused: boolean; active: Animated.Value; labeled: boolean; + variant: 'stacked' | 'horizontal'; activeTintColor: ColorValue; inactiveTintColor: ColorValue; activeColor?: string; @@ -267,6 +277,7 @@ const NavigationBarItem = ({ focused, active, labeled, + variant, activeTintColor, inactiveTintColor, activeColor, @@ -345,96 +356,185 @@ const NavigationBarItem = ({ : null; const itemTestID = getTestID({ route }); + // The horizontal arrangement places the label beside the icon and only + // applies when labels are shown; otherwise it falls back to stacked icon-only. + const horizontal = variant === 'horizontal' && labeled; - return renderTouchable({ - key: route.key, - route, - borderless: true, - centered: true, - rippleColor: 'transparent', - onPress, - onLongPress, - onPressIn: () => setPressed(true), - onPressOut: () => setPressed(false), - onHoverIn: () => setHovered(true), - onHoverOut: () => setHovered(false), - onFocus: () => setKeyboardFocused(true), - onBlur: () => setKeyboardFocused(false), - testID: itemTestID, - accessibilityLabel: getAccessibilityLabel({ route }), - accessibilityRole: Platform.OS === 'ios' ? 'button' : 'tab', - accessibilityState: { selected: focused }, - style: [styles.item, styles.v3Item], - children: ( - - - {focused && ( - + + {focused && ( + + )} + + + + + {renderIcon ? ( + renderIcon({ route, focused: true, color: activeTintColor }) + ) : ( + )} - - + + {renderIcon ? ( + renderIcon({ route, focused: false, color: inactiveTintColor }) + ) : ( + - + )} + + + {typeof badge === 'boolean' ? ( + + ) : ( + + {badge} + + )} + + + {labeled ? ( + - {renderIcon ? ( - renderIcon({ route, focused: true, color: activeTintColor }) + {renderLabel ? ( + renderLabel({ route, focused: true, color: activeLabelColor }) ) : ( - + + {getLabelText({ route })} + )} - {renderIcon ? ( - renderIcon({ route, focused: false, color: inactiveTintColor }) + {renderLabel ? ( + renderLabel({ + route, + focused: false, + color: inactiveLabelColor, + }) ) : ( - + + {getLabelText({ route })} + )} + + ) : null} + + ); + + const horizontalContent = ( + + + {focused && ( + + )} + + + {renderIcon ? ( + renderIcon({ + route, + focused, + color: focused ? activeTintColor : inactiveTintColor, + }) + ) : ( + + )} {typeof badge === 'boolean' ? ( @@ -444,48 +544,53 @@ const NavigationBarItem = ({ )} - - {labeled ? ( - - - {renderLabel ? ( - renderLabel({ route, focused: true, color: activeLabelColor }) - ) : ( - - {getLabelText({ route })} - - )} - - - {renderLabel ? ( - renderLabel({ - route, - focused: false, - color: inactiveLabelColor, - }) - ) : ( - - {getLabelText({ route })} - - )} - - - ) : null} + + {renderLabel ? ( + renderLabel({ + route, + focused, + color: focused ? activeLabelColor : inactiveLabelColor, + }) + ) : ( + + {getLabelText({ route })} + + )} - ), + + ); + + return renderTouchable({ + key: route.key, + route, + borderless: true, + centered: true, + rippleColor: 'transparent', + onPress, + onLongPress, + onPressIn: () => setPressed(true), + onPressOut: () => setPressed(false), + onHoverIn: () => setHovered(true), + onHoverOut: () => setHovered(false), + onFocus: () => setKeyboardFocused(true), + onBlur: () => setKeyboardFocused(false), + testID: itemTestID, + accessibilityLabel: getAccessibilityLabel({ route }), + accessibilityRole: Platform.OS === 'ios' ? 'button' : 'tab', + accessibilityState: { selected: focused }, + style: [styles.item, styles.v3Item], + children: horizontal ? horizontalContent : stackedContent, }); }; @@ -575,6 +680,7 @@ const NavigationBar = ({ style, activeIndicatorStyle, labeled = true, + variant = 'stacked', animationEasing, onTabPress, onTabLongPress, @@ -791,6 +897,7 @@ const NavigationBar = ({ focused={focused} active={tabsAnims[index]} labeled={labeled} + variant={variant} activeTintColor={activeTintColor} inactiveTintColor={inactiveTintColor} activeColor={activeColor} @@ -917,4 +1024,28 @@ const styles = StyleSheet.create({ height: INDICATOR_HEIGHT, borderRadius: INDICATOR_BORDER_RADIUS, }, + horizontalContainer: { + height: BAR_HEIGHT, + justifyContent: 'center', + alignItems: 'center', + }, + horizontalItem: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + height: INDICATOR_HEIGHT, + paddingHorizontal: 16, + }, + horizontalIndicator: { + borderRadius: INDICATOR_BORDER_RADIUS, + }, + horizontalLabel: { + marginLeft: ICON_LABEL_GAP, + textAlign: 'center', + ...(Platform.OS === 'web' + ? { + whiteSpace: 'nowrap', + } + : null), + }, }); diff --git a/src/components/__tests__/BottomNavigation.test.tsx b/src/components/__tests__/BottomNavigation.test.tsx index e659f9284c..13ee836926 100644 --- a/src/components/__tests__/BottomNavigation.test.tsx +++ b/src/components/__tests__/BottomNavigation.test.tsx @@ -483,6 +483,33 @@ it('does not render legacy ripple overlay when shifting is disabled', () => { expect(queryByTestId('bottom-navigation-bar-content-ripple')).toBeNull(); }); +it('renders the horizontal (flexible) variant', () => { + const tree = render( + + ).toJSON(); + + expect(tree).toMatchSnapshot(); +}); + +it('falls back to icon-only when horizontal is combined with labeled=false', () => { + const { queryByText } = render( + + ); + + // `horizontal` is a no-op without labels, so no label text is rendered. + expect(queryByText('Route: 0')).toBeNull(); + expect(queryByText('Route: 1')).toBeNull(); +}); + it('renders MD3 state layers on hover, focus and press', () => { const navigationState = { index: 0, diff --git a/src/components/__tests__/__snapshots__/BottomNavigation.test.tsx.snap b/src/components/__tests__/__snapshots__/BottomNavigation.test.tsx.snap index 295965ab49..3ad1c58fa3 100644 --- a/src/components/__tests__/__snapshots__/BottomNavigation.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/BottomNavigation.test.tsx.snap @@ -13598,3 +13598,668 @@ exports[`renders shifting bottom navigation 1`] = ` `; + +exports[`renders the horizontal (flexible) variant 1`] = ` + + + + + + + + + + + + magnify + + + + + + + Route: 0 + + + + + + + + + + + camera + + + + + + + Route: 1 + + + + + + + + + + + inbox + + + + + + + Route: 2 + + + + + + + + +`; From bbedae9db41fd9a579d4869608b83858c3432108 Mon Sep 17 00:00:00 2001 From: Bartek Dybowski Date: Wed, 17 Jun 2026 11:47:43 +0200 Subject: [PATCH 07/15] docs(navigation-bar): add example and update JSDoc Add a NavigationBarExample with a responsive stacked/horizontal toggle, label toggle and badges, registered in the example list. Rewrite the NavigationBar JSDoc with variant docs and a migration note from the deprecated BottomNavigation.Bar. Re #4975 --- example/src/ExampleList.tsx | 2 + example/src/Examples/NavigationBarExample.tsx | 108 ++++++++++++++++++ .../NavigationBar/NavigationBar.tsx | 33 +++--- 3 files changed, 129 insertions(+), 14 deletions(-) create mode 100644 example/src/Examples/NavigationBarExample.tsx diff --git a/example/src/ExampleList.tsx b/example/src/ExampleList.tsx index 53132e299f..5c8aba19da 100644 --- a/example/src/ExampleList.tsx +++ b/example/src/ExampleList.tsx @@ -27,6 +27,7 @@ import ListAccordionExampleGroup from './Examples/ListAccordionGroupExample'; import ListItemExample from './Examples/ListItemExample'; import ListSectionExample from './Examples/ListSectionExample'; import MenuExample from './Examples/MenuExample'; +import NavigationBarExample from './Examples/NavigationBarExample'; import ProgressBarExample from './Examples/ProgressBarExample'; import RadioButtonExample from './Examples/RadioButtonExample'; import RadioButtonGroupExample from './Examples/RadioButtonGroupExample'; @@ -72,6 +73,7 @@ export const mainExamples = { ListSection: ListSectionExample, ListItem: ListItemExample, Menu: MenuExample, + NavigationBar: NavigationBarExample, Progressbar: ProgressBarExample, Radio: RadioButtonExample, RadioGroup: RadioButtonGroupExample, diff --git a/example/src/Examples/NavigationBarExample.tsx b/example/src/Examples/NavigationBarExample.tsx new file mode 100644 index 0000000000..070bbc28c2 --- /dev/null +++ b/example/src/Examples/NavigationBarExample.tsx @@ -0,0 +1,108 @@ +import * as React from 'react'; +import { StyleSheet, View, useWindowDimensions } from 'react-native'; + +import { + NavigationBar, + SegmentedButtons, + Switch, + Text, +} from 'react-native-paper'; + +type VariantMode = 'auto' | 'stacked' | 'horizontal'; + +// The flexible navigation bar switches to a horizontal item arrangement in +// medium-width windows. M3 recommends ~600dp as the breakpoint, but the +// component leaves the decision to the consumer — here it is driven by +// `useWindowDimensions`. +const MEDIUM_WINDOW_WIDTH = 600; + +const routes = [ + { key: 'album', title: 'Album', focusedIcon: 'image-album', badge: 3 }, + { key: 'library', title: 'Library', focusedIcon: 'bookshelf' }, + { + key: 'favorites', + title: 'Favorites', + focusedIcon: 'heart', + unfocusedIcon: 'heart-outline', + }, + { + key: 'settings', + title: 'Settings', + focusedIcon: 'cog', + unfocusedIcon: 'cog-outline', + }, +]; + +const NavigationBarExample = () => { + const [index, setIndex] = React.useState(0); + const [labeled, setLabeled] = React.useState(true); + const [variantMode, setVariantMode] = React.useState('auto'); + + const { width } = useWindowDimensions(); + const autoVariant = width >= MEDIUM_WINDOW_WIDTH ? 'horizontal' : 'stacked'; + const variant = variantMode === 'auto' ? autoVariant : variantMode; + + return ( + + + {routes[index].title} + + + + Show labels + + + + setVariantMode(value as VariantMode)} + buttons={[ + { value: 'auto', label: `Auto (${autoVariant})` }, + { value: 'stacked', label: 'Stacked' }, + { value: 'horizontal', label: 'Horizontal' }, + ]} + /> + + + + { + const nextIndex = routes.findIndex((r) => r.key === route.key); + if (nextIndex !== -1) { + setIndex(nextIndex); + } + }} + /> + + ); +}; + +NavigationBarExample.title = 'Navigation Bar (flexible)'; + +export default NavigationBarExample; + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + content: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + padding: 16, + }, + controls: { + marginTop: 32, + width: '100%', + maxWidth: 480, + gap: 24, + }, + row: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + }, +}); diff --git a/src/components/NavigationBar/NavigationBar.tsx b/src/components/NavigationBar/NavigationBar.tsx index 724ef62950..42dbbc15cc 100644 --- a/src/components/NavigationBar/NavigationBar.tsx +++ b/src/components/NavigationBar/NavigationBar.tsx @@ -130,7 +130,7 @@ export type Props = { * } * ``` * - * `BottomNavigation.Bar` is a controlled component, which means the `index` needs to be updated via the `onTabPress` callback. + * `NavigationBar` is a controlled component, which means the `index` needs to be updated via the `onTabPress` callback. */ navigationState: NavigationState; /** @@ -595,16 +595,24 @@ const NavigationBarItem = ({ }; /** - * A navigation bar which can easily be integrated with [React Navigation's Bottom Tabs Navigator](https://reactnavigation.org/docs/bottom-tab-navigator/). + * The Material Design 3 flexible navigation bar. It can easily be integrated + * with [React Navigation's Bottom Tabs Navigator](https://reactnavigation.org/docs/bottom-tab-navigator/). + * + * The flexible navigation bar replaces the original (now deprecated) navigation + * bar exposed as `BottomNavigation.Bar`. Set the `variant` prop to `'horizontal'` + * to lay items out horizontally (icon beside label) in medium-width windows. + * + * Migrating from `BottomNavigation.Bar`: it is deprecated in favor of + * `NavigationBar`. The Material Design 2 `shifting` prop has been removed (it + * has no MD3 equivalent), tab interactions now show MD3 state layers instead of + * suppressing feedback, and the bar height follows the 64dp spec. * * ## Usage * ### without React Navigation * ```js - * import React from 'react'; - * import { useState } from 'react'; + * import * as React 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'; + * import { NavigationBar, Text, Provider } from 'react-native-paper'; * * function HomeScreen() { * return ( @@ -623,13 +631,13 @@ const NavigationBarItem = ({ * } * * export default function MyComponent() { - * const [index, setIndex] = useState(0); + * const [index, setIndex] = React.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': @@ -644,7 +652,7 @@ const NavigationBarItem = ({ * return ( * * {renderScene({ route: routes[index] })} - * { * const newIndex = routes.findIndex((r) => r.key === route.key); @@ -652,9 +660,6 @@ const NavigationBarItem = ({ * setIndex(newIndex); * } * }} - * renderIcon={({ route, color }) => ( - * - * )} * getLabelText={({ route }) => route.title} * /> * From 3f2e172f0d36d351922be18643fdf41137394404 Mon Sep 17 00:00:00 2001 From: Bartek Dybowski Date: Wed, 17 Jun 2026 13:28:22 +0200 Subject: [PATCH 08/15] fix(navigation-bar): restore labels and active indicator on tab change Stacked labels were collapsed to zero width by `alignItems: center` on the item container; remove it (the icon centers via alignSelf). The active indicator was mounted only while focused and driven by a native value, so it never appeared on a newly-selected tab; always mount it and drive opacity from `active` so it cross-fades between tabs. Re #4975 --- .../NavigationBar/NavigationBar.tsx | 78 +-- .../__tests__/BottomNavigation.test.tsx | 19 + .../BottomNavigation.test.tsx.snap | 635 ++++++++++++++++-- 3 files changed, 643 insertions(+), 89 deletions(-) diff --git a/src/components/NavigationBar/NavigationBar.tsx b/src/components/NavigationBar/NavigationBar.tsx index 42dbbc15cc..791588b96b 100644 --- a/src/components/NavigationBar/NavigationBar.tsx +++ b/src/components/NavigationBar/NavigationBar.tsx @@ -307,13 +307,13 @@ const NavigationBarItem = ({ const activeOpacity = focused ? 1 : 0; const inactiveOpacity = focused ? 0 : 1; - // Scale horizontally the active-indicator pill. - const outlineScale = focused - ? active.interpolate({ - inputRange: [0, 1], - outputRange: [0.5, 1], - }) - : 0; + // Scale horizontally the active-indicator pill. The indicator is always + // mounted and its visibility is driven by `active` opacity, so it cross-fades + // between tabs instead of remounting (which breaks native-driven animations). + const outlineScale = active.interpolate({ + inputRange: [0, 1], + outputRange: [0.5, 1], + }); const badge = getBadge({ route }); @@ -366,18 +366,17 @@ const NavigationBarItem = ({ style={labeled ? styles.v3TouchableContainer : styles.v3NoLabelContainer} > - {focused && ( - - )} + ({ const horizontalContent = ( - {focused && ( - - )} + { expect(queryByTestId('bottom-navigation-bar-content-ripple')).toBeNull(); }); +it('renders tab labels when labeled', () => { + const { getAllByText } = render( + + ); + + // Each tab renders an active and inactive label layer, so both match. + expect(getAllByText('Alpha').length).toBeGreaterThan(0); + expect(getAllByText('Beta').length).toBeGreaterThan(0); +}); + it('renders the horizontal (flexible) variant', () => { const tree = render( + + + + + + + + + + + + + - + + + + + + + + + + + + + + + + + + + Date: Wed, 17 Jun 2026 13:43:37 +0200 Subject: [PATCH 09/15] test(navigation-bar): drop redundant shifting test variants `shifting` is now a no-op, so the "shifting"/"non-shifting" test pairs were duplicates. Merge them, remove the dead `shifting` props, rename to neutral titles, and prune obsolete snapshots. Keep the deprecation test and the scene-animation coverage. Re #4975 --- .../__tests__/BottomNavigation.test.tsx | 81 +- .../BottomNavigation.test.tsx.snap | 9172 ++++++----------- 2 files changed, 3147 insertions(+), 6106 deletions(-) diff --git a/src/components/__tests__/BottomNavigation.test.tsx b/src/components/__tests__/BottomNavigation.test.tsx index 71cf13ce63..a5c957ded8 100644 --- a/src/components/__tests__/BottomNavigation.test.tsx +++ b/src/components/__tests__/BottomNavigation.test.tsx @@ -34,10 +34,9 @@ const createState = (index: number, length: number) => ({ })), }); -it('renders shifting bottom navigation', () => { +it('renders bottom navigation', () => { const tree = render( route.title} @@ -50,7 +49,6 @@ it('renders shifting bottom navigation', () => { it('renders bottom navigation with scene animation', () => { const tree = render( { const tree = render( { const onIndexChange = jest.fn(); const tree = render( route.title} @@ -154,7 +150,6 @@ it('calls onTabPress', () => { const tree = render( { const tree = render( { ); }); -it('renders non-shifting bottom navigation', () => { +it('renders bottom navigation with three tabs', () => { const tree = render( route.title} @@ -235,10 +228,9 @@ it('does not warn or crash when the deprecated shifting prop is passed with fewe jest.restoreAllMocks(); }); -it('renders custom icon and label in shifting bottom navigation', () => { +it('renders custom icon and label', () => { const tree = render( route.title} @@ -256,46 +248,9 @@ it('renders custom icon and label in shifting bottom navigation', () => { expect(tree).toMatchSnapshot(); }); -it('renders custom icon and label in non-shifting bottom navigation', () => { +it('renders with custom active and inactive colors', () => { const tree = render( route.title} - renderIcon={({ route, color }) => ( - - )} - renderLabel={({ route, color }) => ( - - {route.title} - - )} - /> - ).toJSON(); - - expect(tree).toMatchSnapshot(); -}); - -it('renders custom icon and label with custom colors in shifting bottom navigation', () => { - const tree = render( - route.title} - activeColor="#FBF7DB" - inactiveColor="#853D4B" - /> - ).toJSON(); - - expect(tree).toMatchSnapshot(); -}); - -it('renders custom icon and label with custom colors in non-shifting bottom navigation', () => { - const tree = render( - route.title} @@ -307,24 +262,9 @@ it('renders custom icon and label with custom colors in non-shifting bottom navi expect(tree).toMatchSnapshot(); }); -it('hides labels in shifting bottom navigation', () => { - const tree = render( - route.title} - /> - ).toJSON(); - - expect(tree).toMatchSnapshot(); -}); - -it('hides labels in non-shifting bottom navigation', () => { +it('hides labels when labeled is false', () => { const tree = render( { const labelMaxFontSizeMultiplier = 2; const { getAllByText } = render( { it('renders custom background color passed to barStyle property', () => { const { getByTestId } = render( { it('renders a single tab', () => { const { queryByTestId } = render( route.title} @@ -424,7 +361,6 @@ it('applies maxTabBarWidth styling if compact prop is truthy', () => { onIndexChange={jest.fn()} renderScene={({ route }) => route.title} getLazy={({ route }) => route.key === 'key-2'} - shifting={false} testID="bottom-navigation" compact /> @@ -442,7 +378,6 @@ it('does not apply maxTabBarWidth styling if compact prop is falsy', () => { onIndexChange={jest.fn()} renderScene={({ route }) => route.title} getLazy={({ route }) => route.key === 'key-2'} - shifting={false} testID="bottom-navigation" compact={false} /> @@ -453,7 +388,7 @@ it('does not apply maxTabBarWidth styling if compact prop is falsy', () => { }); }); -it('renders bar content when shifting is enabled', () => { +it('renders bar content', () => { const { getByTestId } = render( { renderScene={({ route }) => route.title} getLazy={({ route }) => route.key === 'key-2'} testID="bottom-navigation" - shifting /> ); expect(getByTestId('bottom-navigation-bar-content')).toBeDefined(); }); -it('does not render legacy ripple overlay when shifting is disabled', () => { +it('does not render the legacy ripple overlay', () => { const { queryByTestId } = render( { renderScene={({ route }) => route.title} getLazy={({ route }) => route.key === 'key-2'} testID="bottom-navigation" - shifting={false} /> ); diff --git a/src/components/__tests__/__snapshots__/BottomNavigation.test.tsx.snap b/src/components/__tests__/__snapshots__/BottomNavigation.test.tsx.snap index a941148f83..c8b5f411fb 100644 --- a/src/components/__tests__/__snapshots__/BottomNavigation.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/BottomNavigation.test.tsx.snap @@ -736,7 +736,7 @@ exports[`allows customizing Route's type via generics 1`] = ` `; -exports[`hides labels in non-shifting bottom navigation 1`] = ` +exports[`hides labels when labeled is false 1`] = ` `; -exports[`hides labels in shifting bottom navigation 1`] = ` +exports[`renders bottom navigation 1`] = ` + + + + Route: 0 + + + + + Route: 0 + + + - - - - - - - - - - - - - - inbox - - - - - inbox - - - - - - - - - - - - - -`; - -exports[`renders bottom navigation with getLazy 1`] = ` - - - - - Route: 0 - - - - - Route: 1 - - - - - Route: 3 - - - - - Route: 4 - - - - - - - - - - - - - - - - - magnify - - - - - magnify - - - - - - - - - - Route: 0 - - - - - Route: 0 - - - - - - - - - - - - - - - camera - - - - - camera - - - - - - - - - - Route: 1 - - - - - Route: 1 - - - - - - - - - - - - - - - inbox - - - - - inbox - - - - - - - - - - Route: 2 - - - - - Route: 2 - - - - - - - - - - - - - - - heart - - - - - heart - - - - - - - - - - Route: 3 - - - - - Route: 3 - - - - - - - - - - - - - - - shopping-music - - - - - shopping-music - - - - - - - - - - Route: 4 - - - - - Route: 4 - - - - - - - - - - -`; - -exports[`renders bottom navigation with scene animation 1`] = ` - - - - - Route: 0 - - - - - - - - - - - - - - - - - magnify - - - - - magnify - - - - - - - - - - Route: 0 - - - - - Route: 0 - - - - - - - - - - - - - - - camera - - - - - camera - - - - - - - - - - Route: 1 - - - - - Route: 1 - + + + + + + Route: 1 + + + + + Route: 1 + @@ -6440,7 +3522,7 @@ exports[`renders bottom navigation with scene animation 1`] = ` `; -exports[`renders custom icon and label in non-shifting bottom navigation 1`] = ` +exports[`renders bottom navigation with getLazy 1`] = ` + + Route: 0 + + + + + Route: 1 + + + + + Route: 3 + + + - Route: 0 + Route: 4 @@ -6555,252 +3769,26 @@ exports[`renders custom icon and label in non-shifting bottom navigation 1`] = ` "alignItems": "center", "backgroundColor": "rgba(243, 237, 247, 1)", "overflow": "hidden", - } - } - testID="bottom-navigation-bar-content" - > - - - - - - - - - - - - - - - - - - Route: 0 - - - - - Route: 0 - - - - - + } + } + testID="bottom-navigation-bar-content" + > + + > + + magnify + + + > + + magnify + + - - Route: 1 - + Route: 0 + - - Route: 1 - + Route: 0 + @@ -7112,60 +4231,120 @@ exports[`renders custom icon and label in non-shifting bottom navigation 1`] = ` } /> + + + + + camera + + + - + > + camera + - - - - - - - - Route: 2 - - - - - Route: 2 - - - - - - - - - - -`; - -exports[`renders custom icon and label in shifting bottom navigation 1`] = ` - - - - - Route: 0 - - - - - - - + + + + + + Route: 1 + + + + + Route: 1 + + + + + + > + + inbox + + + > + + inbox + + + /> + + + + + + Route: 2 + - - - - Route: 0 - - - - - Route: 0 - + Route: 2 + @@ -7745,7 +4984,37 @@ exports[`renders custom icon and label in shifting bottom navigation 1`] = ` "top": 4, } } - /> + > + + heart + + + > + + heart + + - - Route: 1 - + Route: 3 + - - Route: 1 - + Route: 3 + @@ -7971,7 +5341,37 @@ exports[`renders custom icon and label in shifting bottom navigation 1`] = ` "top": 4, } } - /> + > + + shopping-music + + + > + + shopping-music + + - - Route: 2 - + Route: 4 + - - Route: 2 - + Route: 4 + + + + + + +`; + +exports[`renders bottom navigation with scene animation 1`] = ` + + + + + Route: 0 + + + + + + + + > + + magnify + + + > + + magnify + + - - Route: 3 - + Route: 0 + - - Route: 3 - + Route: 0 + @@ -8423,7 +6197,37 @@ exports[`renders custom icon and label in shifting bottom navigation 1`] = ` "top": 4, } } - /> + > + + camera + + + > + + camera + + - + - Route: 4 - + Route: 1 + - - Route: 4 - + Route: 1 + - - - - - -`; - -exports[`renders custom icon and label with custom colors in non-shifting bottom navigation 1`] = ` - - - - - Route: 0 - - - - - - - - magnify + inbox - magnify + inbox - Route: 0 + Route: 2 - Route: 0 + Route: 2 @@ -9157,7 +6920,7 @@ exports[`renders custom icon and label with custom colors in non-shifting bottom style={ [ { - "color": "#FBF7DB", + "color": "rgba(29, 25, 43, 1)", "fontSize": 24, }, [ @@ -9176,7 +6939,7 @@ exports[`renders custom icon and label with custom colors in non-shifting bottom ] } > - camera + heart - camera + heart - Route: 1 + Route: 3 - Route: 1 + Route: 3 @@ -9514,7 +7277,7 @@ exports[`renders custom icon and label with custom colors in non-shifting bottom style={ [ { - "color": "#FBF7DB", + "color": "rgba(29, 25, 43, 1)", "fontSize": 24, }, [ @@ -9533,7 +7296,7 @@ exports[`renders custom icon and label with custom colors in non-shifting bottom ] } > - inbox + shopping-music - inbox + shopping-music - Route: 2 + Route: 4 - Route: 2 + Route: 4 @@ -9745,7 +7508,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 bottom navigation with three tabs 1`] = ` `; -exports[`renders non-shifting bottom navigation 1`] = ` +exports[`renders custom icon and label 1`] = ` - - magnify - - + /> - - magnify - - + /> - Route: 0 - + - Route: 0 - + @@ -11574,37 +9206,7 @@ exports[`renders non-shifting bottom navigation 1`] = ` "top": 4, } } - > - - camera - - + /> - - camera - - + } + /> - Route: 1 - + - Route: 1 - + @@ -11931,37 +9432,7 @@ exports[`renders non-shifting bottom navigation 1`] = ` "top": 4, } } - > - - inbox - - + /> - - inbox - - + /> + + Route: 2 + + + + + Route: 2 + + + + + + + + + - - Route: 2 - - + } + /> - - Route: 2 - - - - - - - - - - -`; - -exports[`renders shifting bottom navigation 1`] = ` - - - - - Route: 0 - - - - - - - + null, + ] + } + /> + + + + + + + + + + + Route: 3 + + + + + Route: 3 + + + + + - - magnify - - + /> - - magnify - - + /> - - - - - - Route: 0 - + /> + + - + Route: 4 + + + + - Route: 0 - + Route: 4 + - + + + + +`; + +exports[`renders the horizontal (flexible) variant 1`] = ` + + + + + + + + /> - - - + + - + { + "backgroundColor": "transparent", + }, + ], + ] + } + > + magnify + + + + - camera - - - + Route: 0 + + + + + + + + + - + + - camera - - + { + "backgroundColor": "transparent", + }, + ], + ] + } + > + camera + - - - - Route: 1 - - - - - Route: 1 - - - + ], + ], + ] + } + > + Route: 1 + - - + + + @@ -13076,149 +10549,69 @@ exports[`renders shifting bottom navigation 1`] = ` collapsable={false} style={ { - "alignSelf": "center", - "height": 32, - "justifyContent": "center", - "marginBottom": 4, - "marginHorizontal": 12, - "marginTop": 0, - "width": 32, + "backgroundColor": "rgba(232, 222, 248, 1)", + "borderRadius": 16, + "bottom": 0, + "left": 0, + "opacity": 0, + "position": "absolute", + "right": 0, + "top": 0, + "transform": [ + { + "scale": 0.8, + }, + ], } } - > - - + - - - - + + - inbox - - - - - inbox - - + ], + ] + } + > + inbox + - - - - Route: 2 - - - - - Route: 2 - - - + ], + ], + ] + } + > + Route: 2 + + + + + + +`; + +exports[`renders with custom active and inactive colors 1`] = ` + + + + + Route: 0 + + + + + + + - heart + magnify - heart + magnify - Route: 3 + Route: 0 - Route: 3 + Route: 0 @@ -13835,466 +11291,104 @@ exports[`renders shifting bottom navigation 1`] = ` - - - - shopping-music - - - - - shopping-music - - - - - - - - - - Route: 4 - + /> - Route: 4 + camera - - - - - - - - -`; - -exports[`renders the horizontal (flexible) variant 1`] = ` - - - - - - - - - - - + - magnify - + [ + { + "lineHeight": 24, + "transform": [ + { + "scaleX": 1, + }, + ], + }, + { + "backgroundColor": "transparent", + }, + ], + ] + } + > + camera + + - - Route: 0 - - - - - - - - + - - + - camera - + [ + { + "fontFamily": "System", + "fontSize": 12, + "fontWeight": "500", + "letterSpacing": 0.5, + "lineHeight": 16, + }, + [ + { + "backgroundColor": "transparent", + "fontSize": 12, + "textAlign": "center", + }, + { + "color": "#FBF7DB", + "fontFamily": "System", + "fontSize": 12, + "fontWeight": "500", + "letterSpacing": 0.5, + "lineHeight": 16, + }, + ], + ], + ] + } + > + Route: 1 + + - - - - Route: 1 - + [ + { + "fontFamily": "System", + "fontSize": 12, + "fontWeight": "500", + "letterSpacing": 0.5, + "lineHeight": 16, + }, + [ + { + "backgroundColor": "transparent", + "fontSize": 12, + "textAlign": "center", + }, + { + "color": "#853D4B", + "fontFamily": "System", + "fontSize": 12, + "fontWeight": "500", + "letterSpacing": 0.5, + "lineHeight": 16, + }, + ], + ], + ] + } + > + Route: 1 + + + - - @@ -14654,69 +11603,149 @@ exports[`renders the horizontal (flexible) variant 1`] = ` collapsable={false} style={ { - "backgroundColor": "rgba(232, 222, 248, 1)", - "borderRadius": 16, - "bottom": 0, - "left": 0, - "opacity": 0, - "position": "absolute", - "right": 0, - "top": 0, - "transform": [ - { - "scale": 0.8, - }, - ], + "alignSelf": "center", + "height": 32, + "justifyContent": "center", + "marginBottom": 4, + "marginHorizontal": 12, + "marginTop": 0, + "width": 32, } } - /> - + + + + + - - + + inbox + + + - inbox - + + inbox + + - + + + Route: 2 + + + + - Route: 2 - + [ + { + "fontFamily": "System", + "fontSize": 12, + "fontWeight": "500", + "letterSpacing": 0.5, + "lineHeight": 16, + }, + [ + { + "backgroundColor": "transparent", + "fontSize": 12, + "textAlign": "center", + }, + { + "color": "#853D4B", + "fontFamily": "System", + "fontSize": 12, + "fontWeight": "500", + "letterSpacing": 0.5, + "lineHeight": 16, + }, + ], + ], + ] + } + > + Route: 2 + + + From ee3ede571c6fedc281aa66c0c02ac0b362f1c93b Mon Sep 17 00:00:00 2001 From: Bartek Dybowski Date: Thu, 18 Jun 2026 07:25:03 +0200 Subject: [PATCH 10/15] test(navigation-bar): add characterization tests before refactor Add a -active-indicator testID and tests pinning the per-state label colors, active indicator color, and badge rendering, so the upcoming item refactor is guarded by explicit behavior rather than snapshots alone. Re #4975 --- .../NavigationBar/NavigationBar.tsx | 2 + .../__tests__/BottomNavigation.test.tsx | 57 +++++++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/src/components/NavigationBar/NavigationBar.tsx b/src/components/NavigationBar/NavigationBar.tsx index 791588b96b..d4d1695b0f 100644 --- a/src/components/NavigationBar/NavigationBar.tsx +++ b/src/components/NavigationBar/NavigationBar.tsx @@ -367,6 +367,7 @@ const NavigationBarItem = ({ > ({ { expect(layerOpacity()).toBeUndefined(); }); +it('colors the focused tab label with secondary and others with onSurfaceVariant', () => { + const navigationState = { + index: 0, + routes: [ + { key: 'a', title: 'Alpha', focusedIcon: 'magnify' }, + { key: 'b', title: 'Beta', focusedIcon: 'camera' }, + ], + }; + + const { getAllByText } = render( + + ); + + const colorsOf = (text: string) => + getAllByText(text).map( + (node) => StyleSheet.flatten(node.props.style).color + ); + + expect(colorsOf('Alpha')).toContain(getTheme().colors.secondary); + expect(colorsOf('Beta')).toContain(getTheme().colors.onSurfaceVariant); +}); + +it('renders the active indicator with the secondaryContainer color', () => { + const navigationState = { + index: 0, + routes: [ + { key: 'a', title: 'Alpha', focusedIcon: 'magnify', testID: 'tab-a' }, + { key: 'b', title: 'Beta', focusedIcon: 'camera', testID: 'tab-b' }, + ], + }; + + const { getByTestId } = render( + + ); + + expect( + StyleSheet.flatten(getByTestId('tab-a-active-indicator').props.style) + .backgroundColor + ).toBe(getTheme().colors.secondaryContainer); +}); + +it('renders a badge for routes that define one', () => { + const navigationState = { + index: 0, + routes: [ + { key: 'a', title: 'Alpha', focusedIcon: 'magnify', badge: 3 }, + { key: 'b', title: 'Beta', focusedIcon: 'camera' }, + ], + }; + + const { getByText } = render( + + ); + + expect(getByText('3')).toBeTruthy(); +}); + describe('getActiveTintColor', () => { it.each` activeColor | expected From 6937d586443c33027be75c669431c4f6f7330e47 Mon Sep 17 00:00:00 2001 From: Bartek Dybowski Date: Thu, 18 Jun 2026 07:37:27 +0200 Subject: [PATCH 11/15] refactor(navigation-bar): collapse cross-fade, dedupe item render The stacked active/inactive layers were toggled instantly (not animated), so render a single icon + label using the focused color, matching the horizontal layout. Extract shared icon/badge/label rendering across both layouts and merge the vestigial v3* duplicate styles. Behavior-preserving; characterization tests unchanged. Re #4975 --- .../NavigationBar/NavigationBar.tsx | 288 +- .../BottomNavigation.test.tsx.snap | 4772 +++-------------- 2 files changed, 887 insertions(+), 4173 deletions(-) diff --git a/src/components/NavigationBar/NavigationBar.tsx b/src/components/NavigationBar/NavigationBar.tsx index d4d1695b0f..f2bc59cf00 100644 --- a/src/components/NavigationBar/NavigationBar.tsx +++ b/src/components/NavigationBar/NavigationBar.tsx @@ -11,6 +11,7 @@ import type { ColorValue, EasingFunction, StyleProp, + TextStyle, ViewStyle, } from 'react-native'; @@ -20,6 +21,7 @@ import { BAR_HEIGHT, colorRoles, ICON_LABEL_GAP, + ICON_SIZE, INDICATOR_BORDER_RADIUS, INDICATOR_HEIGHT, INDICATOR_WIDTH, @@ -301,36 +303,23 @@ const NavigationBarItem = ({ const [keyboardFocused, setKeyboardFocused] = React.useState(false); const [pressed, setPressed] = React.useState(false); - // We render the active icon and label on top of the inactive ones and toggle - // their opacity on change, so the active/inactive colors swap without - // animating color (which the native driver can't do). - const activeOpacity = focused ? 1 : 0; - const inactiveOpacity = focused ? 0 : 1; - - // Scale horizontally the active-indicator pill. The indicator is always - // mounted and its visibility is driven by `active` opacity, so it cross-fades - // between tabs instead of remounting (which breaks native-driven animations). + // The active indicator is always mounted and cross-fades via `active` opacity + // (remounting it on focus change breaks native-driven animations). In the + // stacked layout it also scales horizontally from 0.5 → 1 on selection. const outlineScale = active.interpolate({ inputRange: [0, 1], outputRange: [0.5, 1], }); - const badge = getBadge({ route }); - - const activeLabelColor = getLabelColor({ - tintColor: activeTintColor, - hasColor: Boolean(activeColor), - focused, - theme, - }); - - const inactiveLabelColor = getLabelColor({ - tintColor: inactiveTintColor, - hasColor: Boolean(inactiveColor), + const iconColor = focused ? activeTintColor : inactiveTintColor; + const labelColor = getLabelColor({ + tintColor: iconColor, + hasColor: Boolean(focused ? activeColor : inactiveColor), focused, theme, }); + const badge = getBadge({ route }); const badgeStyle = { top: typeof badge === 'boolean' ? 4 : 2, right: @@ -341,9 +330,8 @@ const NavigationBarItem = ({ const font = theme.fonts.labelMedium; - // MD3 state layer: visible on hover (8%) and focus/press (10%), shaped like - // the active-indicator pill. Active items use the on-secondary-container - // role, inactive items the on-surface-variant role. + // MD3 state layer: visible on hover (8%) and focus/press (10%). Active items + // use the on-secondary-container role, inactive the on-surface-variant role. const stateLayerRole = focused ? colorRoles.activeIcon : colorRoles.inactiveIcon; @@ -354,22 +342,72 @@ const NavigationBarItem = ({ : hovered ? getStateLayer(theme, stateLayerRole, 'hovered') : null; + const stateLayerColor = stateLayer + ? { backgroundColor: stateLayer.color, opacity: stateLayer.opacity } + : null; const itemTestID = getTestID({ route }); + const indicatorTestID = itemTestID + ? `${itemTestID}-active-indicator` + : undefined; + const stateLayerTestID = itemTestID ? `${itemTestID}-state-layer` : undefined; + // The horizontal arrangement places the label beside the icon and only // applies when labels are shown; otherwise it falls back to stacked icon-only. const horizontal = variant === 'horizontal' && labeled; + // Item pieces shared across both layouts. The active/inactive distinction is + // a plain color swap (no cross-fade), so a single icon and label suffice. + const icon = renderIcon ? ( + renderIcon({ route, focused, color: iconColor }) + ) : ( + + ); + + const tabBadge = ( + + {typeof badge === 'boolean' ? ( + + ) : ( + + {badge} + + )} + + ); + + const renderTabLabel = (labelStyle: StyleProp) => + renderLabel ? ( + renderLabel({ route, focused, color: labelColor }) + ) : ( + + {getLabelText({ route })} + + ); + const stackedContent = ( - + ({ /> - - {renderIcon ? ( - renderIcon({ route, focused: true, color: activeTintColor }) - ) : ( - - )} - - - {renderIcon ? ( - renderIcon({ route, focused: false, color: inactiveTintColor }) - ) : ( - - )} - - - {typeof badge === 'boolean' ? ( - - ) : ( - - {badge} - - )} - - + {icon} + {tabBadge} + {labeled ? ( - - - {renderLabel ? ( - renderLabel({ route, focused: true, color: activeLabelColor }) - ) : ( - - {getLabelText({ route })} - - )} - - - {renderLabel ? ( - renderLabel({ - route, - focused: false, - color: inactiveLabelColor, - }) - ) : ( - - {getLabelText({ route })} - - )} - - + + {renderTabLabel(styles.label)} + ) : null} ); @@ -484,7 +437,7 @@ const NavigationBarItem = ({ ({ ]} /> - {renderIcon ? ( - renderIcon({ - route, - focused, - color: focused ? activeTintColor : inactiveTintColor, - }) - ) : ( - - )} - - {typeof badge === 'boolean' ? ( - - ) : ( - - {badge} - - )} - + {icon} + {tabBadge} - {renderLabel ? ( - renderLabel({ - route, - focused, - color: focused ? activeLabelColor : inactiveLabelColor, - }) - ) : ( - - {getLabelText({ route })} - - )} + {renderTabLabel(styles.horizontalLabel)} ); @@ -588,7 +491,7 @@ const NavigationBarItem = ({ accessibilityLabel: getAccessibilityLabel({ route }), accessibilityRole: Platform.OS === 'ios' ? 'button' : 'tab', accessibilityState: { selected: focused }, - style: [styles.item, styles.v3Item], + style: styles.item, children: horizontal ? horizontalContent : stackedContent, }); }; @@ -951,41 +854,26 @@ const styles = StyleSheet.create({ }, 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, - }, - v3Item: { paddingVertical: 0, }, iconContainer: { - height: 24, - width: 24, - marginTop: 2, - marginHorizontal: 12, - alignSelf: 'center', - }, - v3IconContainer: { height: INDICATOR_HEIGHT, width: INDICATOR_HEIGHT, - marginBottom: ICON_LABEL_GAP, marginTop: 0, + marginBottom: ICON_LABEL_GAP, + marginHorizontal: 12, + alignSelf: 'center', justifyContent: 'center', }, iconWrapper: { ...StyleSheet.absoluteFill, - alignItems: 'center', - }, - v3IconWrapper: { top: 4, + alignItems: 'center', }, labelContainer: { height: 16, paddingBottom: 2, }, - labelWrapper: { - ...StyleSheet.absoluteFill, - }, // eslint-disable-next-line react-native/no-color-literals label: { fontSize: 12, @@ -1002,16 +890,16 @@ const styles = StyleSheet.create({ position: 'absolute', left: 0, }, - v3TouchableContainer: { + stackedContainer: { height: BAR_HEIGHT, justifyContent: 'center', }, - v3NoLabelContainer: { + noLabelContainer: { height: NO_LABEL_BAR_HEIGHT, justifyContent: 'center', alignItems: 'center', }, - outline: { + stackedIndicator: { width: INDICATOR_WIDTH, height: INDICATOR_HEIGHT, borderRadius: INDICATOR_BORDER_RADIUS, diff --git a/src/components/__tests__/__snapshots__/BottomNavigation.test.tsx.snap b/src/components/__tests__/__snapshots__/BottomNavigation.test.tsx.snap index c8b5f411fb..e2b4116595 100644 --- a/src/components/__tests__/__snapshots__/BottomNavigation.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/BottomNavigation.test.tsx.snap @@ -167,15 +167,10 @@ exports[`allows customizing Route's type via generics 1`] = ` onResponderTerminationRequest={[Function]} onStartShouldSetResponder={[Function]} style={ - [ - { - "flex": 1, - "paddingVertical": 6, - }, - { - "paddingVertical": 0, - }, - ] + { + "flex": 1, + "paddingVertical": 0, + } } > - - - - First - - - - - First - - + ], + ] + } + > + First + @@ -464,15 +373,10 @@ exports[`allows customizing Route's type via generics 1`] = ` onResponderTerminationRequest={[Function]} onStartShouldSetResponder={[Function]} style={ - [ - { - "flex": 1, - "paddingVertical": 6, - }, - { - "paddingVertical": 0, - }, - ] + { + "flex": 1, + "paddingVertical": 0, + } } > - - - - Second - - - - - Second - - + ], + ] + } + > + Second + @@ -903,15 +721,10 @@ exports[`hides labels when labeled is false 1`] = ` onResponderTerminationRequest={[Function]} onStartShouldSetResponder={[Function]} style={ - [ - { - "flex": 1, - "paddingVertical": 6, - }, - { - "paddingVertical": 0, - }, - ] + { + "flex": 1, + "paddingVertical": 0, + } } > - magnify - - - - - - camera - - - - - inbox - - - - - - magnify - - - - - Route: 0 - - - - - Route: 0 - - + Route: 0 + @@ -2119,15 +1660,10 @@ exports[`renders bottom navigation 1`] = ` onResponderTerminationRequest={[Function]} onStartShouldSetResponder={[Function]} style={ - [ - { - "flex": 1, - "paddingVertical": 6, - }, - { - "paddingVertical": 0, - }, - ] + { + "flex": 1, + "paddingVertical": 0, + } } > - - camera - - - - - - Route: 1 - - - - - Route: 1 - - + ], + ] + } + > + Route: 1 + @@ -2476,15 +1896,10 @@ exports[`renders bottom navigation 1`] = ` onResponderTerminationRequest={[Function]} onStartShouldSetResponder={[Function]} style={ - [ - { - "flex": 1, - "paddingVertical": 6, - }, - { - "paddingVertical": 0, - }, - ] + { + "flex": 1, + "paddingVertical": 0, + } } > - - inbox - - - - - - Route: 2 - - - - - Route: 2 - - + Route: 2 + @@ -2833,15 +2132,10 @@ exports[`renders bottom navigation 1`] = ` onResponderTerminationRequest={[Function]} onStartShouldSetResponder={[Function]} style={ - [ - { - "flex": 1, - "paddingVertical": 6, - }, - { - "paddingVertical": 0, - }, - ] + { + "flex": 1, + "paddingVertical": 0, + } } > - - heart - - - - - - Route: 3 - - - - - Route: 3 - - + ], + ] + } + > + Route: 3 + @@ -3190,15 +2368,10 @@ exports[`renders bottom navigation 1`] = ` onResponderTerminationRequest={[Function]} onStartShouldSetResponder={[Function]} style={ - [ - { - "flex": 1, - "paddingVertical": 6, - }, - { - "paddingVertical": 0, - }, - ] + { + "flex": 1, + "paddingVertical": 0, + } } > - - shopping-music - - - - - - Route: 4 - - - - - Route: 4 - - + Route: 4 + @@ -3821,15 +2878,10 @@ exports[`renders bottom navigation with getLazy 1`] = ` onResponderTerminationRequest={[Function]} onStartShouldSetResponder={[Function]} style={ - [ - { - "flex": 1, - "paddingVertical": 6, - }, - { - "paddingVertical": 0, - }, - ] + { + "flex": 1, + "paddingVertical": 0, + } } > - - - magnify - - - - - Route: 0 - - - - - Route: 0 - - + ], + ] + } + > + Route: 0 + @@ -4178,15 +3114,10 @@ exports[`renders bottom navigation with getLazy 1`] = ` onResponderTerminationRequest={[Function]} onStartShouldSetResponder={[Function]} style={ - [ - { - "flex": 1, - "paddingVertical": 6, - }, - { - "paddingVertical": 0, - }, - ] + { + "flex": 1, + "paddingVertical": 0, + } } > - - camera - - - - - - Route: 1 - - - - - Route: 1 - - + ], + ] + } + > + Route: 1 + @@ -4535,15 +3350,10 @@ exports[`renders bottom navigation with getLazy 1`] = ` onResponderTerminationRequest={[Function]} onStartShouldSetResponder={[Function]} style={ - [ - { - "flex": 1, - "paddingVertical": 6, - }, - { - "paddingVertical": 0, - }, - ] + { + "flex": 1, + "paddingVertical": 0, + } } > - - inbox - - - - - - Route: 2 - - - - - Route: 2 - - + ], + ] + } + > + Route: 2 + @@ -4892,15 +3586,10 @@ exports[`renders bottom navigation with getLazy 1`] = ` onResponderTerminationRequest={[Function]} onStartShouldSetResponder={[Function]} style={ - [ - { - "flex": 1, - "paddingVertical": 6, - }, - { - "paddingVertical": 0, - }, - ] + { + "flex": 1, + "paddingVertical": 0, + } } > - - heart - - - - - - Route: 3 - - - - - Route: 3 - - + ], + ] + } + > + Route: 3 + @@ -5249,15 +3822,10 @@ exports[`renders bottom navigation with getLazy 1`] = ` onResponderTerminationRequest={[Function]} onStartShouldSetResponder={[Function]} style={ - [ - { - "flex": 1, - "paddingVertical": 6, - }, - { - "paddingVertical": 0, - }, - ] + { + "flex": 1, + "paddingVertical": 0, + } } > - - shopping-music - - - - - - Route: 4 - - - - - Route: 4 - - + ], + ] + } + > + Route: 4 + @@ -5748,15 +4200,10 @@ exports[`renders bottom navigation with scene animation 1`] = ` onResponderTerminationRequest={[Function]} onStartShouldSetResponder={[Function]} style={ - [ - { - "flex": 1, - "paddingVertical": 6, - }, - { - "paddingVertical": 0, - }, - ] + { + "flex": 1, + "paddingVertical": 0, + } } > - magnify - - - - - - - Route: 0 - - - - - Route: 0 - - + ], + ] + } + > + Route: 0 + @@ -6105,15 +4436,10 @@ exports[`renders bottom navigation with scene animation 1`] = ` onResponderTerminationRequest={[Function]} onStartShouldSetResponder={[Function]} style={ - [ - { - "flex": 1, - "paddingVertical": 6, - }, - { - "paddingVertical": 0, - }, - ] + { + "flex": 1, + "paddingVertical": 0, + } } > - - camera - - - - - - Route: 1 - - - - - Route: 1 - - + ], + ] + } + > + Route: 1 + @@ -6462,15 +4672,10 @@ exports[`renders bottom navigation with scene animation 1`] = ` onResponderTerminationRequest={[Function]} onStartShouldSetResponder={[Function]} style={ - [ - { - "flex": 1, - "paddingVertical": 6, - }, - { - "paddingVertical": 0, - }, - ] + { + "flex": 1, + "paddingVertical": 0, + } } > - - - - inbox - + ] + } + /> - - - Route: 2 - - - - - Route: 2 - - + ], + ] + } + > + Route: 2 + @@ -6819,15 +4908,10 @@ exports[`renders bottom navigation with scene animation 1`] = ` onResponderTerminationRequest={[Function]} onStartShouldSetResponder={[Function]} style={ - [ - { - "flex": 1, - "paddingVertical": 6, - }, - { - "paddingVertical": 0, - }, - ] + { + "flex": 1, + "paddingVertical": 0, + } } > - - heart - - - - - - Route: 3 - - - - - Route: 3 - - + ], + ] + } + > + Route: 3 + @@ -7176,15 +5144,10 @@ exports[`renders bottom navigation with scene animation 1`] = ` onResponderTerminationRequest={[Function]} onStartShouldSetResponder={[Function]} style={ - [ - { - "flex": 1, - "paddingVertical": 6, - }, - { - "paddingVertical": 0, - }, - ] + { + "flex": 1, + "paddingVertical": 0, + } } > - - - + } + /> - - shopping-music - + /> - - - Route: 4 - - - - - Route: 4 - - + ], + ] + } + > + Route: 4 + @@ -7675,15 +5522,10 @@ exports[`renders bottom navigation with three tabs 1`] = ` onResponderTerminationRequest={[Function]} onStartShouldSetResponder={[Function]} style={ - [ - { - "flex": 1, - "paddingVertical": 6, - }, - { - "paddingVertical": 0, - }, - ] + { + "flex": 1, + "paddingVertical": 0, + } } > - - - magnify - - - - - Route: 0 - - - - - Route: 0 - - + "lineHeight": 16, + }, + ], + ], + ] + } + > + Route: 0 + @@ -8032,15 +5758,10 @@ exports[`renders bottom navigation with three tabs 1`] = ` onResponderTerminationRequest={[Function]} onStartShouldSetResponder={[Function]} style={ - [ - { - "flex": 1, - "paddingVertical": 6, - }, - { - "paddingVertical": 0, - }, - ] + { + "flex": 1, + "paddingVertical": 0, + } } > - - camera - - - - - - Route: 1 - - - - - Route: 1 - - + ], + ] + } + > + Route: 1 + @@ -8389,15 +5994,10 @@ exports[`renders bottom navigation with three tabs 1`] = ` onResponderTerminationRequest={[Function]} onStartShouldSetResponder={[Function]} style={ - [ - { - "flex": 1, - "paddingVertical": 6, - }, - { - "paddingVertical": 0, - }, - ] + { + "flex": 1, + "paddingVertical": 0, + } } > - - inbox - - - - - - - - Route: 2 - - - + - + - Route: 2 - - + ], + ] + } + > + Route: 2 + @@ -8888,15 +6372,10 @@ exports[`renders custom icon and label 1`] = ` onResponderTerminationRequest={[Function]} onStartShouldSetResponder={[Function]} style={ - [ - { - "flex": 1, - "paddingVertical": 6, - }, - { - "paddingVertical": 0, - }, - ] + { + "flex": 1, + "paddingVertical": 0, + } } > - - - - Route: 0 - - - - - Route: 0 - - + Route: 0 + @@ -9114,15 +6542,10 @@ exports[`renders custom icon and label 1`] = ` onResponderTerminationRequest={[Function]} onStartShouldSetResponder={[Function]} style={ - [ - { - "flex": 1, - "paddingVertical": 6, - }, - { - "paddingVertical": 0, - }, - ] + { + "flex": 1, + "paddingVertical": 0, + } } > - - - - Route: 1 - - - - - Route: 1 - - + Route: 1 + @@ -9340,15 +6712,10 @@ exports[`renders custom icon and label 1`] = ` onResponderTerminationRequest={[Function]} onStartShouldSetResponder={[Function]} style={ - [ - { - "flex": 1, - "paddingVertical": 6, - }, - { - "paddingVertical": 0, - }, - ] + { + "flex": 1, + "paddingVertical": 0, + } } > - - - - Route: 2 - - - - - Route: 2 - - + Route: 2 + @@ -9566,15 +6882,10 @@ exports[`renders custom icon and label 1`] = ` onResponderTerminationRequest={[Function]} onStartShouldSetResponder={[Function]} style={ - [ - { - "flex": 1, - "paddingVertical": 6, - }, - { - "paddingVertical": 0, - }, - ] + { + "flex": 1, + "paddingVertical": 0, + } } > - - - - Route: 3 - - - - - Route: 3 - - + Route: 3 + @@ -9792,15 +7052,10 @@ exports[`renders custom icon and label 1`] = ` onResponderTerminationRequest={[Function]} onStartShouldSetResponder={[Function]} style={ - [ - { - "flex": 1, - "paddingVertical": 6, - }, - { - "paddingVertical": 0, - }, - ] + { + "flex": 1, + "paddingVertical": 0, + } } > - - - - Route: 4 - - - - - Route: 4 - - + Route: 4 + @@ -10091,15 +7295,10 @@ exports[`renders the horizontal (flexible) variant 1`] = ` onResponderTerminationRequest={[Function]} onStartShouldSetResponder={[Function]} style={ - [ - { - "flex": 1, - "paddingVertical": 6, - }, - { - "paddingVertical": 0, - }, - ] + { + "flex": 1, + "paddingVertical": 0, + } } > - - - magnify - - - - - Route: 0 - - - - - Route: 0 - - + ], + ] + } + > + Route: 0 + @@ -11222,15 +8290,10 @@ exports[`renders with custom active and inactive colors 1`] = ` onResponderTerminationRequest={[Function]} onStartShouldSetResponder={[Function]} style={ - [ - { - "flex": 1, - "paddingVertical": 6, - }, - { - "paddingVertical": 0, - }, - ] + { + "flex": 1, + "paddingVertical": 0, + } } > - - - + } + /> - - camera - + /> - - - Route: 1 - - - - - Route: 1 - - + ], + ] + } + > + Route: 1 + @@ -11579,15 +8526,10 @@ exports[`renders with custom active and inactive colors 1`] = ` onResponderTerminationRequest={[Function]} onStartShouldSetResponder={[Function]} style={ - [ - { - "flex": 1, - "paddingVertical": 6, - }, - { - "paddingVertical": 0, - }, - ] + { + "flex": 1, + "paddingVertical": 0, + } } > - - inbox - - - - - - Route: 2 - - - - - Route: 2 - - + ], + ] + } + > + Route: 2 + From b102488a1d0d69073dcd40bb5309f79025b5c2b3 Mon Sep 17 00:00:00 2001 From: Bartek Dybowski Date: Thu, 18 Jun 2026 07:51:48 +0200 Subject: [PATCH 12/15] refactor(navigation-bar): unify color source on colorRoles Move the color resolvers from BottomNavigation/utils into the NavigationBar module (reversing the cross-module dependency) and express them via colorRoles. Use colors[colorRoles.activeIndicator] for the indicator background instead of a hardcoded role. Same resolved colors; no behavior change. Re #4975 --- src/components/NavigationBar/NavigationBar.tsx | 14 +++++++------- .../{BottomNavigation => NavigationBar}/utils.ts | 11 ++++------- src/components/__tests__/BottomNavigation.test.tsx | 10 +++++----- 3 files changed, 16 insertions(+), 19 deletions(-) rename src/components/{BottomNavigation => NavigationBar}/utils.ts (74%) diff --git a/src/components/NavigationBar/NavigationBar.tsx b/src/components/NavigationBar/NavigationBar.tsx index f2bc59cf00..724e73f1c3 100644 --- a/src/components/NavigationBar/NavigationBar.tsx +++ b/src/components/NavigationBar/NavigationBar.tsx @@ -29,6 +29,11 @@ import { MIN_TAB_WIDTH, NO_LABEL_BAR_HEIGHT, } from './tokens'; +import { + getActiveTintColor, + getInactiveTintColor, + getLabelColor, +} from './utils'; import { useInternalTheme } from '../../core/theming'; import { toRawSpring } from '../../theme/tokens/sys/motion'; import { getStateLayer } from '../../theme/utils/state'; @@ -38,11 +43,6 @@ import useAnimatedValueArray from '../../utils/useAnimatedValueArray'; import useIsKeyboardShown from '../../utils/useIsKeyboardShown'; import useLayout from '../../utils/useLayout'; import Badge from '../Badge'; -import { - getActiveTintColor, - getInactiveTintColor, - getLabelColor, -} from '../BottomNavigation/utils'; import Icon from '../Icon'; import type { IconSource } from '../Icon'; import Surface from '../Surface'; @@ -411,7 +411,7 @@ const NavigationBarItem = ({ { opacity: active, transform: [{ scaleX: outlineScale }], - backgroundColor: colors.secondaryContainer, + backgroundColor: colors[colorRoles.activeIndicator], }, activeIndicatorStyle, ]} @@ -442,7 +442,7 @@ const NavigationBarItem = ({ StyleSheet.absoluteFill, styles.horizontalIndicator, { - backgroundColor: colors.secondaryContainer, + backgroundColor: colors[colorRoles.activeIndicator], opacity: active, transform: [ { diff --git a/src/components/BottomNavigation/utils.ts b/src/components/NavigationBar/utils.ts similarity index 74% rename from src/components/BottomNavigation/utils.ts rename to src/components/NavigationBar/utils.ts index 60f0545d69..3e10c84f5e 100644 --- a/src/components/BottomNavigation/utils.ts +++ b/src/components/NavigationBar/utils.ts @@ -1,5 +1,6 @@ import type { ColorValue } from 'react-native'; +import { colorRoles } from './tokens'; import type { InternalTheme, Theme } from '../../types'; export const getActiveTintColor = ({ @@ -13,7 +14,7 @@ export const getActiveTintColor = ({ return activeColor; } - return (theme as Theme).colors.onSecondaryContainer; + return (theme as Theme).colors[colorRoles.activeIcon]; }; export const getInactiveTintColor = ({ @@ -27,7 +28,7 @@ export const getInactiveTintColor = ({ return inactiveColor; } - return (theme as Theme).colors.onSurfaceVariant; + return (theme as Theme).colors[colorRoles.inactiveIcon]; }; export const getLabelColor = ({ @@ -46,9 +47,5 @@ export const getLabelColor = ({ return tintColor; } - if (focused) { - // M3 active label color is `secondary` (changed from `onSurface`). - return colors.secondary; - } - return colors.onSurfaceVariant; + return colors[focused ? colorRoles.activeLabel : colorRoles.inactiveLabel]; }; diff --git a/src/components/__tests__/BottomNavigation.test.tsx b/src/components/__tests__/BottomNavigation.test.tsx index a5ba18ad89..c8205c97bc 100644 --- a/src/components/__tests__/BottomNavigation.test.tsx +++ b/src/components/__tests__/BottomNavigation.test.tsx @@ -8,13 +8,13 @@ import { render } from '../../test-utils'; import { Palette } from '../../theme/tokens'; import BottomNavigation from '../BottomNavigation/BottomNavigation'; import BottomNavigationRouteScreen from '../BottomNavigation/BottomNavigationRouteScreen'; +import Icon from '../Icon'; +import NavigationBar from '../NavigationBar/NavigationBar'; import { getActiveTintColor, getInactiveTintColor, getLabelColor, -} from '../BottomNavigation/utils'; -import Icon from '../Icon'; -import NavigationBar from '../NavigationBar/NavigationBar'; +} from '../NavigationBar/utils'; const styles = StyleSheet.create({ backgroundColor: { @@ -134,8 +134,8 @@ it('calls onIndexChange', () => { renderScene={({ route }) => route.title} /> ); - // Both the active and inactive labels render per tab, so target the last - // match (the label inside the tab) to fire the tab press. + // The active scene also renders the route title, so target the last match + // (the tab label) to fire the tab press. // pressing same index as active navigation state does not call onIndexChange fireEvent(tree.getAllByText('Route: 0').at(-1)!, 'onPress'); expect(onIndexChange).not.toHaveBeenCalled(); From a5a012a83d444a227335b1d3a8f05eb364531f54 Mon Sep 17 00:00:00 2001 From: Bartek Dybowski Date: Thu, 18 Jun 2026 08:15:18 +0200 Subject: [PATCH 13/15] docs(navigation-bar): register NavigationBar page, fix docs build Register NavigationBar in the docs component map and drop the deprecated BottomNavigationBar entry (now a re-export that react-docgen can't parse). Point the BottomNavigation JSDoc link at NavigationBar. Re #4975 --- docs/docusaurus.config.js | 4 +++- src/components/BottomNavigation/BottomNavigation.tsx | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/docusaurus.config.js b/docs/docusaurus.config.js index ee54135042..12575d429e 100644 --- a/docs/docusaurus.config.js +++ b/docs/docusaurus.config.js @@ -85,7 +85,6 @@ const config = { Banner: 'Banner', BottomNavigation: { BottomNavigation: 'BottomNavigation/BottomNavigation', - BottomNavigationBar: 'BottomNavigation/BottomNavigationBar', }, Button: { Button: 'Button/Button', @@ -148,6 +147,9 @@ const config = { MenuItem: 'Menu/MenuItem', }, Modal: 'Modal', + NavigationBar: { + NavigationBar: 'NavigationBar/NavigationBar', + }, Portal: { Portal: 'Portal/Portal', PortalHost: 'Portal/PortalHost', diff --git a/src/components/BottomNavigation/BottomNavigation.tsx b/src/components/BottomNavigation/BottomNavigation.tsx index f68705f604..417d5b2abf 100644 --- a/src/components/BottomNavigation/BottomNavigation.tsx +++ b/src/components/BottomNavigation/BottomNavigation.tsx @@ -260,7 +260,7 @@ const SceneComponent = React.memo(({ component, ...rest }: any) => /** * 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). + * It is primarily designed for use on mobile. If you want to use the navigation bar only see [`NavigationBar`](../NavigationBar). * * 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. From 13d34e9f5871bb4c5dd12ac4ba08a9bbb4c0a184 Mon Sep 17 00:00:00 2001 From: Bartek Dybowski Date: Thu, 18 Jun 2026 15:57:18 +0200 Subject: [PATCH 14/15] refactor(navigation-bar): drop deprecations per review - remove deprecated BottomNavigation.Bar alias + BottomNavigationBar module - remove dead no-op shifting prop from BottomNavigation - BottomNavigation renders NavigationBar directly - migrate react-navigation example to top-level NavigationBar Addresses satya164: no deprecations, remove old code. --- .../Examples/BottomNavigationBarExample.tsx | 6 ++--- .../BottomNavigation/BottomNavigation.tsx | 23 ++++--------------- .../BottomNavigation/BottomNavigationBar.tsx | 8 ------- .../__tests__/BottomNavigation.test.tsx | 22 +----------------- 4 files changed, 8 insertions(+), 51 deletions(-) delete mode 100644 src/components/BottomNavigation/BottomNavigationBar.tsx diff --git a/example/src/Examples/BottomNavigationBarExample.tsx b/example/src/Examples/BottomNavigationBarExample.tsx index 9fbddf7f23..4b4d98d94d 100644 --- a/example/src/Examples/BottomNavigationBarExample.tsx +++ b/example/src/Examples/BottomNavigationBarExample.tsx @@ -11,7 +11,7 @@ import { SFSymbol, MaterialSymbol, } from '@react-navigation/native'; -import { Text, BottomNavigation } from 'react-native-paper'; +import { Text, NavigationBar } from 'react-native-paper'; function HomeScreen() { return ( @@ -34,7 +34,7 @@ const BottomNavigationBarExample = createBottomTabNavigator({ headerShown: false, }, tabBar: ({ navigation, state, descriptors }) => ( - { const event = navigation.emit({ @@ -119,7 +119,7 @@ const BottomNavigationBarExample = createBottomTabNavigator({ }); export default Object.assign(BottomNavigationBarExample, { - title: 'Bottom Navigation Bar', + title: 'Navigation Bar (React Navigation)', }); const styles = StyleSheet.create({ diff --git a/src/components/BottomNavigation/BottomNavigation.tsx b/src/components/BottomNavigation/BottomNavigation.tsx index 417d5b2abf..fe489a7054 100644 --- a/src/components/BottomNavigation/BottomNavigation.tsx +++ b/src/components/BottomNavigation/BottomNavigation.tsx @@ -9,12 +9,12 @@ import type { 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 NavigationBar from '../NavigationBar/NavigationBar'; import type { Props as TouchableRippleProps } from '../TouchableRipple/TouchableRipple'; export type BaseRoute = { @@ -48,13 +48,6 @@ type TouchableProps = TouchableRippleProps & { }; export type Props = { - /** - * @deprecated The `shifting` style is a Material Design 2 pattern that is not - * part of Material Design 3 and no longer has any effect. It will be removed - * in a future version. To animate scene transitions, use `sceneAnimationType` - * and `sceneAnimationEnabled` instead. - */ - shifting?: boolean; /** * Whether to show labels in tabs. When `false`, only icons will be displayed. */ @@ -199,8 +192,8 @@ export type Props = { */ 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. + * Whether animation is enabled for scene transitions. + * By default, the scenes cross-fade during tab change. * Specify `sceneAnimationEnabled` as `false` to disable the animation. */ sceneAnimationEnabled?: boolean; @@ -546,7 +539,7 @@ const BottomNavigation = ({ ); })} - (scenes: { ); }; -/** - * @deprecated Use the top-level `NavigationBar` export instead. - * `BottomNavigation.Bar` is the M3 "original" navigation bar, superseded by the - * flexible `NavigationBar`. Kept as an alias for backwards compatibility. - */ -// @component ./BottomNavigationBar.tsx -BottomNavigation.Bar = BottomNavigationBar; - export default BottomNavigation; const styles = StyleSheet.create({ diff --git a/src/components/BottomNavigation/BottomNavigationBar.tsx b/src/components/BottomNavigation/BottomNavigationBar.tsx deleted file mode 100644 index f1b16dc4f2..0000000000 --- a/src/components/BottomNavigation/BottomNavigationBar.tsx +++ /dev/null @@ -1,8 +0,0 @@ -/** - * @deprecated Use `NavigationBar` instead. `BottomNavigation.Bar` is the M3 - * "original" navigation bar and has been superseded by the flexible - * `NavigationBar`. This module re-exports `NavigationBar` for backwards - * compatibility and will be removed in a future major version. - */ -export { default } from '../NavigationBar/NavigationBar'; -export type { Props, BaseRoute } from '../NavigationBar/NavigationBar'; diff --git a/src/components/__tests__/BottomNavigation.test.tsx b/src/components/__tests__/BottomNavigation.test.tsx index c8205c97bc..87450d1abc 100644 --- a/src/components/__tests__/BottomNavigation.test.tsx +++ b/src/components/__tests__/BottomNavigation.test.tsx @@ -208,26 +208,6 @@ it('renders bottom navigation with three tabs', () => { expect(tree).toMatchSnapshot(); }); -it('does not warn or crash when the deprecated shifting prop is passed with fewer than 2 tabs', () => { - const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); - - const { getByTestId } = render( - route.title} - testID="bottom-navigation" - /> - ); - - // `shifting` is a deprecated no-op, so it no longer warns about tab count. - expect(getByTestId('bottom-navigation-bar')).toBeDefined(); - expect(warn).not.toHaveBeenCalled(); - - jest.restoreAllMocks(); -}); - it('renders custom icon and label', () => { const tree = render( { /> ); - // Each tab renders an active and inactive label layer, so both match. + // Each tab renders a single label (no cross-fade layers). expect(getAllByText('Alpha').length).toBeGreaterThan(0); expect(getAllByText('Beta').length).toBeGreaterThan(0); }); From c6bc35f36632d16eb5527f66a4cf1b36f55c0c66 Mon Sep 17 00:00:00 2001 From: Bartek Dybowski Date: Thu, 18 Jun 2026 16:20:42 +0200 Subject: [PATCH 15/15] refactor(navigation-bar): migrate motion to reanimated - replace RN Animated with react-native-reanimated (shared values + worklets) - each item springs its own selection progress from its focused prop, removing the central tabsAnims array, animateToIndex, and the native-driver reset workaround - keyboard slide uses withTiming + useAnimatedStyle on a wrapper view - honor reduce motion via ReduceMotion (drops theme.animation.scale gate) - remove customizable animationEasing prop (+ its BottomNavigation passthrough) Addresses satya164: move to reanimated, drop animationEasing. Also Copilot: map-for-side-effects removed with animateToIndex. --- .../BottomNavigation/BottomNavigation.tsx | 1 - .../NavigationBar/NavigationBar.tsx | 329 +- .../BottomNavigation.test.tsx.snap | 11736 ++++++++-------- 3 files changed, 6174 insertions(+), 5892 deletions(-) diff --git a/src/components/BottomNavigation/BottomNavigation.tsx b/src/components/BottomNavigation/BottomNavigation.tsx index fe489a7054..08313484e9 100644 --- a/src/components/BottomNavigation/BottomNavigation.tsx +++ b/src/components/BottomNavigation/BottomNavigation.tsx @@ -554,7 +554,6 @@ const BottomNavigation = ({ style={barStyle} activeIndicatorStyle={activeIndicatorStyle} labeled={labeled} - animationEasing={sceneAnimationEasing} onTabPress={handleTabPress} onTabLongPress={onTabLongPress} safeAreaInsets={safeAreaInsets} diff --git a/src/components/NavigationBar/NavigationBar.tsx b/src/components/NavigationBar/NavigationBar.tsx index 724e73f1c3..2ff7379aa4 100644 --- a/src/components/NavigationBar/NavigationBar.tsx +++ b/src/components/NavigationBar/NavigationBar.tsx @@ -1,20 +1,22 @@ import * as React from 'react'; -import { - Animated, - Easing, - Platform, - StyleSheet, - Pressable, - View, -} from 'react-native'; +import { Platform, StyleSheet, Pressable, View } from 'react-native'; import type { + Animated as RNAnimated, ColorValue, - EasingFunction, StyleProp, TextStyle, ViewStyle, } from 'react-native'; +import Animated, { + Easing, + ReduceMotion, + runOnJS, + useAnimatedStyle, + useSharedValue, + withSpring, + withTiming, +} from 'react-native-reanimated'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { @@ -35,11 +37,10 @@ import { getLabelColor, } from './utils'; import { useInternalTheme } from '../../core/theming'; +import { useReduceMotion } from '../../theme/accessibility/ReduceMotionContext'; import { toRawSpring } from '../../theme/tokens/sys/motion'; import { getStateLayer } from '../../theme/utils/state'; import type { Theme, ThemeProp } from '../../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'; @@ -189,10 +190,6 @@ export type Props = { * 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`. @@ -212,7 +209,7 @@ export type Props = { * Specifies the largest possible scale a label font can reach. */ labelMaxFontSizeMultiplier?: number; - style?: Animated.WithAnimatedValue>; + style?: RNAnimated.WithAnimatedValue>; activeIndicatorStyle?: StyleProp; /** * @optional @@ -253,7 +250,6 @@ const Touchable = ({ type ItemProps = { route: Route; focused: boolean; - active: Animated.Value; labeled: boolean; variant: 'stacked' | 'horizontal'; activeTintColor: ColorValue; @@ -277,7 +273,6 @@ type ItemProps = { const NavigationBarItem = ({ route, focused, - active, labeled, variant, activeTintColor, @@ -303,13 +298,39 @@ const NavigationBarItem = ({ const [keyboardFocused, setKeyboardFocused] = React.useState(false); const [pressed, setPressed] = React.useState(false); - // The active indicator is always mounted and cross-fades via `active` opacity - // (remounting it on focus change breaks native-driven animations). In the - // stacked layout it also scales horizontally from 0.5 → 1 on selection. - const outlineScale = active.interpolate({ - inputRange: [0, 1], - outputRange: [0.5, 1], - }); + const reduceMotion = useReduceMotion(); + const reanimatedReduceMotion = reduceMotion + ? ReduceMotion.Always + : ReduceMotion.Never; + + // Selection progress for the active indicator: 1 when focused, 0 otherwise. + // Each item springs its own progress from its `focused` prop, so there's no + // shared animation array to keep in sync. + const progress = useSharedValue(focused ? 1 : 0); + const isFirstRender = React.useRef(true); + + React.useEffect(() => { + if (isFirstRender.current) { + isFirstRender.current = false; + return; + } + progress.value = withSpring(focused ? 1 : 0, { + ...toRawSpring(theme.motion.spring.fast.spatial), + reduceMotion: reanimatedReduceMotion, + }); + }, [focused, progress, theme.motion, reanimatedReduceMotion]); + + // The active indicator is always mounted and cross-fades via opacity (the + // stacked layout also scales it horizontally 0.5 → 1, the horizontal layout + // scales it 0.8 → 1). + const stackedIndicatorAnimatedStyle = useAnimatedStyle(() => ({ + opacity: progress.value, + transform: [{ scaleX: 0.5 + progress.value * 0.5 }], + })); + const horizontalIndicatorAnimatedStyle = useAnimatedStyle(() => ({ + opacity: progress.value, + transform: [{ scale: 0.8 + progress.value * 0.2 }], + })); const iconColor = focused ? activeTintColor : inactiveTintColor; const labelColor = getLabelColor({ @@ -408,11 +429,8 @@ const NavigationBarItem = ({ testID={indicatorTestID} style={[ styles.stackedIndicator, - { - opacity: active, - transform: [{ scaleX: outlineScale }], - backgroundColor: colors[colorRoles.activeIndicator], - }, + { backgroundColor: colors[colorRoles.activeIndicator] }, + stackedIndicatorAnimatedStyle, activeIndicatorStyle, ]} /> @@ -441,18 +459,8 @@ const NavigationBarItem = ({ style={[ StyleSheet.absoluteFill, styles.horizontalIndicator, - { - backgroundColor: colors[colorRoles.activeIndicator], - opacity: active, - transform: [ - { - scale: active.interpolate({ - inputRange: [0, 1], - outputRange: [0.8, 1], - }), - }, - ], - }, + { backgroundColor: colors[colorRoles.activeIndicator] }, + horizontalIndicatorAnimatedStyle, activeIndicatorStyle, ]} /> @@ -500,14 +508,8 @@ const NavigationBarItem = ({ * The Material Design 3 flexible navigation bar. It can easily be integrated * with [React Navigation's Bottom Tabs Navigator](https://reactnavigation.org/docs/bottom-tab-navigator/). * - * The flexible navigation bar replaces the original (now deprecated) navigation - * bar exposed as `BottomNavigation.Bar`. Set the `variant` prop to `'horizontal'` - * to lay items out horizontally (icon beside label) in medium-width windows. - * - * Migrating from `BottomNavigation.Bar`: it is deprecated in favor of - * `NavigationBar`. The Material Design 2 `shifting` prop has been removed (it - * has no MD3 equivalent), tab interactions now show MD3 state layers instead of - * suppressing feedback, and the bar height follows the 64dp spec. + * Set the `variant` prop to `'horizontal'` to lay items out horizontally + * (icon beside label) in medium-width windows. * * ## Usage * ### without React Navigation @@ -588,7 +590,6 @@ const NavigationBar = ({ activeIndicatorStyle, labeled = true, variant = 'stacked', - animationEasing, onTabPress, onTabLongPress, safeAreaInsets, @@ -600,23 +601,17 @@ const NavigationBar = ({ const theme = useInternalTheme(themeOverrides); const { colors, motion } = theme as Theme; const { bottom, left, right } = useSafeAreaInsets(); - const { scale } = theme.animation; const compact = compactProp ?? false; - /** - * Visibility of the navigation bar, visible state is 1 and invisible is 0. - */ - const visibleAnim = useAnimatedValue(1); + const reduceMotion = useReduceMotion(); + const reanimatedReduceMotion = reduceMotion + ? ReduceMotion.Always + : ReduceMotion.Never; /** - * Active state of individual tab items, active state is 1 and inactive state is 0. + * Visibility of the navigation bar, visible state is 1 and invisible is 0. */ - const tabsAnims = useAnimatedValueArray( - navigationState.routes.map( - // focused === 1, unfocused === 0 - (_, i) => (i === navigationState.index ? 1 : 0) - ) - ); + const visible = useSharedValue(1); /** * Layout of the navigation bar. @@ -630,77 +625,41 @@ const NavigationBar = ({ const handleKeyboardShow = React.useCallback(() => { setKeyboardVisible(true); - Animated.timing(visibleAnim, { - toValue: 0, + visible.value = withTiming(0, { // The bar slides out, so accelerate (exit). - duration: motion.duration.short3 * scale, + duration: motion.duration.short3, easing: Easing.bezier(...motion.easing.standardAccelerate), - useNativeDriver: true, - }).start(); - }, [motion, scale, visibleAnim]); + reduceMotion: reanimatedReduceMotion, + }); + }, [motion, reanimatedReduceMotion, visible]); const handleKeyboardHide = React.useCallback(() => { - Animated.timing(visibleAnim, { - toValue: 1, - // The bar slides back in, so decelerate (enter). - duration: motion.duration.short2 * scale, - easing: Easing.bezier(...motion.easing.standardDecelerate), - useNativeDriver: true, - }).start(() => { - setKeyboardVisible(false); - }); - }, [motion, scale, visibleAnim]); - - const animateToIndex = React.useCallback( - (index: number) => { - // When animations are disabled (e.g. reduce motion), jump to the value. - if (scale === 0) { - tabsAnims.forEach((tab, i) => tab.setValue(i === index ? 1 : 0)); - return; + visible.value = withTiming( + 1, + { + // The bar slides back in, so decelerate (enter). + duration: motion.duration.short2, + easing: Easing.bezier(...motion.easing.standardDecelerate), + reduceMotion: reanimatedReduceMotion, + }, + (finished) => { + if (finished) { + runOnJS(setKeyboardVisible)(false); + } } + ); + }, [motion, reanimatedReduceMotion, visible]); - Animated.parallel( - navigationState.routes.map((_, i) => { - const toValue = i === index ? 1 : 0; - // Spring the active indicator for the M3-Expressive selection motion. - // A custom `animationEasing` opts back into timed (eased) movement. - return animationEasing - ? Animated.timing(tabsAnims[i], { - toValue, - duration: motion.duration.short4 * scale, - easing: animationEasing, - useNativeDriver: true, - }) - : Animated.spring(tabsAnims[i], { - toValue, - ...toRawSpring(motion.spring.fast.spatial), - useNativeDriver: true, - }); - }) - ).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, motion] - ); - - 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 - }, []); + // Slide the bar down by its own height when the keyboard hides it. + const barAnimatedStyle = useAnimatedStyle(() => ({ + transform: [{ translateY: (1 - visible.value) * layout.height }], + })); useIsKeyboardShown({ onShow: handleKeyboardShow, onHide: handleKeyboardHide, }); - React.useEffect(() => { - animateToIndex(navigationState.index); - }, [navigationState.index, animateToIndex]); - const eventForIndex = (index: number) => { const event = { route: navigationState.routes[index], @@ -743,28 +702,13 @@ const NavigationBar = ({ }; return ( - + + ); }; @@ -840,6 +784,9 @@ const styles = StyleSheet.create({ right: 0, bottom: 0, }, + absolute: { + position: 'absolute', + }, barContent: { alignItems: 'center', overflow: 'hidden', diff --git a/src/components/__tests__/__snapshots__/BottomNavigation.test.tsx.snap b/src/components/__tests__/__snapshots__/BottomNavigation.test.tsx.snap index e2b4116595..5b8c5cbaeb 100644 --- a/src/components/__tests__/__snapshots__/BottomNavigation.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/BottomNavigation.test.tsx.snap @@ -71,32 +71,25 @@ exports[`allows customizing Route's type via generics 1`] = ` - @@ -231,205 +212,211 @@ exports[`allows customizing Route's type via generics 1`] = ` style={ [ { + "alignSelf": "center", "borderRadius": 16, "height": 32, "width": 64, }, - null, + { + "backgroundColor": "rgba(232, 222, 248, 1)", + }, + { + "opacity": 1, + "transform": [ + { + "scaleX": 1, + }, + ], + }, + undefined, ] } /> - - - - + + + + + + - - - + - First - + ] + } + > + First + + - - - - @@ -437,113 +424,152 @@ exports[`allows customizing Route's type via generics 1`] = ` style={ [ { + "alignSelf": "center", "borderRadius": 16, "height": 32, "width": 64, }, - null, - ] - } - /> - - - + - + + + + + + - - - + - Second - + ] + } + > + Second + + @@ -625,32 +651,25 @@ exports[`hides labels when labeled is false 1`] = ` - @@ -786,186 +793,192 @@ exports[`hides labels when labeled is false 1`] = ` style={ [ { + "alignSelf": "center", "borderRadius": 16, "height": 32, "width": 64, }, - null, + { + "backgroundColor": "rgba(232, 222, 248, 1)", + }, + { + "opacity": 1, + "transform": [ + { + "scaleX": 1, + }, + ], + }, + undefined, ] } /> - - - + - magnify - - - + + - + > + + magnify + + + + + - - - - @@ -973,186 +986,192 @@ exports[`hides labels when labeled is false 1`] = ` style={ [ { + "alignSelf": "center", "borderRadius": 16, "height": 32, "width": 64, }, - null, + { + "backgroundColor": "rgba(232, 222, 248, 1)", + }, + { + "opacity": 0, + "transform": [ + { + "scaleX": 0.5, + }, + ], + }, + undefined, ] } /> - - - + - camera - - - + + - + > + + camera + + + + + - - - - @@ -1160,92 +1179,131 @@ exports[`hides labels when labeled is false 1`] = ` style={ [ { + "alignSelf": "center", "borderRadius": 16, "height": 32, "width": 64, }, - null, + { + "backgroundColor": "rgba(232, 222, 248, 1)", + }, + { + "opacity": 0, + "transform": [ + { + "scaleX": 0.5, + }, + ], + }, + undefined, ] } /> - - - + - inbox - - - + + - + > + + inbox + + + + + @@ -1328,32 +1386,25 @@ exports[`renders bottom navigation 1`] = ` - @@ -1488,235 +1527,483 @@ exports[`renders bottom navigation 1`] = ` style={ [ { + "alignSelf": "center", "borderRadius": 16, "height": 32, "width": 64, }, - null, + { + "backgroundColor": "rgba(232, 222, 248, 1)", + }, + { + "opacity": 1, + "transform": [ + { + "scaleX": 1, + }, + ], + }, + undefined, ] } /> + + + + + + magnify + + + + + - magnify + Route: 0 - - - + + - + + + + + + + camera + + + + + + + + - Route: 0 - + ] + } + > + Route: 1 + + - - - - @@ -1724,471 +2011,241 @@ exports[`renders bottom navigation 1`] = ` style={ [ { + "alignSelf": "center", "borderRadius": 16, "height": 32, "width": 64, }, - null, + { + "backgroundColor": "rgba(232, 222, 248, 1)", + }, + { + "opacity": 0, + "transform": [ + { + "scaleX": 0.5, + }, + ], + }, + undefined, ] } /> + + + + + + inbox + + + + + - camera - - - - - - - - - Route: 1 - - - - - - - - - - - - - - inbox - - - - - - - - - Route: 2 - - - - - + > + Route: 2 + + + + - @@ -2196,235 +2253,241 @@ exports[`renders bottom navigation 1`] = ` style={ [ { + "alignSelf": "center", "borderRadius": 16, "height": 32, "width": 64, }, - null, + { + "backgroundColor": "rgba(232, 222, 248, 1)", + }, + { + "opacity": 0, + "transform": [ + { + "scaleX": 0.5, + }, + ], + }, + undefined, ] } /> - - - + + + + + heart + + + - heart - + + - - - - - Route: 3 - + ] + } + > + Route: 3 + + - - - - @@ -2432,143 +2495,182 @@ exports[`renders bottom navigation 1`] = ` style={ [ { - "borderRadius": 16, - "height": 32, - "width": 64, + "alignSelf": "center", + "borderRadius": 16, + "height": 32, + "width": 64, + }, + { + "backgroundColor": "rgba(232, 222, 248, 1)", + }, + { + "opacity": 0, + "transform": [ + { + "scaleX": 0.5, + }, + ], + }, + undefined, + ] + } + /> + + + + + + shopping-music + + + + > + + - shopping-music - - - - - - - - - Route: 4 - + ] + } + > + Route: 4 + + @@ -2782,32 +2884,25 @@ exports[`renders bottom navigation with getLazy 1`] = ` - @@ -2942,235 +3025,241 @@ exports[`renders bottom navigation with getLazy 1`] = ` style={ [ { + "alignSelf": "center", "borderRadius": 16, "height": 32, "width": 64, }, - null, + { + "backgroundColor": "rgba(232, 222, 248, 1)", + }, + { + "opacity": 1, + "transform": [ + { + "scaleX": 1, + }, + ], + }, + undefined, ] } /> - - - + + + + + magnify + + + - magnify - + + - - - - - Route: 0 - + ] + } + > + Route: 0 + + - - - - @@ -3178,235 +3267,241 @@ exports[`renders bottom navigation with getLazy 1`] = ` style={ [ { + "alignSelf": "center", "borderRadius": 16, "height": 32, "width": 64, }, - null, + { + "backgroundColor": "rgba(232, 222, 248, 1)", + }, + { + "opacity": 0, + "transform": [ + { + "scaleX": 0.5, + }, + ], + }, + undefined, ] } /> - - - + + + + + camera + + + - camera - + + - - - - - Route: 1 - + ] + } + > + Route: 1 + + - - - - @@ -3414,235 +3509,241 @@ exports[`renders bottom navigation with getLazy 1`] = ` style={ [ { + "alignSelf": "center", "borderRadius": 16, "height": 32, "width": 64, }, - null, + { + "backgroundColor": "rgba(232, 222, 248, 1)", + }, + { + "opacity": 0, + "transform": [ + { + "scaleX": 0.5, + }, + ], + }, + undefined, ] } /> - - - + + + + + inbox + + + - inbox - + + - - - - - Route: 2 - + ] + } + > + Route: 2 + + - - - - @@ -3650,235 +3751,241 @@ exports[`renders bottom navigation with getLazy 1`] = ` style={ [ { + "alignSelf": "center", "borderRadius": 16, "height": 32, "width": 64, }, - null, + { + "backgroundColor": "rgba(232, 222, 248, 1)", + }, + { + "opacity": 0, + "transform": [ + { + "scaleX": 0.5, + }, + ], + }, + undefined, ] } /> - - - + + + + + heart + + + - heart - + + - - - - - Route: 3 - + ] + } + > + Route: 3 + + - - - - @@ -3886,143 +3993,182 @@ exports[`renders bottom navigation with getLazy 1`] = ` style={ [ { - "borderRadius": 16, - "height": 32, - "width": 64, + "alignSelf": "center", + "borderRadius": 16, + "height": 32, + "width": 64, + }, + { + "backgroundColor": "rgba(232, 222, 248, 1)", + }, + { + "opacity": 0, + "transform": [ + { + "scaleX": 0.5, + }, + ], + }, + undefined, + ] + } + /> + + + + + + shopping-music + + + + > + + - shopping-music - - - - - - - - - Route: 4 - + ] + } + > + Route: 4 + + @@ -4104,32 +4250,25 @@ exports[`renders bottom navigation with scene animation 1`] = ` - @@ -4264,235 +4391,241 @@ exports[`renders bottom navigation with scene animation 1`] = ` style={ [ { + "alignSelf": "center", "borderRadius": 16, "height": 32, "width": 64, }, - null, + { + "backgroundColor": "rgba(232, 222, 248, 1)", + }, + { + "opacity": 1, + "transform": [ + { + "scaleX": 1, + }, + ], + }, + undefined, ] } /> - - - + + + + + magnify + + + - magnify - + + - - - - - Route: 0 - + ] + } + > + Route: 0 + + - - - - @@ -4500,235 +4633,241 @@ exports[`renders bottom navigation with scene animation 1`] = ` style={ [ { + "alignSelf": "center", "borderRadius": 16, "height": 32, "width": 64, }, - null, + { + "backgroundColor": "rgba(232, 222, 248, 1)", + }, + { + "opacity": 0, + "transform": [ + { + "scaleX": 0.5, + }, + ], + }, + undefined, ] } /> - - - + + + + + camera + + + - camera - + + - - - - - Route: 1 - + ] + } + > + Route: 1 + + - - - - @@ -4736,235 +4875,241 @@ exports[`renders bottom navigation with scene animation 1`] = ` style={ [ { + "alignSelf": "center", "borderRadius": 16, "height": 32, "width": 64, }, - null, + { + "backgroundColor": "rgba(232, 222, 248, 1)", + }, + { + "opacity": 0, + "transform": [ + { + "scaleX": 0.5, + }, + ], + }, + undefined, ] } /> - - - + + + + + inbox + + + - inbox - + + - - - - - Route: 2 - + ] + } + > + Route: 2 + + - - - - @@ -4972,235 +5117,241 @@ exports[`renders bottom navigation with scene animation 1`] = ` style={ [ { + "alignSelf": "center", "borderRadius": 16, "height": 32, "width": 64, }, - null, + { + "backgroundColor": "rgba(232, 222, 248, 1)", + }, + { + "opacity": 0, + "transform": [ + { + "scaleX": 0.5, + }, + ], + }, + undefined, ] } /> - - - + + + + + heart + + + - heart - + + - - - - - Route: 3 - + ] + } + > + Route: 3 + + - - - - @@ -5208,143 +5359,182 @@ exports[`renders bottom navigation with scene animation 1`] = ` style={ [ { + "alignSelf": "center", "borderRadius": 16, "height": 32, "width": 64, }, - null, + { + "backgroundColor": "rgba(232, 222, 248, 1)", + }, + { + "opacity": 0, + "transform": [ + { + "scaleX": 0.5, + }, + ], + }, + undefined, ] } /> - - - + + + + + shopping-music + + + - shopping-music - + + - - - - - Route: 4 - + ] + } + > + Route: 4 + + @@ -5426,32 +5616,25 @@ exports[`renders bottom navigation with three tabs 1`] = ` - @@ -5586,235 +5757,241 @@ exports[`renders bottom navigation with three tabs 1`] = ` style={ [ { + "alignSelf": "center", "borderRadius": 16, "height": 32, "width": 64, }, - null, + { + "backgroundColor": "rgba(232, 222, 248, 1)", + }, + { + "opacity": 1, + "transform": [ + { + "scaleX": 1, + }, + ], + }, + undefined, ] } /> - - - + - magnify - - - + + - + > + + magnify + + + + + - - - + - Route: 0 - + ] + } + > + Route: 0 + + - - - - @@ -5822,235 +5999,241 @@ exports[`renders bottom navigation with three tabs 1`] = ` style={ [ { + "alignSelf": "center", "borderRadius": 16, "height": 32, "width": 64, }, - null, + { + "backgroundColor": "rgba(232, 222, 248, 1)", + }, + { + "opacity": 0, + "transform": [ + { + "scaleX": 0.5, + }, + ], + }, + undefined, ] } /> - - - + + + + + camera + + + - camera - + + - - - - - Route: 1 - + ] + } + > + Route: 1 + + - - - + - - @@ -6058,143 +6241,182 @@ exports[`renders bottom navigation with three tabs 1`] = ` style={ [ { + "alignSelf": "center", "borderRadius": 16, "height": 32, "width": 64, }, - null, + { + "backgroundColor": "rgba(232, 222, 248, 1)", + }, + { + "opacity": 0, + "transform": [ + { + "scaleX": 0.5, + }, + ], + }, + undefined, ] } /> - - - + + + + + inbox + + + - inbox - + + - - - - - Route: 2 - + ] + } + > + Route: 2 + + @@ -6276,32 +6498,25 @@ exports[`renders custom icon and label 1`] = ` - - - + + + - @@ -6436,169 +6639,175 @@ exports[`renders custom icon and label 1`] = ` style={ [ { + "alignSelf": "center", "borderRadius": 16, "height": 32, "width": 64, }, - null, + { + "backgroundColor": "rgba(232, 222, 248, 1)", + }, + { + "opacity": 1, + "transform": [ + { + "scaleX": 1, + }, + ], + }, + undefined, ] } /> - - - - + + + + + + - - - - Route: 0 - + + Route: 0 + + - - - - @@ -6606,169 +6815,175 @@ exports[`renders custom icon and label 1`] = ` style={ [ { + "alignSelf": "center", "borderRadius": 16, "height": 32, "width": 64, }, - null, + { + "backgroundColor": "rgba(232, 222, 248, 1)", + }, + { + "opacity": 0, + "transform": [ + { + "scaleX": 0.5, + }, + ], + }, + undefined, ] } /> - - - - + + + + + + - - - - Route: 1 - - - - - + + Route: 1 + + + + + - - @@ -6776,169 +6991,175 @@ exports[`renders custom icon and label 1`] = ` style={ [ { + "alignSelf": "center", "borderRadius": 16, "height": 32, "width": 64, }, - null, + { + "backgroundColor": "rgba(232, 222, 248, 1)", + }, + { + "opacity": 0, + "transform": [ + { + "scaleX": 0.5, + }, + ], + }, + undefined, ] } /> - - - - + + + + + + - - - - Route: 2 - + + Route: 2 + + - - - - @@ -6946,169 +7167,175 @@ exports[`renders custom icon and label 1`] = ` style={ [ { + "alignSelf": "center", "borderRadius": 16, "height": 32, "width": 64, }, - null, + { + "backgroundColor": "rgba(232, 222, 248, 1)", + }, + { + "opacity": 0, + "transform": [ + { + "scaleX": 0.5, + }, + ], + }, + undefined, ] } /> - - - - + + + + + + - - - - Route: 3 - + + Route: 3 + + - - - - @@ -7116,77 +7343,116 @@ exports[`renders custom icon and label 1`] = ` style={ [ { + "alignSelf": "center", "borderRadius": 16, "height": 32, "width": 64, }, - null, + { + "backgroundColor": "rgba(232, 222, 248, 1)", + }, + { + "opacity": 0, + "transform": [ + { + "scaleX": 0.5, + }, + ], + }, + undefined, ] } /> - - - - + + + + + + - - - - Route: 4 - + + Route: 4 + + @@ -7199,32 +7465,25 @@ exports[`renders custom icon and label 1`] = ` exports[`renders the horizontal (flexible) variant 1`] = ` - - - + - magnify - + /> + /> + + magnify + + + > + + - - - Route: 0 - + ] + } + > + Route: 0 + + - - - - - - + - camera - + /> + /> + + > + camera + + + + - - - Route: 1 - + ] + } + > + Route: 1 + + - - - - - - + - inbox - + /> + /> + + > + inbox + + + + - - - Route: 2 - + ] + } + > + Route: 2 + + @@ -7958,32 +8262,25 @@ exports[`renders with custom active and inactive colors 1`] = ` + - + + + + + > + + magnify + + + > + + - magnify - - - - - - - - - Route: 0 - + ] + } + > + Route: 0 + + - - - - @@ -8354,235 +8645,241 @@ exports[`renders with custom active and inactive colors 1`] = ` style={ [ { + "alignSelf": "center", "borderRadius": 16, "height": 32, "width": 64, }, - null, + { + "backgroundColor": "rgba(232, 222, 248, 1)", + }, + { + "opacity": 0, + "transform": [ + { + "scaleX": 0.5, + }, + ], + }, + undefined, ] } /> - - - + + + + + camera + + + - camera - - - + + + - - - - - Route: 1 - + ] + } + > + Route: 1 + + - - - - @@ -8590,143 +8887,182 @@ exports[`renders with custom active and inactive colors 1`] = ` style={ [ { + "alignSelf": "center", "borderRadius": 16, "height": 32, "width": 64, }, - null, + { + "backgroundColor": "rgba(232, 222, 248, 1)", + }, + { + "opacity": 0, + "transform": [ + { + "scaleX": 0.5, + }, + ], + }, + undefined, ] } /> - - - + + + + + inbox + + + - inbox - + + - - - - - Route: 2 - + ] + } + > + Route: 2 + +