diff --git a/.changeset/eight-ducks-rest.md b/.changeset/eight-ducks-rest.md new file mode 100644 index 00000000..caa21bfd --- /dev/null +++ b/.changeset/eight-ducks-rest.md @@ -0,0 +1,6 @@ +--- +'react-native-bottom-tabs': minor +'@bottom-tabs/react-navigation': minor +--- + +feat: introduce preventsDefault option diff --git a/apps/example/src/Examples/NativeBottomTabs.tsx b/apps/example/src/Examples/NativeBottomTabs.tsx index 1fee7784..190db787 100644 --- a/apps/example/src/Examples/NativeBottomTabs.tsx +++ b/apps/example/src/Examples/NativeBottomTabs.tsx @@ -66,14 +66,14 @@ function NativeBottomTabs() { name="Contacts" component={Contacts} listeners={{ - tabPress: (e) => { - e.preventDefault(); + tabPress: () => { console.log('Contacts tab press prevented'); }, }} options={{ tabBarIcon: () => require('../../assets/icons/person_dark.png'), tabBarActiveTintColor: 'yellow', + preventsDefault: true, }} /> ({ sfSymbol: 'gear' }), + preventsDefault: true, // Prevents automatic tab switching }} /> @@ -321,6 +322,17 @@ Boolean indicating whether to prevent inactive screens from re-rendering. Defaul It's working separately from `enableFreeze()` in `react-native-screens`. So settings won't be shared between them. +#### `preventsDefault` + +Whether to prevent default tab switching behavior when this tab is pressed. This is useful when you want to handle tab press events manually without switching tabs. + +- Type: `boolean` +- Default: `false` + +:::note +Due to iOS 26's new tab switching animations, controlling tab switching from JavaScript can cause significant delays. The `preventsDefault` option allows you to define this behavior statically to avoid animation delays. +::: + #### `tabBarButtonTestID` @@ -361,9 +373,9 @@ To prevent the default behavior, you can call `event.preventDefault`: ```tsx React.useEffect(() => { const unsubscribe = navigation.addListener('tabPress', (e) => { - // Prevent default behavior - e.preventDefault(); - + // Note: For iOS 26+, use the `preventsDefault` option instead of `e.preventDefault()` + // to avoid animation delays + // Do something manually // ... }); diff --git a/packages/react-native-bottom-tabs/ios/Fabric/RCTTabViewComponentView.mm b/packages/react-native-bottom-tabs/ios/Fabric/RCTTabViewComponentView.mm index 9eee1811..9b5a2b37 100644 --- a/packages/react-native-bottom-tabs/ios/Fabric/RCTTabViewComponentView.mm +++ b/packages/react-native-bottom-tabs/ios/Fabric/RCTTabViewComponentView.mm @@ -38,7 +38,8 @@ lhs.activeTintColor == rhs.activeTintColor && lhs.hidden == rhs.hidden && lhs.testID == rhs.testID && - lhs.role == rhs.role; + lhs.role == rhs.role && + lhs.preventsDefault == rhs.preventsDefault; } bool operator!=(const RNCTabViewItemsStruct& lhs, const RNCTabViewItemsStruct& rhs) { @@ -189,7 +190,9 @@ - (void)updateProps:(Props::Shared const &)props oldProps:(Props::Shared const & activeTintColor:RCTUIColorFromSharedColor(item.activeTintColor) hidden:item.hidden testID:RCTNSStringFromStringNilIfEmpty(item.testID) - role:RCTNSStringFromStringNilIfEmpty(item.role)]; + role:RCTNSStringFromStringNilIfEmpty(item.role) + preventsDefault:item.preventsDefault + ]; [result addObject:tabInfo]; } diff --git a/packages/react-native-bottom-tabs/ios/TabItemEventModifier.swift b/packages/react-native-bottom-tabs/ios/TabItemEventModifier.swift index d298ad3b..322a56c7 100644 --- a/packages/react-native-bottom-tabs/ios/TabItemEventModifier.swift +++ b/packages/react-native-bottom-tabs/ios/TabItemEventModifier.swift @@ -7,7 +7,7 @@ import UIKit #if !os(macOS) && !os(visionOS) private final class TabBarDelegate: NSObject, UITabBarControllerDelegate { - var onClick: ((_ index: Int) -> Void)? + var onClick: ((_ index: Int) -> Bool)? func tabBarController(_ tabBarController: UITabBarController, shouldSelect viewController: UIViewController) -> Bool { #if os(iOS) @@ -17,15 +17,21 @@ private final class TabBarDelegate: NSObject, UITabBarControllerDelegate { } #endif + // Unfortunately, due to iOS 26 new tab switching animations, controlling state from JavaScript is causing significant delays when switching tabs. + // See: https://github.com/callstackincubator/react-native-bottom-tabs/issues/383 + // Due to this, whether the tab prevents default has to be defined statically. if let index = tabBarController.viewControllers?.firstIndex(of: viewController) { - onClick?(index) + let defaultPrevented = onClick?(index) ?? false + + return !defaultPrevented } + return false } } struct TabItemEventModifier: ViewModifier { - let onTabEvent: (_ key: Int, _ isLongPress: Bool) -> Void + let onTabEvent: (_ key: Int, _ isLongPress: Bool) -> Bool private let delegate = TabBarDelegate() func body(content: Content) -> some View { @@ -54,7 +60,7 @@ struct TabItemEventModifier: ViewModifier { } // Create gesture handler - let handler = LongPressGestureHandler(tabBar: tabController.tabBar, handler: onTabEvent) + let handler = LongPressGestureHandler(tabBar: tabController.tabBar, handler: { key, isLongPress in _ = onTabEvent(key,isLongPress) }) let gesture = UILongPressGestureRecognizer(target: handler, action: #selector(LongPressGestureHandler.handleLongPress(_:))) gesture.minimumPressDuration = 0.5 @@ -103,7 +109,10 @@ private class LongPressGestureHandler: NSObject { } extension View { - func onTabItemEvent(_ handler: @escaping (Int, Bool) -> Void) -> some View { + /** + Event for tab items. Returns true if should prevent default (switching tabs). + */ + func onTabItemEvent(_ handler: @escaping (Int, Bool) -> Bool) -> some View { modifier(TabItemEventModifier(onTabEvent: handler)) } } diff --git a/packages/react-native-bottom-tabs/ios/TabViewImpl.swift b/packages/react-native-bottom-tabs/ios/TabViewImpl.swift index 7b695c9d..60bfd406 100644 --- a/packages/react-native-bottom-tabs/ios/TabViewImpl.swift +++ b/packages/react-native-bottom-tabs/ios/TabViewImpl.swift @@ -44,7 +44,7 @@ struct TabViewImpl: View { #if !os(tvOS) && !os(macOS) && !os(visionOS) .onTabItemEvent { index, isLongPress in let item = props.filteredItems[safe: index] - guard let key = item?.key else { return } + guard let key = item?.key else { return false } if isLongPress { onLongPress(key) @@ -53,6 +53,7 @@ struct TabViewImpl: View { onSelect(key) emitHapticFeedback() } + return item?.preventsDefault ?? false } #endif .introspectTabView { tabController in diff --git a/packages/react-native-bottom-tabs/ios/TabViewProvider.swift b/packages/react-native-bottom-tabs/ios/TabViewProvider.swift index 6920fe03..17e6f9a3 100644 --- a/packages/react-native-bottom-tabs/ios/TabViewProvider.swift +++ b/packages/react-native-bottom-tabs/ios/TabViewProvider.swift @@ -14,6 +14,7 @@ public final class TabInfo: NSObject { public let hidden: Bool public let testID: String? public let role: TabBarRole? + public let preventsDefault: Bool public init( key: String, @@ -23,7 +24,8 @@ public final class TabInfo: NSObject { activeTintColor: PlatformColor?, hidden: Bool, testID: String?, - role: String? + role: String?, + preventsDefault: Bool = false ) { self.key = key self.title = title @@ -33,6 +35,7 @@ public final class TabInfo: NSObject { self.hidden = hidden self.testID = testID self.role = TabBarRole(rawValue: role ?? "") + self.preventsDefault = preventsDefault super.init() } } @@ -288,7 +291,8 @@ public final class TabInfo: NSObject { activeTintColor: RCTConvert.uiColor(itemDict["activeTintColor"] as? NSNumber), hidden: itemDict["hidden"] as? Bool ?? false, testID: itemDict["testID"] as? String ?? "", - role: itemDict["role"] as? String + role: itemDict["role"] as? String, + preventsDefault: itemDict["preventsDefault"] as? Bool ?? false ) ) } diff --git a/packages/react-native-bottom-tabs/src/TabView.tsx b/packages/react-native-bottom-tabs/src/TabView.tsx index 7b1d5fb2..fd41570f 100644 --- a/packages/react-native-bottom-tabs/src/TabView.tsx +++ b/packages/react-native-bottom-tabs/src/TabView.tsx @@ -107,6 +107,10 @@ interface Props { * Get active tint color for the tab, uses `route.activeTintColor` by default. */ getActiveTintColor?: (props: { route: Route }) => ColorValue | undefined; + /** + * Determines whether the tab prevents default action (switching tabs) on press, uses `route.preventsDefault` by default. + */ + getPreventsDefault?: (props: { route: Route }) => boolean | undefined; /** * Get icon for the tab, uses `route.focusedIcon` by default. */ @@ -204,6 +208,7 @@ const TabView = ({ getTestID = ({ route }: { route: Route }) => route.testID, getRole = ({ route }: { route: Route }) => route.role, getSceneStyle = ({ route }: { route: Route }) => route.style, + getPreventsDefault = ({ route }: { route: Route }) => route.preventsDefault, hapticFeedbackEnabled = false, // Android's native behavior is to show labels when there are less than 4 tabs. We leave it as undefined to use the platform default behavior. labeled = Platform.OS !== 'android' ? true : undefined, @@ -276,6 +281,7 @@ const TabView = ({ hidden: getHidden?.({ route }), testID: getTestID?.({ route }), role: getRole?.({ route }), + preventsDefault: getPreventsDefault?.({ route }), }; }), [ @@ -287,6 +293,7 @@ const TabView = ({ getHidden, getTestID, getRole, + getPreventsDefault, ] ); diff --git a/packages/react-native-bottom-tabs/src/TabViewNativeComponent.ts b/packages/react-native-bottom-tabs/src/TabViewNativeComponent.ts index a91a6ed9..f3746888 100644 --- a/packages/react-native-bottom-tabs/src/TabViewNativeComponent.ts +++ b/packages/react-native-bottom-tabs/src/TabViewNativeComponent.ts @@ -31,6 +31,7 @@ export type TabViewItems = ReadonlyArray<{ hidden?: boolean; testID?: string; role?: string; + preventsDefault?: boolean; }>; export interface TabViewProps extends ViewProps { diff --git a/packages/react-native-bottom-tabs/src/types.ts b/packages/react-native-bottom-tabs/src/types.ts index c8eda0b0..4f6e01d2 100644 --- a/packages/react-native-bottom-tabs/src/types.ts +++ b/packages/react-native-bottom-tabs/src/types.ts @@ -20,6 +20,7 @@ export type BaseRoute = { role?: TabRole; freezeOnBlur?: boolean; style?: StyleProp; + preventsDefault?: boolean; }; export type NavigationState = { diff --git a/packages/react-navigation/src/types.ts b/packages/react-navigation/src/types.ts index 6713f841..c0d89a41 100644 --- a/packages/react-navigation/src/types.ts +++ b/packages/react-navigation/src/types.ts @@ -106,6 +106,10 @@ export type NativeBottomTabNavigationOptions = { * Style object for the component wrapping the screen content. */ sceneStyle?: StyleProp; + /** + * Whether to prevent default action of the tab. Defaults to `false`. + */ + preventsDefault?: boolean; }; export type NativeBottomTabDescriptor = Descriptor< @@ -145,6 +149,7 @@ export type NativeBottomTabNavigationConfig = Partial< | 'tabBar' | 'getFreezeOnBlur' | 'getSceneStyle' + | 'getPreventsDefault' > > & { tabBar?: (props: BottomTabBarProps) => React.ReactNode; diff --git a/packages/react-navigation/src/views/NativeBottomTabView.tsx b/packages/react-navigation/src/views/NativeBottomTabView.tsx index fd1c17b3..75aab81e 100644 --- a/packages/react-navigation/src/views/NativeBottomTabView.tsx +++ b/packages/react-navigation/src/views/NativeBottomTabView.tsx @@ -79,6 +79,9 @@ export default function NativeBottomTabView({ target: route.key, }); }} + getPreventsDefault={({ route }) => + descriptors[route.key]?.options.preventsDefault + } onIndexChange={(index) => { const route = state.routes[index]; if (!route) { @@ -91,7 +94,10 @@ export default function NativeBottomTabView({ canPreventDefault: true, }); - if (event.defaultPrevented) { + if ( + event.defaultPrevented || + descriptors[route.key]?.options.preventsDefault + ) { return; } else { navigation.dispatch({