diff --git a/android/src/main/java/com/rcttabview/RCTTabView.kt b/android/src/main/java/com/rcttabview/RCTTabView.kt index a8ee0468..79897c96 100644 --- a/android/src/main/java/com/rcttabview/RCTTabView.kt +++ b/android/src/main/java/com/rcttabview/RCTTabView.kt @@ -92,6 +92,7 @@ class ReactBottomNavigationView(context: Context) : BottomNavigationView(context this.items = items items.forEachIndexed { index, item -> val menuItem = getOrCreateItem(index, item.title) + menuItem.isVisible = !item.hidden if (icons.containsKey(index)) { menuItem.icon = getDrawable(icons[index]!!) } diff --git a/android/src/main/java/com/rcttabview/RCTTabViewImpl.kt b/android/src/main/java/com/rcttabview/RCTTabViewImpl.kt index 82510a4c..2ecdcec2 100644 --- a/android/src/main/java/com/rcttabview/RCTTabViewImpl.kt +++ b/android/src/main/java/com/rcttabview/RCTTabViewImpl.kt @@ -12,7 +12,8 @@ data class TabInfo( val key: String, val title: String, val badge: String, - val activeTintColor: Int? + val activeTintColor: Int?, + val hidden: Boolean, ) class RCTTabViewImpl { @@ -29,7 +30,8 @@ class RCTTabViewImpl { key = item.getString("key") ?: "", title = item.getString("title") ?: "", badge = item.getString("badge") ?: "", - activeTintColor = if (item.hasKey("activeTintColor")) item.getInt("activeTintColor") else null + activeTintColor = if (item.hasKey("activeTintColor")) item.getInt("activeTintColor") else null, + hidden = if (item.hasKey("hidden")) item.getBoolean("hidden") else false ) ) } diff --git a/docs/docs/docs/guides/standalone-usage.md b/docs/docs/docs/guides/standalone-usage.md index 9b077f01..e6973c81 100644 --- a/docs/docs/docs/guides/standalone-usage.md +++ b/docs/docs/docs/guides/standalone-usage.md @@ -200,4 +200,12 @@ Function to get the active tint color for a tab. #### `getIcon` Function to get the icon for a tab. + - Default: Uses `route.focusedIcon` and `route.unfocusedIcon` + + +#### `getHidden` + +Function to determine if a tab should be hidden. + +- Default: Uses `route.hidden` diff --git a/docs/docs/docs/guides/usage-with-react-navigation.mdx b/docs/docs/docs/guides/usage-with-react-navigation.mdx index a2cdf65d..22dce85a 100644 --- a/docs/docs/docs/guides/usage-with-react-navigation.mdx +++ b/docs/docs/docs/guides/usage-with-react-navigation.mdx @@ -193,6 +193,16 @@ SF Symbols are only supported on Apple platforms. Badge to show on the tab icon. +#### `tabBarItemHidden` + +Whether the tab bar item is hidden. + +:::warning + +Due to native limitations on iOS, this option doesn't hide the tab item **when hidden route is focused**. + +::: + #### `lazy` Whether this screens should render the first time it's accessed. Defaults to true. Set it to false if you want to render the screen on initial render. diff --git a/example/src/App.tsx b/example/src/App.tsx index 9de0519c..21db84d2 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -37,6 +37,10 @@ const FourTabsIgnoreSafeArea = () => { return ; }; +const HiddenTab = () => { + return ; +}; + const FourTabsRippleColor = () => { return ; }; @@ -114,6 +118,10 @@ const examples = [ component: FourTabsActiveIndicatorColor, name: 'Four Tabs - Active Indicator color', }, + { + component: HiddenTab, + name: 'Four Tabs - With Hidden Tab', + }, { component: NativeBottomTabsVectorIcons, name: 'Native Bottom Tabs with Vector Icons', diff --git a/example/src/Examples/FourTabs.tsx b/example/src/Examples/FourTabs.tsx index abd92e86..a9be0675 100644 --- a/example/src/Examples/FourTabs.tsx +++ b/example/src/Examples/FourTabs.tsx @@ -12,6 +12,7 @@ interface Props { scrollEdgeAppearance?: 'default' | 'opaque' | 'transparent'; barTintColor?: ColorValue; translucent?: boolean; + hideOneTab?: boolean; rippleColor?: ColorValue; activeIndicatorColor?: ColorValue; } @@ -22,6 +23,7 @@ export default function FourTabs({ scrollEdgeAppearance = 'default', barTintColor, translucent = true, + hideOneTab = false, rippleColor, activeIndicatorColor, }: Props) { @@ -39,6 +41,7 @@ export default function FourTabs({ title: 'Albums', focusedIcon: require('../../assets/icons/grid_dark.png'), badge: '5', + hidden: hideOneTab, }, { key: 'contacts', diff --git a/example/src/Examples/NativeBottomTabsVectorIcons.tsx b/example/src/Examples/NativeBottomTabsVectorIcons.tsx index eca794dd..b1436c86 100644 --- a/example/src/Examples/NativeBottomTabsVectorIcons.tsx +++ b/example/src/Examples/NativeBottomTabsVectorIcons.tsx @@ -69,6 +69,7 @@ function NativeBottomTabsVectorIcons() { options={{ tabBarIcon: () => messageIcon, tabBarActiveTintColor: 'white', + tabBarItemHidden: true, }} /> diff --git a/ios/Fabric/RCTTabViewComponentView.mm b/ios/Fabric/RCTTabViewComponentView.mm index 17a743ce..d735a724 100644 --- a/ios/Fabric/RCTTabViewComponentView.mm +++ b/ios/Fabric/RCTTabViewComponentView.mm @@ -155,7 +155,8 @@ bool areTabItemsEqual(const RNCTabViewItemsStruct& lhs, const RNCTabViewItemsStr lhs.title == rhs.title && lhs.sfSymbol == rhs.sfSymbol && lhs.badge == rhs.badge && - lhs.activeTintColor == rhs.activeTintColor; + lhs.activeTintColor == rhs.activeTintColor && + lhs.hidden == rhs.hidden; } bool haveTabItemsChanged(const std::vector& oldItems, @@ -178,7 +179,7 @@ bool haveTabItemsChanged(const std::vector& oldItems, NSMutableArray *result = [NSMutableArray array]; for (const auto& item : items) { - auto tabInfo = [[TabInfo alloc] initWithKey:RCTNSStringFromString(item.key) title:RCTNSStringFromString(item.title) badge:RCTNSStringFromString(item.badge) sfSymbol:RCTNSStringFromString(item.sfSymbol) activeTintColor:RCTUIColorFromSharedColor(item.activeTintColor)]; + auto tabInfo = [[TabInfo alloc] initWithKey:RCTNSStringFromString(item.key) title:RCTNSStringFromString(item.title) badge:RCTNSStringFromString(item.badge) sfSymbol:RCTNSStringFromString(item.sfSymbol) activeTintColor:RCTUIColorFromSharedColor(item.activeTintColor) hidden:item.hidden]; [result addObject:tabInfo]; } diff --git a/ios/TabViewImpl.swift b/ios/TabViewImpl.swift index de8b8447..31225921 100644 --- a/ios/TabViewImpl.swift +++ b/ios/TabViewImpl.swift @@ -56,50 +56,20 @@ struct TabViewImpl: View { var body: some View { TabView(selection: $props.selectedPage) { ForEach(props.children.indices, id: \.self) { index in - let child = props.children[safe: index] ?? UIView() - let tabData = props.items[safe: index] - let icon = props.icons[index] - - RepresentableView(view: child) - .ignoresTopSafeArea( - props.ignoresTopSafeArea ?? false, - frame: child.frame - ) - .tabItem { - TabItem( - title: tabData?.title, - icon: icon, - sfSymbol: tabData?.sfSymbol, - labeled: props.labeled - ) - } - .tag(tabData?.key) - .tabBadge(tabData?.badge) -#if os(iOS) - .onAppear { - // SwiftUI doesn't change `selection` when clicked on items from the "More" list. - // More tab is visible only if we have more than 5 items. - // This is a workaround that fixes the "More" feature. - if index < 4 { - return - } - if let key = tabData?.key, props.selectedPage != key { - onSelect(key) - } - } -#endif + renderTabItem(at: index) } - } .onTabItemEvent({ index, isLongPress in - if let key = props.items[safe: index]?.key { - if isLongPress { - onLongPress(key) - emitHapticFeedback(longPress: true) - } else { - onSelect(key) - emitHapticFeedback() - } + guard let key = props.items.filter({ + !$0.hidden || $0.key == props.selectedPage + })[safe: index]?.key else { return } + + if isLongPress { + onLongPress(key) + emitHapticFeedback(longPress: true) + } else { + onSelect(key) + emitHapticFeedback() } }) .tintColor(props.selectedActiveTintColor) @@ -115,6 +85,42 @@ struct TabViewImpl: View { } } + @ViewBuilder + private func renderTabItem(at index: Int) -> some View { + let tabData = props.items[safe: index] + let isHidden = tabData?.hidden ?? false + let isFocused = props.selectedPage == tabData?.key + + if !isHidden || isFocused { + let child = props.children[safe: index] ?? UIView() + let icon = props.icons[index] + + RepresentableView(view: child) + .ignoresTopSafeArea( + props.ignoresTopSafeArea ?? false, + frame: child.frame + ) + .tabItem { + TabItem( + title: tabData?.title, + icon: icon, + sfSymbol: tabData?.sfSymbol, + labeled: props.labeled + ) + } + .tag(tabData?.key) + .tabBadge(tabData?.badge) +#if os(iOS) + .onAppear { + guard index >= 4, + let key = tabData?.key, + props.selectedPage != key else { return } + onSelect(key) + } +#endif + } + } + func emitHapticFeedback(longPress: Bool = false) { #if os(iOS) if !props.hapticFeedbackEnabled { diff --git a/ios/TabViewProvider.swift b/ios/TabViewProvider.swift index f0e74df5..1e65f442 100644 --- a/ios/TabViewProvider.swift +++ b/ios/TabViewProvider.swift @@ -8,6 +8,7 @@ import React @objc public let badge: String @objc public let sfSymbol: String @objc public let activeTintColor: UIColor? + @objc public let hidden: Bool @objc public init( @@ -15,13 +16,15 @@ import React title: String, badge: String, sfSymbol: String, - activeTintColor: UIColor? + activeTintColor: UIColor?, + hidden: Bool ) { self.key = key self.title = title self.badge = badge self.sfSymbol = sfSymbol self.activeTintColor = activeTintColor + self.hidden = hidden super.init() } } @@ -210,7 +213,8 @@ import React title: itemDict["title"] as? String ?? "", badge: itemDict["badge"] as? String ?? "", sfSymbol: itemDict["sfSymbol"] as? String ?? "", - activeTintColor: RCTConvert.uiColor(itemDict["activeTintColor"] as? NSNumber) + activeTintColor: RCTConvert.uiColor(itemDict["activeTintColor"] as? NSNumber), + hidden: itemDict["hidden"] as? Bool ?? false ) ) } diff --git a/src/TabView.tsx b/src/TabView.tsx index 954bc120..e9c5eb9e 100644 --- a/src/TabView.tsx +++ b/src/TabView.tsx @@ -104,6 +104,12 @@ interface Props { focused: boolean; }) => ImageSource | undefined; + /** + * Get hidden for the tab, uses `route.hidden` by default. + * If `true`, the tab will be hidden. + */ + getHidden?: (props: { route: Route }) => boolean | undefined; + /** * Background color of the tab bar. */ @@ -126,6 +132,10 @@ const TabView = ({ renderScene, onIndexChange, onTabLongPress, + getBadge, + rippleColor, + tabBarActiveTintColor: activeTintColor, + tabBarInactiveTintColor: inactiveTintColor, getLazy = ({ route }: { route: Route }) => route.lazy, getLabelText = ({ route }: { route: Route }) => route.title, getIcon = ({ route, focused }: { route: Route; focused: boolean }) => @@ -135,11 +145,9 @@ const TabView = ({ : route.unfocusedIcon : route.focusedIcon, barTintColor, + getHidden = ({ route }: { route: Route }) => route.hidden, getActiveTintColor = ({ route }: { route: Route }) => route.activeTintColor, - tabBarActiveTintColor: activeTintColor, - tabBarInactiveTintColor: inactiveTintColor, hapticFeedbackEnabled = true, - rippleColor, ...props }: Props) => { // @ts-ignore @@ -190,15 +198,24 @@ const TabView = ({ 'SF Symbols are not supported on Android. Use require() or pass uri to load an image instead.' ); } + return { key: route.key, title: getLabelText({ route }) ?? route.key, sfSymbol: isSfSymbol ? icon.sfSymbol : undefined, - badge: props.getBadge?.({ route }), + badge: getBadge?.({ route }), activeTintColor: processColor(getActiveTintColor({ route })), + hidden: getHidden?.({ route }), }; }), - [trimmedRoutes, icons, getLabelText, props, getActiveTintColor] + [ + trimmedRoutes, + icons, + getLabelText, + getBadge, + getActiveTintColor, + getHidden, + ] ); const resolvedIconAssets: ImageSource[] = useMemo( diff --git a/src/TabViewNativeComponent.ts b/src/TabViewNativeComponent.ts index 54908465..3da589be 100644 --- a/src/TabViewNativeComponent.ts +++ b/src/TabViewNativeComponent.ts @@ -14,6 +14,7 @@ export type TabViewItems = ReadonlyArray<{ sfSymbol?: string; badge?: string; activeTintColor?: ProcessedColorValue | null; + hidden?: boolean; }>; export interface TabViewProps extends ViewProps { diff --git a/src/react-navigation/types.ts b/src/react-navigation/types.ts index 0dc21948..2318ac4a 100644 --- a/src/react-navigation/types.ts +++ b/src/react-navigation/types.ts @@ -67,6 +67,17 @@ export type NativeBottomTabNavigationOptions = { */ tabBarIcon?: (props: { focused: boolean }) => ImageSourcePropType | AppleIcon; + /** + * Whether the tab bar item is visible when this screen is active. + * Used for compatibility with JS Tabs. Prefer using `tabBarItemHidden` as this API may be removed in the future. + */ + tabBarButton?: () => null; + + /** + * Whether the tab bar item is visible. Defaults to true. + */ + tabBarItemHidden?: boolean; + /** * Badge to show on the tab icon. */ diff --git a/src/react-navigation/views/NativeBottomTabView.tsx b/src/react-navigation/views/NativeBottomTabView.tsx index 997be9c4..f7f504ba 100644 --- a/src/react-navigation/views/NativeBottomTabView.tsx +++ b/src/react-navigation/views/NativeBottomTabView.tsx @@ -40,6 +40,14 @@ export default function NativeBottomTabView({ : (route as Route).name; }} getBadge={({ route }) => descriptors[route.key]?.options.tabBarBadge} + getHidden={({ route }) => { + const options = descriptors[route.key]?.options; + + return ( + options?.tabBarItemHidden === true || + options?.tabBarButton?.() === null + ); + }} getIcon={({ route, focused }) => { const options = descriptors[route.key]?.options; diff --git a/src/types.ts b/src/types.ts index 5ddf5aaf..8307f910 100644 --- a/src/types.ts +++ b/src/types.ts @@ -13,6 +13,7 @@ export type BaseRoute = { focusedIcon?: ImageSourcePropType | AppleIcon; unfocusedIcon?: ImageSourcePropType | AppleIcon; activeTintColor?: string; + hidden?: boolean; }; export type NavigationState = {