UNPKG

@react-navigation/bottom-tabs

Version:

Bottom tab navigator following iOS design guidelines

557 lines (554 loc) • 18 kB
"use strict"; import { getLabel, SafeAreaProviderCompat, Screen as ScreenContent } from '@react-navigation/elements'; import { CommonActions, NavigationMetaContext, StackActions, useTheme } from '@react-navigation/native'; import Color from 'color'; import * as React from 'react'; import { Platform, PlatformColor } from 'react-native'; import { Tabs } from 'react-native-screens'; import { Deferred } from "./Deferred.js"; import { NativeScreen } from "./NativeScreen/NativeScreen.js"; import { jsx as _jsx } from "react/jsx-runtime"; const meta = { type: 'native-tabs' }; const SUPPORTS_PROMINENT_TAB = Platform.OS === 'ios' && parseInt(Platform.Version, 10) >= 27; function reducer(state, action) { switch (action.type) { case 'CONFIRM_STATE': return state.confirmed.routeKey === action.confirmed.routeKey && state.confirmed.provenance === action.confirmed.provenance ? state : { ...state, confirmed: action.confirmed }; } } export function NativeBottomTabView({ state, navigation, descriptors }) { const { dark, colors, fonts } = useTheme(); const focusedRouteKey = state.routes[state.index].key; const [nativeState, dispatch] = React.useReducer(reducer, { confirmed: { routeKey: focusedRouteKey, provenance: 0 } }); const previousRouteKeyRef = React.useRef(focusedRouteKey); React.useEffect(() => { const previousRouteKey = previousRouteKeyRef.current; if (previousRouteKey !== focusedRouteKey && descriptors[previousRouteKey]?.options.popToTopOnBlur) { const currentState = navigation.getState(); const prevRoute = currentState.routes.find(route => route.key === previousRouteKey); if (prevRoute?.state?.type === 'stack' && prevRoute.state.key && (prevRoute.state.index ?? prevRoute.state.routes.length - 1) > 0) { navigation.dispatch({ ...StackActions.popToTop(), target: prevRoute.state.key }); } } previousRouteKeyRef.current = focusedRouteKey; }, [descriptors, focusedRouteKey, navigation]); const navigate = (route, confirmed) => { dispatch({ type: 'CONFIRM_STATE', confirmed }); navigation.dispatch({ ...CommonActions.navigate(route.name, route.params), target: state.key }); }; // Native tabs are the source of truth for the selected tab. // JS sends a requested tab with the native provenance it was based on. // Native replies with the selected tab and its new provenance. const onTabSelected = event => { const { selectedScreenKey, provenance, actionOrigin, isRepeated } = event.nativeEvent; const confirmed = { routeKey: selectedScreenKey, provenance }; const route = state.routes.find(route => route.key === selectedScreenKey); if (!route) { console.error(`Received 'tabSelected' for route that doesn't exist: ${selectedScreenKey}`); return; } if (actionOrigin === 'user') { const { tabBarRepeatedPressBehavior } = descriptors[route.key].options; navigation.emit({ type: 'tabPress', target: route.key, data: { origin: 'native', behavior: { scrollToTop: isRepeated && tabBarRepeatedPressBehavior?.scrollToTop !== false, popToTop: isRepeated && tabBarRepeatedPressBehavior?.popToTop !== false } } }); } if (actionOrigin === 'programmatic-js' || focusedRouteKey === route.key) { dispatch({ type: 'CONFIRM_STATE', confirmed }); return; } navigate(route, confirmed); }; // If native rejects a JS request, keep native as the source of truth // and move JS back to the tab that native says is selected. const onTabSelectionRejected = event => { const { selectedScreenKey, provenance } = event.nativeEvent; const confirmed = { routeKey: selectedScreenKey, provenance }; const route = state.routes.find(route => route.key === selectedScreenKey); if (!route) { console.error(`Received 'tabSelectionRejected' for route that doesn't exist: ${selectedScreenKey}`); return; } if (focusedRouteKey === route.key) { dispatch({ type: 'CONFIRM_STATE', confirmed }); return; } navigate(route, confirmed); }; const onTabSelectionPrevented = event => { const { selectedScreenKey, provenance, preventedScreenKey } = event.nativeEvent; dispatch({ type: 'CONFIRM_STATE', confirmed: { routeKey: selectedScreenKey, provenance } }); navigation.emit({ type: 'tabPress', target: preventedScreenKey, data: { origin: 'native', behavior: { scrollToTop: false, popToTop: false } } }); }; const currentOptions = descriptors[state.routes[state.index].key]?.options; const { fontFamily = Platform.select({ ios: fonts.medium.fontFamily, default: fonts.regular.fontFamily }), fontWeight = Platform.select({ ios: fonts.medium.fontWeight, default: fonts.regular.fontWeight }), fontSize, fontStyle, color: fontColor } = currentOptions.tabBarLabelStyle || {}; const { activeIndicatorColor, activeTintColor, inactiveTintColor } = React.useMemo(() => { const activeTintColor = currentOptions.tabBarActiveTintColor ?? colors.primary; const inactiveTintColor = currentOptions.tabBarInactiveTintColor ?? Platform.select({ ios: PlatformColor('label'), default: colors.text }); const activeIndicatorColor = currentOptions?.tabBarActiveIndicatorColor ?? (typeof activeTintColor === 'string' ? Color(activeTintColor)?.alpha(0.1).string() : undefined); return { activeIndicatorColor, activeTintColor, inactiveTintColor }; }, [currentOptions?.tabBarActiveIndicatorColor, currentOptions.tabBarActiveTintColor, currentOptions.tabBarInactiveTintColor, colors.primary, colors.text]); const tabBarControllerMode = currentOptions.tabBarControllerMode === 'auto' ? 'automatic' : currentOptions.tabBarControllerMode; const tabBarMinimizeBehavior = currentOptions.tabBarMinimizeBehavior === 'auto' ? 'automatic' : currentOptions.tabBarMinimizeBehavior === 'none' ? 'never' : currentOptions.tabBarMinimizeBehavior; const tabBarSidebarPreferredPlacement = currentOptions.tabBarSidebarPreferredPlacement === 'auto' ? 'automatic' : currentOptions.tabBarSidebarPreferredPlacement; const prominentScreenKey = SUPPORTS_PROMINENT_TAB ? state.routes.find(route => descriptors[route.key]?.options.tabBarRole === 'prominent')?.key : undefined; const shouldHideTabBar = currentOptions.tabBarStyle?.display === 'none'; const bottomAccessory = currentOptions.bottomAccessory; return /*#__PURE__*/_jsx(SafeAreaProviderCompat, { children: /*#__PURE__*/_jsx(Tabs.Host, { navStateRequest: { selectedScreenKey: focusedRouteKey, baseProvenance: nativeState.confirmed.provenance }, rejectStaleNavStateUpdates: true, onTabSelected: onTabSelected, onTabSelectionRejected: onTabSelectionRejected, onTabSelectionPrevented: onTabSelectionPrevented, tabBarHidden: shouldHideTabBar, colorScheme: dark ? 'dark' : 'light', nativeContainerStyle: { backgroundColor: colors.background }, ios: { prominentScreenKey, bottomAccessory: bottomAccessory ? environment => bottomAccessory({ placement: environment }) : undefined, tabBarHiddenAnimationEnabled: currentOptions.tabBarVisibilityAnimationEnabled, tabBarControllerMode, tabBarSidebarPreferredPlacement, tabBarMinimizeBehavior, tabBarTintColor: activeTintColor }, children: state.routes.map((route, index) => { const descriptor = descriptors[route.key]; const isFocused = state.index === index; const isPreloaded = state.preloadedRouteKeys.includes(route.key); const { lazy = false } = descriptor.options; return /*#__PURE__*/_jsx(TabScene, { route: route, descriptor: descriptor, navigation: navigation, isFocused: isFocused, lazy: lazy, visible: isFocused || isPreloaded, fontFamily: fontFamily, fontWeight: fontWeight, fontSize: fontSize, fontStyle: fontStyle, fontColor: fontColor, activeTintColor: activeTintColor, inactiveTintColor: inactiveTintColor, activeIndicatorColor: activeIndicatorColor, rippleColor: currentOptions.tabBarRippleColor, labelVisibilityMode: currentOptions.tabBarLabelVisibilityMode, activeIndicatorEnabled: currentOptions.tabBarActiveIndicatorEnabled }, route.key); }) }) }); } const TabScene = /*#__PURE__*/React.memo(function TabScene({ route, descriptor, navigation, isFocused, lazy, visible, fontFamily, fontWeight, fontSize, fontStyle, fontColor, activeTintColor, inactiveTintColor, activeIndicatorColor, rippleColor, labelVisibilityMode, activeIndicatorEnabled }) { const { dark, colors } = useTheme(); const onTransitionStart = ({ closing, route }) => { navigation.emit({ type: 'transitionStart', data: { closing }, target: route.key }); }; const onTransitionEnd = ({ closing, route }) => { navigation.emit({ type: 'transitionEnd', data: { closing }, target: route.key }); }; const { options, render } = descriptor; const { title, tabBarLabel, tabBarLabelPositionAdjustment, tabBarSelectionEnabled, tabBarRepeatedPressBehavior, tabBarBadgeStyle, tabBarIcon, tabBarIconSize, tabBarActiveIndicatorWidth, tabBarActiveIndicatorHeight, tabBarBadge, tabBarSystemItem, tabBarRole, tabBarSearchAutoFocus, tabBarBlurEffect = dark ? 'systemMaterialDark' : 'systemMaterial', tabBarStyle, overrideScrollViewContentInsetAdjustmentBehavior } = options; const systemItem = tabBarSystemItem ?? ( // Setting the `role` to 'search' doesn't add title and icon automatically // So we also set `tabBarSystemItem` to 'search' to match SwiftUI's behavior tabBarRole === 'search' ? 'search' : undefined); const { backgroundColor: tabBarBackgroundColor, shadowColor: tabBarShadowColor } = tabBarStyle || {}; const tabTitle = // On iOS, `systemItem` already provides a localized label // So we should only use `tabBarLabel` if explicitly provided Platform.OS === 'ios' && systemItem != null ? tabBarLabel : getLabel({ label: tabBarLabel, title }, route.name); const badgeBackgroundColor = tabBarBadgeStyle?.backgroundColor ?? colors.notification; const badgeTextColor = tabBarBadgeStyle?.color ?? (typeof badgeBackgroundColor === 'string' ? Color(badgeBackgroundColor).isLight() ? 'black' : 'white' : undefined); const tabItemAppearance = { tabBarItemTitleFontFamily: fontFamily, tabBarItemTitleFontSize: fontSize, tabBarItemTitleFontWeight: fontWeight, tabBarItemTitleFontStyle: fontStyle, tabBarItemTitleFontColor: inactiveTintColor ?? fontColor, tabBarItemIconColor: inactiveTintColor, tabBarItemBadgeBackgroundColor: badgeBackgroundColor }; const tabItemLayoutAppearance = { stacked: { normal: { ...tabItemAppearance, tabBarItemTitlePositionAdjustment: tabBarLabelPositionAdjustment?.stacked } }, inline: { normal: { ...tabItemAppearance, tabBarItemTitlePositionAdjustment: tabBarLabelPositionAdjustment?.inline } }, compactInline: { normal: { ...tabItemAppearance, tabBarItemTitlePositionAdjustment: tabBarLabelPositionAdjustment?.compactInline } } }; const normalTabItemAppearance = { tabBarItemTitleFontColor: inactiveTintColor ?? fontColor, tabBarItemIconColor: inactiveTintColor }; const selectedTabItemAppearance = { tabBarItemTitleFontColor: activeTintColor, tabBarItemIconColor: activeTintColor }; const icon = typeof tabBarIcon === 'function' ? getPlatformIcon(tabBarIcon({ focused: false })) : tabBarIcon != null ? getPlatformIcon(tabBarIcon) : undefined; const selectedIcon = typeof tabBarIcon === 'function' ? getPlatformIcon(tabBarIcon({ focused: true })) : undefined; return /*#__PURE__*/_jsx(Tabs.Screen, { onWillDisappear: () => onTransitionStart({ closing: true, route }), onWillAppear: () => onTransitionStart({ closing: false, route }), onDidAppear: () => onTransitionEnd({ closing: false, route }), onDidDisappear: () => onTransitionEnd({ closing: true, route }), screenKey: route.key, preventNativeSelection: tabBarSelectionEnabled === false, badgeValue: tabBarBadge?.toString(), title: tabTitle, style: { backgroundColor: colors.background }, specialEffects: { repeatedTabSelection: { popToRoot: tabBarRepeatedPressBehavior?.popToTop !== false, scrollToTop: tabBarRepeatedPressBehavior?.scrollToTop !== false } }, android: { icon: icon?.android ?? icon?.shared, selectedIcon: selectedIcon?.android ?? selectedIcon?.shared, standardAppearance: { tabBarBackgroundColor: tabBarBackgroundColor ?? colors.card, tabBarItemRippleColor: rippleColor, tabBarItemLabelVisibilityMode: labelVisibilityMode, normal: normalTabItemAppearance, selected: selectedTabItemAppearance, tabBarItemIconSize: tabBarIconSize, tabBarItemActiveIndicatorColor: activeIndicatorColor, tabBarItemActiveIndicatorEnabled: activeIndicatorEnabled, tabBarItemActiveIndicatorWidth: tabBarActiveIndicatorWidth, tabBarItemActiveIndicatorHeight: tabBarActiveIndicatorHeight, tabBarItemTitleFontFamily: fontFamily, tabBarItemTitleFontWeight: fontWeight, tabBarItemTitleSmallLabelFontSize: fontSize, tabBarItemTitleLargeLabelFontSize: fontSize, tabBarItemTitleFontStyle: fontStyle, tabBarItemBadgeBackgroundColor: badgeBackgroundColor, tabBarItemBadgeTextColor: badgeTextColor } }, ios: { icon: icon?.ios ?? icon?.shared, selectedIcon: selectedIcon?.ios ?? selectedIcon?.shared, systemItem, role: tabBarRole === 'search' ? tabBarRole : undefined, automaticallyActivatesSearch: tabBarSearchAutoFocus, scrollEdgeAppearance: { tabBarBackgroundColor, tabBarShadowColor, tabBarBlurEffect, ...tabItemLayoutAppearance }, standardAppearance: { tabBarBackgroundColor, tabBarShadowColor, tabBarBlurEffect, ...tabItemLayoutAppearance }, overrideScrollViewContentInsetAdjustmentBehavior }, children: /*#__PURE__*/_jsx(Deferred, { lazy: lazy, visible: visible, children: /*#__PURE__*/_jsx(ScreenWithHeader, { isFocused: isFocused, route: route, navigation: descriptor.navigation, options: options, children: /*#__PURE__*/_jsx(NavigationMetaContext.Provider, { value: meta, children: render() }) }) }) }); }); function ScreenWithHeader({ isFocused, route, navigation, options, children }) { const { headerTransparent, header: renderCustomHeader, headerShown = renderCustomHeader != null } = options; const hasNativeHeader = headerShown && renderCustomHeader == null; const [wasNativeHeaderShown] = React.useState(hasNativeHeader); React.useEffect(() => { if (wasNativeHeaderShown !== hasNativeHeader) { throw new Error(`Changing 'headerShown' or 'header' options dynamically is not supported when using native header.`); } }, [wasNativeHeaderShown, hasNativeHeader]); if (hasNativeHeader) { return /*#__PURE__*/_jsx(NativeScreen, { route: route, navigation: navigation, options: options, children: children }); } return /*#__PURE__*/_jsx(ScreenContent, { focused: isFocused, route: route, navigation: navigation, headerShown: headerShown, headerTransparent: headerTransparent, header: renderCustomHeader?.({ route, navigation, options }), children: children }); } function getPlatformIcon(icon) { switch (icon.type) { case 'sfSymbol': return { ios: { type: 'sfSymbol', name: icon.name, renderingMode: icon.renderingMode === 'multicolor' ? 'original' : icon.renderingMode }, android: undefined, shared: undefined }; case 'image': { // A `{ uri: 'name' }` source maps to an Android drawable by name. const drawableName = typeof icon.source === 'object' && 'uri' in icon.source && icon.source.uri?.includes(':') === false ? icon.source.uri : undefined; return { ios: { type: 'imageSource', imageSource: icon.source, renderingMode: icon.tinted === false ? 'original' : 'template' }, android: drawableName != null ? { type: 'drawableResource', name: drawableName, tinting: icon.tinted === false ? 'original' : 'tinted' } : { type: 'imageSource', imageSource: icon.source, tinting: icon.tinted === false ? 'original' : 'tinted' }, shared: undefined }; } default: { const _exhaustiveCheck = icon; return _exhaustiveCheck; } } } //# sourceMappingURL=NativeBottomTabView.native.js.map