Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/eight-ducks-rest.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'react-native-bottom-tabs': minor
'@bottom-tabs/react-navigation': minor
---

feat: introduce preventsDefault option
4 changes: 2 additions & 2 deletions apps/example/src/Examples/NativeBottomTabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}}
/>
<Tab.Screen
Expand Down
7 changes: 7 additions & 0 deletions docs/docs/docs/guides/standalone-usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,7 @@ Each route in the `routes` array can have the following properties:
- `freezeOnBlur`: Whether to freeze the tab's content when it's not visible
- `role`: A value that defines the purpose of the tab
- `style`: Style object for the component wrapping the screen content
- `preventsDefault`: Whether to prevent default tab switching behavior when pressed

### Helper Props

Expand Down Expand Up @@ -274,3 +275,9 @@ Function to get the role for a tab item.
Function to get the style for a tab scene.

- Default: Uses `route.style`

#### `getPreventsDefault`

Function to determine if a tab should prevent default switching behavior when pressed.

- Default: Uses `route.preventsDefault`
18 changes: 15 additions & 3 deletions docs/docs/docs/guides/usage-with-react-navigation.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ export default function App() {
component={SettingsScreen}
options={{
tabBarIcon: () => ({ sfSymbol: 'gear' }),
preventsDefault: true, // Prevents automatic tab switching
}}
/>
</Tab.Navigator>
Expand Down Expand Up @@ -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`

Expand Down Expand Up @@ -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
// ...
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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];
}
Expand Down
19 changes: 14 additions & 5 deletions packages/react-native-bottom-tabs/ios/TabItemEventModifier.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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 {
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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))
}
}
Expand Down
3 changes: 2 additions & 1 deletion packages/react-native-bottom-tabs/ios/TabViewImpl.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -53,6 +53,7 @@ struct TabViewImpl: View {
onSelect(key)
emitHapticFeedback()
}
return item?.preventsDefault ?? false
}
#endif
.introspectTabView { tabController in
Expand Down
8 changes: 6 additions & 2 deletions packages/react-native-bottom-tabs/ios/TabViewProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand All @@ -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()
}
}
Expand Down Expand Up @@ -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
)
)
}
Expand Down
7 changes: 7 additions & 0 deletions packages/react-native-bottom-tabs/src/TabView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,10 @@ interface Props<Route extends BaseRoute> {
* 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.
*/
Expand Down Expand Up @@ -204,6 +208,7 @@ const TabView = <Route extends BaseRoute>({
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,
Expand Down Expand Up @@ -276,6 +281,7 @@ const TabView = <Route extends BaseRoute>({
hidden: getHidden?.({ route }),
testID: getTestID?.({ route }),
role: getRole?.({ route }),
preventsDefault: getPreventsDefault?.({ route }),
};
}),
[
Expand All @@ -287,6 +293,7 @@ const TabView = <Route extends BaseRoute>({
getHidden,
getTestID,
getRole,
getPreventsDefault,
]
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export type TabViewItems = ReadonlyArray<{
hidden?: boolean;
testID?: string;
role?: string;
preventsDefault?: boolean;
}>;

export interface TabViewProps extends ViewProps {
Expand Down
1 change: 1 addition & 0 deletions packages/react-native-bottom-tabs/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export type BaseRoute = {
role?: TabRole;
freezeOnBlur?: boolean;
style?: StyleProp<ViewStyle>;
preventsDefault?: boolean;
};

export type NavigationState<Route extends BaseRoute> = {
Expand Down
5 changes: 5 additions & 0 deletions packages/react-navigation/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,10 @@ export type NativeBottomTabNavigationOptions = {
* Style object for the component wrapping the screen content.
*/
sceneStyle?: StyleProp<ViewStyle>;
/**
* Whether to prevent default action of the tab. Defaults to `false`.
*/
preventsDefault?: boolean;
};

export type NativeBottomTabDescriptor = Descriptor<
Expand Down Expand Up @@ -145,6 +149,7 @@ export type NativeBottomTabNavigationConfig = Partial<
| 'tabBar'
| 'getFreezeOnBlur'
| 'getSceneStyle'
| 'getPreventsDefault'
>
> & {
tabBar?: (props: BottomTabBarProps) => React.ReactNode;
Expand Down
8 changes: 7 additions & 1 deletion packages/react-navigation/src/views/NativeBottomTabView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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({
Expand Down