Skip to content

Commit f564fde

Browse files
authored
fix: iOS 26 switching animation delay (#408)
1 parent fd1e0df commit f564fde

File tree

13 files changed

+78
-16
lines changed

13 files changed

+78
-16
lines changed

.changeset/eight-ducks-rest.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'react-native-bottom-tabs': minor
3+
'@bottom-tabs/react-navigation': minor
4+
---
5+
6+
feat: introduce preventsDefault option

apps/example/src/Examples/NativeBottomTabs.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,14 +66,14 @@ function NativeBottomTabs() {
6666
name="Contacts"
6767
component={Contacts}
6868
listeners={{
69-
tabPress: (e) => {
70-
e.preventDefault();
69+
tabPress: () => {
7170
console.log('Contacts tab press prevented');
7271
},
7372
}}
7473
options={{
7574
tabBarIcon: () => require('../../assets/icons/person_dark.png'),
7675
tabBarActiveTintColor: 'yellow',
76+
preventsDefault: true,
7777
}}
7878
/>
7979
<Tab.Screen

docs/docs/docs/guides/standalone-usage.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,7 @@ Each route in the `routes` array can have the following properties:
214214
- `freezeOnBlur`: Whether to freeze the tab's content when it's not visible
215215
- `role`: A value that defines the purpose of the tab
216216
- `style`: Style object for the component wrapping the screen content
217+
- `preventsDefault`: Whether to prevent default tab switching behavior when pressed
217218

218219
### Helper Props
219220

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

276277
- Default: Uses `route.style`
278+
279+
#### `getPreventsDefault`
280+
281+
Function to determine if a tab should prevent default switching behavior when pressed.
282+
283+
- Default: Uses `route.preventsDefault`

docs/docs/docs/guides/usage-with-react-navigation.mdx

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ export default function App() {
5353
component={SettingsScreen}
5454
options={{
5555
tabBarIcon: () => ({ sfSymbol: 'gear' }),
56+
preventsDefault: true, // Prevents automatic tab switching
5657
}}
5758
/>
5859
</Tab.Navigator>
@@ -321,6 +322,17 @@ Boolean indicating whether to prevent inactive screens from re-rendering. Defaul
321322

322323
It's working separately from `enableFreeze()` in `react-native-screens`. So settings won't be shared between them.
323324

325+
#### `preventsDefault`
326+
327+
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.
328+
329+
- Type: `boolean`
330+
- Default: `false`
331+
332+
:::note
333+
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.
334+
:::
335+
324336

325337
#### `tabBarButtonTestID`
326338

@@ -361,9 +373,9 @@ To prevent the default behavior, you can call `event.preventDefault`:
361373
```tsx
362374
React.useEffect(() => {
363375
const unsubscribe = navigation.addListener('tabPress', (e) => {
364-
// Prevent default behavior
365-
e.preventDefault();
366-
376+
// Note: For iOS 26+, use the `preventsDefault` option instead of `e.preventDefault()`
377+
// to avoid animation delays
378+
367379
// Do something manually
368380
// ...
369381
});

packages/react-native-bottom-tabs/ios/Fabric/RCTTabViewComponentView.mm

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,8 @@
3838
lhs.activeTintColor == rhs.activeTintColor &&
3939
lhs.hidden == rhs.hidden &&
4040
lhs.testID == rhs.testID &&
41-
lhs.role == rhs.role;
41+
lhs.role == rhs.role &&
42+
lhs.preventsDefault == rhs.preventsDefault;
4243
}
4344

4445
bool operator!=(const RNCTabViewItemsStruct& lhs, const RNCTabViewItemsStruct& rhs) {
@@ -189,7 +190,9 @@ - (void)updateProps:(Props::Shared const &)props oldProps:(Props::Shared const &
189190
activeTintColor:RCTUIColorFromSharedColor(item.activeTintColor)
190191
hidden:item.hidden
191192
testID:RCTNSStringFromStringNilIfEmpty(item.testID)
192-
role:RCTNSStringFromStringNilIfEmpty(item.role)];
193+
role:RCTNSStringFromStringNilIfEmpty(item.role)
194+
preventsDefault:item.preventsDefault
195+
];
193196

194197
[result addObject:tabInfo];
195198
}

packages/react-native-bottom-tabs/ios/TabItemEventModifier.swift

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import UIKit
77
#if !os(macOS) && !os(visionOS)
88

99
private final class TabBarDelegate: NSObject, UITabBarControllerDelegate {
10-
var onClick: ((_ index: Int) -> Void)?
10+
var onClick: ((_ index: Int) -> Bool)?
1111

1212
func tabBarController(_ tabBarController: UITabBarController, shouldSelect viewController: UIViewController) -> Bool {
1313
#if os(iOS)
@@ -17,15 +17,21 @@ private final class TabBarDelegate: NSObject, UITabBarControllerDelegate {
1717
}
1818
#endif
1919

20+
// Unfortunately, due to iOS 26 new tab switching animations, controlling state from JavaScript is causing significant delays when switching tabs.
21+
// See: https://github.com/callstackincubator/react-native-bottom-tabs/issues/383
22+
// Due to this, whether the tab prevents default has to be defined statically.
2023
if let index = tabBarController.viewControllers?.firstIndex(of: viewController) {
21-
onClick?(index)
24+
let defaultPrevented = onClick?(index) ?? false
25+
26+
return !defaultPrevented
2227
}
28+
2329
return false
2430
}
2531
}
2632

2733
struct TabItemEventModifier: ViewModifier {
28-
let onTabEvent: (_ key: Int, _ isLongPress: Bool) -> Void
34+
let onTabEvent: (_ key: Int, _ isLongPress: Bool) -> Bool
2935
private let delegate = TabBarDelegate()
3036

3137
func body(content: Content) -> some View {
@@ -54,7 +60,7 @@ struct TabItemEventModifier: ViewModifier {
5460
}
5561

5662
// Create gesture handler
57-
let handler = LongPressGestureHandler(tabBar: tabController.tabBar, handler: onTabEvent)
63+
let handler = LongPressGestureHandler(tabBar: tabController.tabBar, handler: { key, isLongPress in _ = onTabEvent(key,isLongPress) })
5864
let gesture = UILongPressGestureRecognizer(target: handler, action: #selector(LongPressGestureHandler.handleLongPress(_:)))
5965
gesture.minimumPressDuration = 0.5
6066

@@ -103,7 +109,10 @@ private class LongPressGestureHandler: NSObject {
103109
}
104110

105111
extension View {
106-
func onTabItemEvent(_ handler: @escaping (Int, Bool) -> Void) -> some View {
112+
/**
113+
Event for tab items. Returns true if should prevent default (switching tabs).
114+
*/
115+
func onTabItemEvent(_ handler: @escaping (Int, Bool) -> Bool) -> some View {
107116
modifier(TabItemEventModifier(onTabEvent: handler))
108117
}
109118
}

packages/react-native-bottom-tabs/ios/TabViewImpl.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ struct TabViewImpl: View {
4444
#if !os(tvOS) && !os(macOS) && !os(visionOS)
4545
.onTabItemEvent { index, isLongPress in
4646
let item = props.filteredItems[safe: index]
47-
guard let key = item?.key else { return }
47+
guard let key = item?.key else { return false }
4848

4949
if isLongPress {
5050
onLongPress(key)
@@ -53,6 +53,7 @@ struct TabViewImpl: View {
5353
onSelect(key)
5454
emitHapticFeedback()
5555
}
56+
return item?.preventsDefault ?? false
5657
}
5758
#endif
5859
.introspectTabView { tabController in

packages/react-native-bottom-tabs/ios/TabViewProvider.swift

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ public final class TabInfo: NSObject {
1414
public let hidden: Bool
1515
public let testID: String?
1616
public let role: TabBarRole?
17+
public let preventsDefault: Bool
1718

1819
public init(
1920
key: String,
@@ -23,7 +24,8 @@ public final class TabInfo: NSObject {
2324
activeTintColor: PlatformColor?,
2425
hidden: Bool,
2526
testID: String?,
26-
role: String?
27+
role: String?,
28+
preventsDefault: Bool = false
2729
) {
2830
self.key = key
2931
self.title = title
@@ -33,6 +35,7 @@ public final class TabInfo: NSObject {
3335
self.hidden = hidden
3436
self.testID = testID
3537
self.role = TabBarRole(rawValue: role ?? "")
38+
self.preventsDefault = preventsDefault
3639
super.init()
3740
}
3841
}
@@ -288,7 +291,8 @@ public final class TabInfo: NSObject {
288291
activeTintColor: RCTConvert.uiColor(itemDict["activeTintColor"] as? NSNumber),
289292
hidden: itemDict["hidden"] as? Bool ?? false,
290293
testID: itemDict["testID"] as? String ?? "",
291-
role: itemDict["role"] as? String
294+
role: itemDict["role"] as? String,
295+
preventsDefault: itemDict["preventsDefault"] as? Bool ?? false
292296
)
293297
)
294298
}

packages/react-native-bottom-tabs/src/TabView.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,10 @@ interface Props<Route extends BaseRoute> {
107107
* Get active tint color for the tab, uses `route.activeTintColor` by default.
108108
*/
109109
getActiveTintColor?: (props: { route: Route }) => ColorValue | undefined;
110+
/**
111+
* Determines whether the tab prevents default action (switching tabs) on press, uses `route.preventsDefault` by default.
112+
*/
113+
getPreventsDefault?: (props: { route: Route }) => boolean | undefined;
110114
/**
111115
* Get icon for the tab, uses `route.focusedIcon` by default.
112116
*/
@@ -204,6 +208,7 @@ const TabView = <Route extends BaseRoute>({
204208
getTestID = ({ route }: { route: Route }) => route.testID,
205209
getRole = ({ route }: { route: Route }) => route.role,
206210
getSceneStyle = ({ route }: { route: Route }) => route.style,
211+
getPreventsDefault = ({ route }: { route: Route }) => route.preventsDefault,
207212
hapticFeedbackEnabled = false,
208213
// 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.
209214
labeled = Platform.OS !== 'android' ? true : undefined,
@@ -276,6 +281,7 @@ const TabView = <Route extends BaseRoute>({
276281
hidden: getHidden?.({ route }),
277282
testID: getTestID?.({ route }),
278283
role: getRole?.({ route }),
284+
preventsDefault: getPreventsDefault?.({ route }),
279285
};
280286
}),
281287
[
@@ -287,6 +293,7 @@ const TabView = <Route extends BaseRoute>({
287293
getHidden,
288294
getTestID,
289295
getRole,
296+
getPreventsDefault,
290297
]
291298
);
292299

packages/react-native-bottom-tabs/src/TabViewNativeComponent.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ export type TabViewItems = ReadonlyArray<{
3131
hidden?: boolean;
3232
testID?: string;
3333
role?: string;
34+
preventsDefault?: boolean;
3435
}>;
3536

3637
export interface TabViewProps extends ViewProps {

0 commit comments

Comments
 (0)