diff --git a/.changeset/khaki-maps-lay.md b/.changeset/khaki-maps-lay.md new file mode 100644 index 0000000..bb4b9d7 --- /dev/null +++ b/.changeset/khaki-maps-lay.md @@ -0,0 +1,5 @@ +--- +'react-native-bottom-tabs': minor +--- + +fix: adjust the behavior of empty string for badge prop diff --git a/.changeset/ready-paths-train.md b/.changeset/ready-paths-train.md new file mode 100644 index 0000000..3e7de55 --- /dev/null +++ b/.changeset/ready-paths-train.md @@ -0,0 +1,5 @@ +--- +'react-native-bottom-tabs': patch +--- + +feat: implement iOS 26 minimizeBehavior feature diff --git a/README.md b/README.md index 0154a3a..7f44a3f 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ - + React Native Bottom Tabs diff --git a/apps/example/ios/Podfile.lock b/apps/example/ios/Podfile.lock index 92798c7..b0a038e 100644 --- a/apps/example/ios/Podfile.lock +++ b/apps/example/ios/Podfile.lock @@ -1180,7 +1180,7 @@ PODS: - React-jsiexecutor - React-RCTFBReactNativeSpec - ReactCommon/turbomodule/core - - react-native-bottom-tabs (0.9.1): + - react-native-bottom-tabs (0.9.2): - DoubleConversion - glog - RCT-Folly (= 2024.11.18.00) @@ -1931,7 +1931,7 @@ SPEC CHECKSUMS: React-logger: 1935d6e6461e9c8be4c87af56c56a4876021171e React-Mapbuffer: 212171f037e3b22e6c2df839aa826806da480b85 React-microtasksnativemodule: a0ffd165c39251512d8cf51e9e8f5719dabc38b6 - react-native-bottom-tabs: 7c7ee94435e518759b414e89068956096a1adec7 + react-native-bottom-tabs: 6ee03990297f7e37f5c1dd6f5259cee851733d4f react-native-safe-area-context: e54b360402f089600c2fb0d825d1d3d918b99e15 React-nativeconfig: cb207ebba7cafce30657c7ad9f1587a8f32e4564 React-NativeModulesApple: 76a5d35322908fbc88871e6dd20433bea2b8b2db diff --git a/apps/example/src/Examples/FourTabs.tsx b/apps/example/src/Examples/FourTabs.tsx index 93ed60f..4c32ece 100644 --- a/apps/example/src/Examples/FourTabs.tsx +++ b/apps/example/src/Examples/FourTabs.tsx @@ -52,6 +52,7 @@ export default function FourTabs({ key: 'contacts', focusedIcon: require('../../assets/icons/person_dark.png'), title: 'Contacts', + badge: ' ', }, { key: 'chat', diff --git a/apps/example/src/Examples/SFSymbols.tsx b/apps/example/src/Examples/SFSymbols.tsx index 591424d..1e7b48c 100644 --- a/apps/example/src/Examples/SFSymbols.tsx +++ b/apps/example/src/Examples/SFSymbols.tsx @@ -47,6 +47,7 @@ export default function SFSymbols() { return ( ); } @@ -90,6 +91,7 @@ const renderScene = SceneMap({ #### `navigationState` State for the tab view. The state should contain: + - `routes`: Array of route objects containing `key` and `title` props - `index`: Current selected tab index @@ -106,6 +108,7 @@ Callback that is called when the tab index changes. #### `labeled` Whether to show labels in tabs. When `false`, only icons will be displayed. + - Type: `boolean` - Default : `true` - Default : `false` @@ -113,6 +116,7 @@ Whether to show labels in tabs. When `false`, only icons will be displayed. #### `sidebarAdaptable` A tab bar style that adapts to each platform: + - iPadOS: Top tab bar that can adapt into a sidebar - iOS: Bottom tab bar - macOS/tvOS: Sidebar @@ -121,19 +125,21 @@ A tab bar style that adapts to each platform: #### `disablePageAnimations` Whether to disable animations between tabs. + - Type: `boolean` #### `hapticFeedbackEnabled` Whether to enable haptic feedback on tab press. + - Type: `boolean` - Default: `false` - #### `tabLabelStyle` Object containing styles for the tab label. Supported properties: + - `fontFamily` - `fontSize` - `fontWeight` @@ -141,11 +147,31 @@ Supported properties: #### `scrollEdgeAppearance` Appearance attributes for the tab bar when a scroll view is at the bottom. + - Type: `'default' | 'opaque' | 'transparent'` +#### `minimizeBehavior` + +Controls how the tab bar behaves when content is scrolled. + +- Type: `'automatic' | 'onScrollDown' | 'onScrollUp' | 'never'` +- Default: `undefined` (uses system default) + +Options: + +- `automatic`: Platform determines the behavior +- `onScrollDown`: Tab bar minimizes when scrolling down +- `onScrollUp`: Tab bar minimizes when scrolling up +- `never`: Tab bar never minimizes + +:::note +This feature requires iOS 26.0 or later and is only available on iOS. On older versions, this prop is ignored. +::: + #### `tabBarActiveTintColor` Color for the active tab. + - Type: `ColorValue` #### `tabBarInactiveTintColor` @@ -165,11 +191,13 @@ Supported properties: #### `translucent` Whether the tab bar is translucent. + - Type: `boolean` #### `activeIndicatorColor` Color of tab indicator. + - Type: `ColorValue` ### Route Configuration @@ -190,21 +218,29 @@ Each route in the `routes` array can have the following properties: #### `getLazy` Function to determine if a screen should be lazy loaded. + - Default: Uses `route.lazy` #### `getLabelText` Function to get the label text for a tab. + - Default: Uses `route.title` #### `getBadge` Function to get the badge text for a tab. + - Default: Uses `route.badge` +:::warning +To display a badge without text (just a dot), you need to pass a string with a space character (`" "`). +::: + #### `getActiveTintColor` Function to get the active tint color for a tab. + - Default: Uses `route.activeTintColor` #### `getIcon` @@ -213,7 +249,6 @@ 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. @@ -223,4 +258,5 @@ Function to determine if a tab should be hidden. #### `getTestID` Function to get the test ID for a tab item. + - Default: Uses `route.testID` diff --git a/docs/docs/docs/guides/usage-with-react-navigation.mdx b/docs/docs/docs/guides/usage-with-react-navigation.mdx index 4c92ce3..c04a8c9 100644 --- a/docs/docs/docs/guides/usage-with-react-navigation.mdx +++ b/docs/docs/docs/guides/usage-with-react-navigation.mdx @@ -158,6 +158,23 @@ Tab views using the sidebar adaptable style have an appearance Whether to enable haptic feedback on tab press. Defaults to false. +#### `minimizeBehavior` + +Controls how the tab bar behaves when content is scrolled. + +- Type: `'automatic' | 'onScrollDown' | 'onScrollUp' | 'never'` +- Default: `undefined` (uses system default) + +Options: +- `automatic`: Platform determines the behavior +- `onScrollDown`: Tab bar minimizes when scrolling down +- `onScrollUp`: Tab bar minimizes when scrolling up +- `never`: Tab bar never minimizes + +:::note +This feature requires iOS 26.0 or later and is only available on iOS. On older versions, this prop is ignored. +::: + #### `tabLabelStyle` Object containing styles for the tab label. @@ -244,10 +261,47 @@ Function that given `{ focused: boolean }` returns `ImageSource` or `AppleIcon` SF Symbols are only supported on Apple platforms. ::: +:::warning + +Metro transformers that modify the import resolutions of images to something that is *not* React Native's `ImageSource` will break this. A notable community example of such is [react-native-svg-transformer](https://github.com/kristerkari/react-native-svg-transformer). + +For best results, avoid the use of such transformers altogether. + +If however, it is necessary to use such a transformer, you can modify your `metro.config.js` file to resolve the asset to `ImageSource` with another extension. + +For example, with `react-native-svg-transformer`, you can resolve the `svg` extension to use `react-native-svg-transformer`, and use an alternative extension like `svgx` to resolve it normally with React Native's default behavior. + +To do so, change the extension of your icon SVG file from `.svg` to `.svgx` and modify your `metro.config.js` file like so: + +```diff +config.transformer.babelTransformerPath = require.resolve( + "react-native-svg-transformer" +); +config.resolver.assetExts = config.resolver.assetExts.filter( + (ext) => ext !== "svg" +); + ++ config.resolver.assetExts = [...config.resolver.assetExts, "svgx"]; + +config.resolver.sourceExts = [...config.resolver.sourceExts, "svg"]; +``` + +Then change your require calls like so: +``` +tabBarIcon: () => require('person.svgx') +``` + +::: + + #### `tabBarBadge` Badge to show on the tab icon. +:::warning +To display a badge without text (just a dot), you need to pass a string with a space character (`" "`). +::: + #### `tabBarItemHidden` Whether the tab bar item is hidden. diff --git a/packages/react-native-bottom-tabs/android/src/main/java/com/rcttabview/RCTTabView.kt b/packages/react-native-bottom-tabs/android/src/main/java/com/rcttabview/RCTTabView.kt index ab9b1dd..41547fd 100644 --- a/packages/react-native-bottom-tabs/android/src/main/java/com/rcttabview/RCTTabView.kt +++ b/packages/react-native-bottom-tabs/android/src/main/java/com/rcttabview/RCTTabView.kt @@ -245,7 +245,7 @@ class ReactBottomNavigationView(context: Context) : LinearLayout(context) { } } - if (item.badge.isNotEmpty()) { + if (item.badge?.isNotEmpty() == true) { val badge = bottomNavigation.getOrCreateBadge(index) badge.isVisible = true badge.text = item.badge diff --git a/packages/react-native-bottom-tabs/android/src/main/java/com/rcttabview/RCTTabViewImpl.kt b/packages/react-native-bottom-tabs/android/src/main/java/com/rcttabview/RCTTabViewImpl.kt index 424e27f..0e6df67 100644 --- a/packages/react-native-bottom-tabs/android/src/main/java/com/rcttabview/RCTTabViewImpl.kt +++ b/packages/react-native-bottom-tabs/android/src/main/java/com/rcttabview/RCTTabViewImpl.kt @@ -13,7 +13,7 @@ import com.rcttabview.events.TabLongPressEvent data class TabInfo( val key: String, val title: String, - val badge: String, + val badge: String?, val activeTintColor: Int?, val hidden: Boolean, val testID: String? @@ -32,7 +32,7 @@ class RCTTabViewImpl { TabInfo( key = item.getString("key") ?: "", title = item.getString("title") ?: "", - badge = item.getString("badge") ?: "", + badge = if (item.hasKey("badge")) item.getString("badge") else null, activeTintColor = if (item.hasKey("activeTintColor")) item.getInt("activeTintColor") else null, hidden = if (item.hasKey("hidden")) item.getBoolean("hidden") else false, testID = item.getString("testID") diff --git a/packages/react-native-bottom-tabs/android/src/newarch/RCTTabViewManager.kt b/packages/react-native-bottom-tabs/android/src/newarch/RCTTabViewManager.kt index 0f221a1..59be7ca 100644 --- a/packages/react-native-bottom-tabs/android/src/newarch/RCTTabViewManager.kt +++ b/packages/react-native-bottom-tabs/android/src/newarch/RCTTabViewManager.kt @@ -163,4 +163,7 @@ class RCTTabViewManager(context: ReactApplicationContext) : override fun setScrollEdgeAppearance(view: ReactBottomNavigationView?, value: String?) { } + + override fun setMinimizeBehavior(view: ReactBottomNavigationView?, value: String?) { + } } diff --git a/packages/react-native-bottom-tabs/android/src/oldarch/RCTTabViewManager.kt b/packages/react-native-bottom-tabs/android/src/oldarch/RCTTabViewManager.kt index 9a375ec..50eb1f5 100644 --- a/packages/react-native-bottom-tabs/android/src/oldarch/RCTTabViewManager.kt +++ b/packages/react-native-bottom-tabs/android/src/oldarch/RCTTabViewManager.kt @@ -137,6 +137,10 @@ class RCTTabViewManager(context: ReactApplicationContext) : ViewGroupManager); RCT_EXPORT_VIEW_PROPERTY(sidebarAdaptable, BOOL) RCT_EXPORT_VIEW_PROPERTY(labeled, BOOL) diff --git a/packages/react-native-bottom-tabs/ios/TabAppearModifier.swift b/packages/react-native-bottom-tabs/ios/TabAppearModifier.swift new file mode 100644 index 0000000..58d1855 --- /dev/null +++ b/packages/react-native-bottom-tabs/ios/TabAppearModifier.swift @@ -0,0 +1,33 @@ +import SwiftUI + +struct TabAppearContext { + let index: Int + let tabData: TabInfo + let props: TabViewProps + let updateTabBarAppearance: () -> Void + let onSelect: (_ key: String) -> Void +} + +struct TabAppearModifier: ViewModifier { + let context: TabAppearContext + + func body(content: Content) -> some View { + content.onAppear { + #if !os(macOS) + context.updateTabBarAppearance() + #endif + + #if os(iOS) + if context.index >= 4, context.props.selectedPage != context.tabData.key { + context.onSelect(context.tabData.key) + } + #endif + } + } +} + +extension View { + func tabAppear(using context: TabAppearContext) -> some View { + self.modifier(TabAppearModifier(context: context)) + } +} diff --git a/packages/react-native-bottom-tabs/ios/TabView/AnyTabView.swift b/packages/react-native-bottom-tabs/ios/TabView/AnyTabView.swift new file mode 100644 index 0000000..168565a --- /dev/null +++ b/packages/react-native-bottom-tabs/ios/TabView/AnyTabView.swift @@ -0,0 +1,7 @@ +import SwiftUI + +public protocol AnyTabView: View { + var onLayout: (_ size: CGSize) -> Void { get } + var onSelect: (_ key: String) -> Void { get } + var updateTabBarAppearance: () -> Void { get } +} diff --git a/packages/react-native-bottom-tabs/ios/TabView/LegacyTabView.swift b/packages/react-native-bottom-tabs/ios/TabView/LegacyTabView.swift new file mode 100644 index 0000000..209bfaa --- /dev/null +++ b/packages/react-native-bottom-tabs/ios/TabView/LegacyTabView.swift @@ -0,0 +1,56 @@ +import SwiftUI + +struct LegacyTabView: AnyTabView { + @ObservedObject var props: TabViewProps + + var onLayout: (CGSize) -> Void + var onSelect: (String) -> Void + var updateTabBarAppearance: () -> Void + + @ViewBuilder + var body: some View { + TabView(selection: $props.selectedPage) { + ForEach(props.children.indices, id: \.self) { index in + renderTabItem(at: index) + } + .measureView { size in + onLayout(size) + } + } + .hideTabBar(props.tabBarHidden) + } + + @ViewBuilder + private func renderTabItem(at index: Int) -> some View { + if let tabData = props.items[safe: index] { + let isFocused = props.selectedPage == tabData.key + + if !tabData.hidden || isFocused { + let icon = props.icons[index] + let child = props.children[safe: index] ?? PlatformView() + let context = TabAppearContext( + index: index, + tabData: tabData, + props: props, + updateTabBarAppearance: updateTabBarAppearance, + onSelect: onSelect + ) + + RepresentableView(view: child) + .ignoresSafeArea(.container, edges: .all) + .tabItem { + TabItem( + title: tabData.title, + icon: icon, + sfSymbol: tabData.sfSymbol, + labeled: props.labeled + ) + .accessibilityIdentifier(tabData.testID ?? "") + .tabBadge(tabData.badge) + .tabAppear(using: context) + } + .tag(tabData.key) + } + } + } +} diff --git a/packages/react-native-bottom-tabs/ios/TabView/NewTabView.swift b/packages/react-native-bottom-tabs/ios/TabView/NewTabView.swift new file mode 100644 index 0000000..b0771d4 --- /dev/null +++ b/packages/react-native-bottom-tabs/ios/TabView/NewTabView.swift @@ -0,0 +1,56 @@ +import SwiftUI + +@available(iOS 18, macOS 15, visionOS 2, tvOS 18, *) +struct NewTabView: AnyTabView { + @ObservedObject var props: TabViewProps + + var onLayout: (CGSize) -> Void + var onSelect: (String) -> Void + var updateTabBarAppearance: () -> Void + + @ViewBuilder + var body: some View { + TabView(selection: $props.selectedPage) { + ForEach(props.children.indices, id: \.self) { index in + if let tabData = props.items[safe: index] { + let isFocused = props.selectedPage == tabData.key + + if !tabData.hidden || isFocused { + let icon = props.icons[index] + + let platformChild = props.children[safe: index] ?? PlatformView() + let child = RepresentableView(view: platformChild) + let context = TabAppearContext( + index: index, + tabData: tabData, + props: props, + updateTabBarAppearance: updateTabBarAppearance, + onSelect: onSelect + ) + + Tab(value: tabData.key) { + child + .ignoresSafeArea(.container, edges: .all) + .tabAppear(using: context) + } label: { + TabItem( + title: tabData.title, + icon: icon, + sfSymbol: tabData.sfSymbol, + labeled: props.labeled + ) + } + .badge( + (tabData.badge == nil) ? nil : tabData.badge!.isEmpty ? nil : Text(tabData.badge!) + ) + .accessibilityIdentifier(tabData.testID ?? "") + } + } + } + } + .measureView { size in + onLayout(size) + } + .hideTabBar(props.tabBarHidden) + } +} diff --git a/packages/react-native-bottom-tabs/ios/TabViewImpl.swift b/packages/react-native-bottom-tabs/ios/TabViewImpl.swift index f87934f..4fb8050 100644 --- a/packages/react-native-bottom-tabs/ios/TabViewImpl.swift +++ b/packages/react-native-bottom-tabs/ios/TabViewImpl.swift @@ -3,16 +3,35 @@ import React import SwiftUI @_spi(Advanced) import SwiftUIIntrospect -/** - SwiftUI implementation of TabView used to render React Native views. - */ +/// SwiftUI implementation of TabView used to render React Native views. struct TabViewImpl: View { @ObservedObject var props: TabViewProps -#if os(macOS) - @Weak var tabBar: NSTabView? -#else - @Weak var tabBar: UITabBar? -#endif + #if os(macOS) + @Weak var tabBar: NSTabView? + #else + @Weak var tabBar: UITabBar? + #endif + + @ViewBuilder + var tabContent: some View { + if #available(iOS 18, macOS 15, visionOS 2, tvOS 18, *) { + NewTabView( + props: props, + onLayout: onLayout, + onSelect: onSelect + ) { + updateTabBarAppearance(props: props, tabBar: tabBar) + } + } else { + LegacyTabView( + props: props, + onLayout: onLayout, + onSelect: onSelect + ) { + updateTabBarAppearance(props: props, tabBar: tabBar) + } + } + } var onSelect: (_ key: String) -> Void var onLongPress: (_ key: String) -> Void @@ -20,127 +39,82 @@ struct TabViewImpl: View { var onTabBarMeasured: (_ height: Int) -> Void var body: some View { - TabView(selection: $props.selectedPage) { - ForEach(props.children.indices, id: \.self) { index in - renderTabItem(at: index) - } - .measureView { size in - onLayout(size) - } - } -#if !os(tvOS) && !os(macOS) && !os(visionOS) - .onTabItemEvent { index, isLongPress in - let item = props.filteredItems[safe: index] - guard let key = item?.key else { return } - - if isLongPress { - onLongPress(key) - emitHapticFeedback(longPress: true) - } else { - onSelect(key) - emitHapticFeedback() - } - } -#endif - .introspectTabView { tabController in -#if os(macOS) - tabBar = tabController -#else - tabBar = tabController.tabBar - if !props.tabBarHidden { - onTabBarMeasured( - Int(tabController.tabBar.frame.size.height) - ) - } -#endif - } -#if !os(macOS) - .configureAppearance(props: props, tabBar: tabBar) -#endif - .tintColor(props.selectedActiveTintColor) - .getSidebarAdaptable(enabled: props.sidebarAdaptable ?? false) - .onChange(of: props.selectedPage ?? "") { newValue in -#if !os(macOS) - if props.disablePageAnimations { - UIView.setAnimationsEnabled(false) - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - UIView.setAnimationsEnabled(true) + tabContent + .tabBarMinimizeBehavior(props.minimizeBehavior) + #if !os(tvOS) && !os(macOS) && !os(visionOS) + .onTabItemEvent { index, isLongPress in + let item = props.filteredItems[safe: index] + guard let key = item?.key else { return } + + if isLongPress { + onLongPress(key) + emitHapticFeedback(longPress: true) + } else { + onSelect(key) + emitHapticFeedback() + } } + #endif + .introspectTabView { tabController in + #if os(macOS) + tabBar = tabController + #else + tabBar = tabController.tabBar + if !props.tabBarHidden { + onTabBarMeasured( + Int(tabController.tabBar.frame.size.height) + ) + } + #endif + } + #if !os(macOS) + .configureAppearance(props: props, tabBar: tabBar) + #endif + .tintColor(props.selectedActiveTintColor) + .getSidebarAdaptable(enabled: props.sidebarAdaptable ?? false) + .onChange(of: props.selectedPage ?? "") { newValue in + #if !os(macOS) + if props.disablePageAnimations { + UIView.setAnimationsEnabled(false) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + UIView.setAnimationsEnabled(true) + } + } + #endif + #if os(tvOS) || os(macOS) || os(visionOS) + onSelect(newValue) + #endif } -#endif -#if os(tvOS) || os(macOS) || os(visionOS) - onSelect(newValue) -#endif - } - } - - @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] ?? PlatformView() - let icon = props.icons[index] - - RepresentableView(view: child) - .ignoresSafeArea(.container, edges: .all) - .tabItem { - TabItem( - title: tabData?.title, - icon: icon, - sfSymbol: tabData?.sfSymbol, - labeled: props.labeled - ) - .accessibilityIdentifier(tabData?.testID ?? "") - } - .tag(tabData?.key) - .tabBadge(tabData?.badge) - .hideTabBar(props.tabBarHidden) - .onAppear { -#if !os(macOS) - updateTabBarAppearance(props: props, tabBar: tabBar) -#endif - -#if os(iOS) - 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 { - return - } + #if os(iOS) + if !props.hapticFeedbackEnabled { + return + } - if longPress { - UINotificationFeedbackGenerator().notificationOccurred(.success) - } else { - UISelectionFeedbackGenerator().selectionChanged() - } -#endif + if longPress { + UINotificationFeedbackGenerator().notificationOccurred(.success) + } else { + UISelectionFeedbackGenerator().selectionChanged() + } + #endif } } #if !os(macOS) -private func updateTabBarAppearance(props: TabViewProps, tabBar: UITabBar?) { - guard let tabBar else { return } + private func updateTabBarAppearance(props: TabViewProps, tabBar: UITabBar?) { + guard let tabBar else { return } - tabBar.isHidden = props.tabBarHidden + tabBar.isHidden = props.tabBarHidden - if props.scrollEdgeAppearance == "transparent" { - configureTransparentAppearance(tabBar: tabBar, props: props) - return - } + if props.scrollEdgeAppearance == "transparent" { + configureTransparentAppearance(tabBar: tabBar, props: props) + return + } - configureStandardAppearance(tabBar: tabBar, props: props) -} + configureStandardAppearance(tabBar: tabBar, props: props) + } #endif private func createFontAttributes( @@ -169,85 +143,85 @@ private func createFontAttributes( } #if os(tvOS) -let tabBarDefaultFontSize: CGFloat = 30.0 + let tabBarDefaultFontSize: CGFloat = 30.0 #else -let tabBarDefaultFontSize: CGFloat = UIFont.smallSystemFontSize + let tabBarDefaultFontSize: CGFloat = UIFont.smallSystemFontSize #endif #if !os(macOS) -private func configureTransparentAppearance(tabBar: UITabBar, props: TabViewProps) { - tabBar.barTintColor = props.barTintColor -#if !os(visionOS) - tabBar.isTranslucent = props.translucent -#endif - tabBar.unselectedItemTintColor = props.inactiveTintColor - - guard let items = tabBar.items else { return } - - let fontSize = props.fontSize != nil ? CGFloat(props.fontSize!) : tabBarDefaultFontSize - let attributes = createFontAttributes( - size: fontSize, - family: props.fontFamily, - weight: props.fontWeight, - inactiveTintColor: nil - ) + private func configureTransparentAppearance(tabBar: UITabBar, props: TabViewProps) { + tabBar.barTintColor = props.barTintColor + #if !os(visionOS) + tabBar.isTranslucent = props.translucent + #endif + tabBar.unselectedItemTintColor = props.inactiveTintColor + + guard let items = tabBar.items else { return } + + let fontSize = props.fontSize != nil ? CGFloat(props.fontSize!) : tabBarDefaultFontSize + let attributes = createFontAttributes( + size: fontSize, + family: props.fontFamily, + weight: props.fontWeight, + inactiveTintColor: nil + ) - items.forEach { item in - item.setTitleTextAttributes(attributes, for: .normal) + items.forEach { item in + item.setTitleTextAttributes(attributes, for: .normal) + } } -} -private func configureStandardAppearance(tabBar: UITabBar, props: TabViewProps) { - let appearance = UITabBarAppearance() + private func configureStandardAppearance(tabBar: UITabBar, props: TabViewProps) { + let appearance = UITabBarAppearance() - // Configure background - switch props.scrollEdgeAppearance { - case "opaque": - appearance.configureWithOpaqueBackground() - default: - appearance.configureWithDefaultBackground() - } + // Configure background + switch props.scrollEdgeAppearance { + case "opaque": + appearance.configureWithOpaqueBackground() + default: + appearance.configureWithDefaultBackground() + } - if props.translucent == false { - appearance.configureWithOpaqueBackground() - } + if props.translucent == false { + appearance.configureWithOpaqueBackground() + } - if props.barTintColor != nil { - appearance.backgroundColor = props.barTintColor - } + if props.barTintColor != nil { + appearance.backgroundColor = props.barTintColor + } - // Configure item appearance - let itemAppearance = UITabBarItemAppearance() - let fontSize = props.fontSize != nil ? CGFloat(props.fontSize!) : tabBarDefaultFontSize + // Configure item appearance + let itemAppearance = UITabBarItemAppearance() + let fontSize = props.fontSize != nil ? CGFloat(props.fontSize!) : tabBarDefaultFontSize - var attributes = createFontAttributes( - size: fontSize, - family: props.fontFamily, - weight: props.fontWeight, - inactiveTintColor: props.inactiveTintColor - ) + var attributes = createFontAttributes( + size: fontSize, + family: props.fontFamily, + weight: props.fontWeight, + inactiveTintColor: props.inactiveTintColor + ) - if let inactiveTintColor = props.inactiveTintColor { - attributes[.foregroundColor] = inactiveTintColor - } + if let inactiveTintColor = props.inactiveTintColor { + attributes[.foregroundColor] = inactiveTintColor + } - if let inactiveTintColor = props.inactiveTintColor { - itemAppearance.normal.iconColor = inactiveTintColor - } + if let inactiveTintColor = props.inactiveTintColor { + itemAppearance.normal.iconColor = inactiveTintColor + } - itemAppearance.normal.titleTextAttributes = attributes + itemAppearance.normal.titleTextAttributes = attributes - // Apply item appearance to all layouts - appearance.stackedLayoutAppearance = itemAppearance - appearance.inlineLayoutAppearance = itemAppearance - appearance.compactInlineLayoutAppearance = itemAppearance + // Apply item appearance to all layouts + appearance.stackedLayoutAppearance = itemAppearance + appearance.inlineLayoutAppearance = itemAppearance + appearance.compactInlineLayoutAppearance = itemAppearance - // Apply final appearance - tabBar.standardAppearance = appearance - if #available(iOS 15.0, *) { - tabBar.scrollEdgeAppearance = appearance.copy() + // Apply final appearance + tabBar.standardAppearance = appearance + if #available(iOS 15.0, *) { + tabBar.scrollEdgeAppearance = appearance.copy() + } } -} #endif extension View { @@ -255,11 +229,11 @@ extension View { func getSidebarAdaptable(enabled: Bool) -> some View { if #available(iOS 18.0, macOS 15.0, tvOS 18.0, visionOS 2.0, *) { if enabled { -#if compiler(>=6.0) - self.tabViewStyle(.sidebarAdaptable) -#else - self -#endif + #if compiler(>=6.0) + self.tabViewStyle(.sidebarAdaptable) + #else + self + #endif } else { self } @@ -271,12 +245,12 @@ extension View { @ViewBuilder func tabBadge(_ data: String?) -> some View { if #available(iOS 15.0, macOS 15.0, visionOS 2.0, tvOS 15.0, *) { - if let data, !data.isEmpty { -#if !os(tvOS) - self.badge(data) -#else - self -#endif + if let data { + #if !os(tvOS) + self.badge(data) + #else + self + #endif } else { self } @@ -285,39 +259,39 @@ extension View { } } -#if !os(macOS) - @ViewBuilder - func configureAppearance(props: TabViewProps, tabBar: UITabBar?) -> some View { - self - .onChange(of: props.barTintColor) { _ in - updateTabBarAppearance(props: props, tabBar: tabBar) - } - .onChange(of: props.scrollEdgeAppearance) { _ in - updateTabBarAppearance(props: props, tabBar: tabBar) - } - .onChange(of: props.translucent) { _ in - updateTabBarAppearance(props: props, tabBar: tabBar) - } - .onChange(of: props.inactiveTintColor) { _ in - updateTabBarAppearance(props: props, tabBar: tabBar) - } - .onChange(of: props.selectedActiveTintColor) { _ in - updateTabBarAppearance(props: props, tabBar: tabBar) - } - .onChange(of: props.fontSize) { _ in - updateTabBarAppearance(props: props, tabBar: tabBar) - } - .onChange(of: props.fontFamily) { _ in - updateTabBarAppearance(props: props, tabBar: tabBar) - } - .onChange(of: props.fontWeight) { _ in - updateTabBarAppearance(props: props, tabBar: tabBar) - } - .onChange(of: props.tabBarHidden) { newValue in - tabBar?.isHidden = newValue - } - } -#endif + #if !os(macOS) + @ViewBuilder + func configureAppearance(props: TabViewProps, tabBar: UITabBar?) -> some View { + self + .onChange(of: props.barTintColor) { _ in + updateTabBarAppearance(props: props, tabBar: tabBar) + } + .onChange(of: props.scrollEdgeAppearance) { _ in + updateTabBarAppearance(props: props, tabBar: tabBar) + } + .onChange(of: props.translucent) { _ in + updateTabBarAppearance(props: props, tabBar: tabBar) + } + .onChange(of: props.inactiveTintColor) { _ in + updateTabBarAppearance(props: props, tabBar: tabBar) + } + .onChange(of: props.selectedActiveTintColor) { _ in + updateTabBarAppearance(props: props, tabBar: tabBar) + } + .onChange(of: props.fontSize) { _ in + updateTabBarAppearance(props: props, tabBar: tabBar) + } + .onChange(of: props.fontFamily) { _ in + updateTabBarAppearance(props: props, tabBar: tabBar) + } + .onChange(of: props.fontWeight) { _ in + updateTabBarAppearance(props: props, tabBar: tabBar) + } + .onChange(of: props.tabBarHidden) { newValue in + tabBar?.isHidden = newValue + } + } + #endif @ViewBuilder func tintColor(_ color: PlatformColor?) -> some View { @@ -333,22 +307,39 @@ extension View { } } + @ViewBuilder + func tabBarMinimizeBehavior(_ behavior: MinimizeBehavior?) -> some View { + #if compiler(>=6.2) + if #available(iOS 26.0, *) { + if let behavior { + self.tabBarMinimizeBehavior(behavior.convert()) + } else { + self + } + } else { + self + } + #else + self + #endif + } + @ViewBuilder func hideTabBar(_ flag: Bool) -> some View { -#if !os(macOS) - if flag { - if #available(iOS 16.0, tvOS 16.0, *) { - self.toolbar(.hidden, for: .tabBar) + #if !os(macOS) + if flag { + if #available(iOS 16.0, tvOS 16.0, *) { + self.toolbar(.hidden, for: .tabBar) + } else { + // We fallback to isHidden on UITabBar + self + } } else { - // We fallback to isHidden on UITabBar self } - } else { + #else self - } -#else - self -#endif + #endif } // Allows TabView to use unfilled SFSymbols. diff --git a/packages/react-native-bottom-tabs/ios/TabViewProps.swift b/packages/react-native-bottom-tabs/ios/TabViewProps.swift index 131c7cf..833aca9 100644 --- a/packages/react-native-bottom-tabs/ios/TabViewProps.swift +++ b/packages/react-native-bottom-tabs/ios/TabViewProps.swift @@ -1,5 +1,28 @@ import SwiftUI +internal enum MinimizeBehavior: String { + case automatic + case never + case onScrollUp + case onScrollDown + +#if compiler(>=6.2) + @available(iOS 26.0, *) + func convert() -> TabBarMinimizeBehavior { + switch self { + case .automatic: + return .automatic + case .never: + return .never + case .onScrollUp: + return .onScrollUp + case .onScrollDown: + return .onScrollDown + } + } +#endif +} + /** Props that component accepts. SwiftUI view gets re-rendered when ObservableObject changes. */ @@ -10,6 +33,7 @@ class TabViewProps: ObservableObject { @Published var icons: [Int: PlatformImage] = [:] @Published var sidebarAdaptable: Bool? @Published var labeled: Bool = false + @Published var minimizeBehavior: MinimizeBehavior? @Published var scrollEdgeAppearance: String? @Published var barTintColor: PlatformColor? @Published var activeTintColor: PlatformColor? diff --git a/packages/react-native-bottom-tabs/ios/TabViewProvider.swift b/packages/react-native-bottom-tabs/ios/TabViewProvider.swift index 135c9a9..9a30471 100644 --- a/packages/react-native-bottom-tabs/ios/TabViewProvider.swift +++ b/packages/react-native-bottom-tabs/ios/TabViewProvider.swift @@ -8,7 +8,7 @@ import SwiftUI public final class TabInfo: NSObject { public let key: String public let title: String - public let badge: String + public let badge: String? public let sfSymbol: String public let activeTintColor: PlatformColor? public let hidden: Bool @@ -17,7 +17,7 @@ public final class TabInfo: NSObject { public init( key: String, title: String, - badge: String, + badge: String?, sfSymbol: String, activeTintColor: PlatformColor?, hidden: Bool, @@ -102,6 +102,12 @@ public final class TabInfo: NSObject { } } + @objc public var minimizeBehavior: NSString? { + didSet { + props.minimizeBehavior = MinimizeBehavior(rawValue: minimizeBehavior as? String ?? "") + } + } + @objc public var translucent: Bool = true { didSet { props.translucent = translucent @@ -263,7 +269,7 @@ public final class TabInfo: NSObject { TabInfo( key: itemDict["key"] as? String ?? "", title: itemDict["title"] as? String ?? "", - badge: itemDict["badge"] as? String ?? "", + badge: itemDict["badge"] as? String, sfSymbol: itemDict["sfSymbol"] as? String ?? "", activeTintColor: RCTConvert.uiColor(itemDict["activeTintColor"] as? NSNumber), hidden: itemDict["hidden"] as? Bool ?? false, diff --git a/packages/react-native-bottom-tabs/src/TabView.tsx b/packages/react-native-bottom-tabs/src/TabView.tsx index dd71356..ed30dd1 100644 --- a/packages/react-native-bottom-tabs/src/TabView.tsx +++ b/packages/react-native-bottom-tabs/src/TabView.tsx @@ -54,6 +54,11 @@ interface Props { * Describes the appearance attributes for the tabBar to use when an observable scroll view is scrolled to the bottom. (iOS only) */ scrollEdgeAppearance?: 'default' | 'opaque' | 'transparent'; + + /** + * Behavior for minimizing the tab bar. (iOS 26+) + */ + minimizeBehavior?: 'automatic' | 'onScrollDown' | 'onScrollUp' | 'never'; /** * Active tab color. */ @@ -170,10 +175,10 @@ const TabView = ({ renderScene, onIndexChange, onTabLongPress, - getBadge, rippleColor, tabBarActiveTintColor: activeTintColor, tabBarInactiveTintColor: inactiveTintColor, + getBadge = ({ route }: { route: Route }) => route.badge, getLazy = ({ route }: { route: Route }) => route.lazy, getLabelText = ({ route }: { route: Route }) => route.title, getIcon = ({ route, focused }: { route: Route; focused: boolean }) => diff --git a/packages/react-native-bottom-tabs/src/TabViewNativeComponent.ts b/packages/react-native-bottom-tabs/src/TabViewNativeComponent.ts index 1a7224d..4eca038 100644 --- a/packages/react-native-bottom-tabs/src/TabViewNativeComponent.ts +++ b/packages/react-native-bottom-tabs/src/TabViewNativeComponent.ts @@ -52,6 +52,7 @@ export interface TabViewProps extends ViewProps { disablePageAnimations?: boolean; activeIndicatorColor?: ColorValue; hapticFeedbackEnabled?: boolean; + minimizeBehavior?: string; fontFamily?: string; fontWeight?: string; fontSize?: Int32;