diff --git a/.changeset/cute-carrots-switch.md b/.changeset/cute-carrots-switch.md
new file mode 100644
index 00000000..5a25d953
--- /dev/null
+++ b/.changeset/cute-carrots-switch.md
@@ -0,0 +1,5 @@
+---
+'react-native-bottom-tabs': patch
+---
+
+Fix screen not rendering after tab change on iOS 27
diff --git a/.gitignore b/.gitignore
index 7ce0532c..ab7f80ce 100644
--- a/.gitignore
+++ b/.gitignore
@@ -82,3 +82,6 @@ android/generated
# Codex
.codex
+
+# Agent Device
+tmp/
diff --git a/apps/example/src/App.tsx b/apps/example/src/App.tsx
index 26b5e752..d789517d 100644
--- a/apps/example/src/App.tsx
+++ b/apps/example/src/App.tsx
@@ -36,8 +36,13 @@ import NativeBottomTabsUnmounting from './Examples/NativeBottomTabsUnmounting';
import NativeBottomTabsCustomTabBar from './Examples/NativeBottomTabsCustomTabBar';
import NativeBottomTabsFreezeOnBlur from './Examples/NativeBottomTabsFreezeOnBlur';
import NativeBottomTabsScreenLayout from './Examples/NativeBottomTabsScreenLayout';
+import NativeBottomTabsLazy from './Examples/NativeBottomTabsLazy';
import BottomAccessoryView from './Examples/BottomAccessoryView';
import { useLogger } from '@react-navigation/devtools';
+import LazyTabs from './Examples/LazyTabs';
+import { LogBox } from 'react-native';
+
+LogBox.ignoreAllLogs();
const HiddenTab = () => {
return ;
@@ -74,6 +79,7 @@ const FourTabsActiveIndicatorColor = () => {
const UnlabeledTabs = () => {
return ;
};
+
const FourTabsRightToLeft = () => {
return ;
};
@@ -96,6 +102,7 @@ const examples = [
name: 'Embedded stacks',
screenOptions: { headerShown: false },
},
+ { component: LazyTabs, name: 'Lazy Tabs' },
{
component: FourTabsRippleColor,
name: 'Four Tabs with ripple Color',
@@ -156,6 +163,10 @@ const examples = [
component: NativeBottomTabsScreenLayout,
name: 'Native Bottom Tabs with screenLayout',
},
+ {
+ component: NativeBottomTabsLazy,
+ name: 'Native Bottom Tabs with Lazy',
+ },
{ component: NativeBottomTabs, name: 'Native Bottom Tabs' },
{ component: JSBottomTabs, name: 'JS Bottom Tabs' },
{
diff --git a/apps/example/src/Examples/LazyTabs.tsx b/apps/example/src/Examples/LazyTabs.tsx
new file mode 100644
index 00000000..00bfa722
--- /dev/null
+++ b/apps/example/src/Examples/LazyTabs.tsx
@@ -0,0 +1,47 @@
+import TabView, { SceneMap } from 'react-native-bottom-tabs';
+import { useState } from 'react';
+import { Article } from '../Screens/Article';
+import { Albums } from '../Screens/Albums';
+import { Contacts } from '../Screens/Contacts';
+
+const renderScene = SceneMap({
+ article: Article,
+ albums: Albums,
+ contacts: Contacts,
+});
+
+export default function LazyTabs() {
+ const [index, setIndex] = useState(0);
+ const [routes] = useState([
+ {
+ key: 'article',
+ title: 'Article',
+ focusedIcon: require('../../assets/icons/article_dark.png'),
+ unfocusedIcon: require('../../assets/icons/chat_dark.png'),
+ badge: '!',
+ testID: 'articleTestID',
+ },
+ {
+ key: 'albums',
+ title: 'Albums',
+ focusedIcon: require('../../assets/icons/grid_dark.png'),
+ badge: '5',
+ testID: 'albumsTestID',
+ lazy: false,
+ },
+ {
+ key: 'contacts',
+ focusedIcon: require('../../assets/icons/person_dark.png'),
+ title: 'Contacts',
+ testID: 'contactsTestID',
+ },
+ ]);
+
+ return (
+
+ );
+}
diff --git a/apps/example/src/Examples/NativeBottomTabsLazy.tsx b/apps/example/src/Examples/NativeBottomTabsLazy.tsx
new file mode 100644
index 00000000..3a1e3bce
--- /dev/null
+++ b/apps/example/src/Examples/NativeBottomTabsLazy.tsx
@@ -0,0 +1,43 @@
+import { Article } from '../Screens/Article';
+import { Albums } from '../Screens/Albums';
+import { Contacts } from '../Screens/Contacts';
+import { Chat } from '../Screens/Chat';
+import { createNativeBottomTabNavigator } from '@bottom-tabs/react-navigation';
+
+const Tab = createNativeBottomTabNavigator();
+
+export default function NativeBottomTabsLazy() {
+ return (
+
+ require('../../assets/icons/article_dark.png'),
+ }}
+ />
+ require('../../assets/icons/grid_dark.png'),
+ lazy: false,
+ }}
+ />
+ require('../../assets/icons/person_dark.png'),
+ }}
+ />
+ require('../../assets/icons/chat_dark.png'),
+ }}
+ />
+
+ );
+}
diff --git a/packages/react-native-bottom-tabs/ios/TabItemEventModifier.swift b/packages/react-native-bottom-tabs/ios/TabItemEventModifier.swift
index 42eb1e64..674e602d 100644
--- a/packages/react-native-bottom-tabs/ios/TabItemEventModifier.swift
+++ b/packages/react-native-bottom-tabs/ios/TabItemEventModifier.swift
@@ -7,9 +7,14 @@ import UIKit
#if !os(macOS) && !os(visionOS)
private final class TabBarDelegate: NSObject, UITabBarControllerDelegate {
- var onClick: ((_ index: Int) -> Bool)?
+ var onClick: ((_ index: Int?, _ identifier: String?) -> Bool)?
func tabBarController(_ tabBarController: UITabBarController, shouldSelect viewController: UIViewController) -> Bool {
+ if #available(iOS 27.0, *) {
+ // iOS 27 routes SwiftUI TabView selection through shouldSelectTab.
+ return true
+ }
+
#if os(iOS)
// Handle "More" Tab
if tabBarController.moreNavigationController == viewController {
@@ -21,7 +26,7 @@ private final class TabBarDelegate: NSObject, UITabBarControllerDelegate {
if isReselectingSameTab {
if let index = tabBarController.viewControllers?.firstIndex(of: viewController) {
- _ = onClick?(index)
+ _ = onClick?(index, nil)
}
return false
@@ -31,17 +36,45 @@ private final class TabBarDelegate: NSObject, UITabBarControllerDelegate {
// 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) {
- let defaultPrevented = onClick?(index) ?? false
+ let defaultPrevented = onClick?(index, nil) ?? false
return !defaultPrevented
}
return false
}
+
+ @available(iOS 18.0, tvOS 18.0, visionOS 2.0, *)
+ func tabBarController(_ tabBarController: UITabBarController, shouldSelectTab tab: UITab) -> Bool {
+ guard #available(iOS 27.0, *) else {
+ return true
+ }
+
+ let isReselectingSameTab =
+ tabBarController.selectedTab === tab ||
+ tabBarController.selectedTab?.identifier == tab.identifier
+
+ // 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.
+ let defaultPrevented = onClick?(
+ tabIndex(for: tab, in: tabBarController),
+ tab.identifier
+ ) ?? false
+
+ return isReselectingSameTab ? false : !defaultPrevented
+ }
+
+ @available(iOS 18.0, tvOS 18.0, visionOS 2.0, *)
+ private func tabIndex(for tab: UITab, in tabBarController: UITabBarController) -> Int? {
+ tabBarController.tabs.firstIndex {
+ $0 === tab || $0.identifier == tab.identifier
+ }
+ }
}
struct TabItemEventModifier: ViewModifier {
- let onTabEvent: (_ key: Int, _ isLongPress: Bool) -> Bool
+ let onTabEvent: (_ index: Int?, _ identifier: String?, _ isLongPress: Bool) -> Bool
private let delegate = TabBarDelegate()
func body(content: Content) -> some View {
@@ -52,8 +85,8 @@ struct TabItemEventModifier: ViewModifier {
}
func handle(tabController: UITabBarController) {
- delegate.onClick = { index in
- onTabEvent(index, false)
+ delegate.onClick = { index, identifier in
+ onTabEvent(index, identifier, false)
}
tabController.delegate = delegate
@@ -70,7 +103,7 @@ struct TabItemEventModifier: ViewModifier {
}
// Create gesture handler
- let handler = LongPressGestureHandler(tabBar: tabController.tabBar) { key, isLongPress in _ = onTabEvent(key, isLongPress) }
+ let handler = LongPressGestureHandler(tabBar: tabController.tabBar) { index, isLongPress in _ = onTabEvent(index, nil, isLongPress) }
let gesture = UILongPressGestureRecognizer(target: handler, action: #selector(LongPressGestureHandler.handleLongPress(_:)))
gesture.minimumPressDuration = 0.5
@@ -122,7 +155,7 @@ extension View {
/**
Event for tab items. Returns true if should prevent default (switching tabs).
*/
- func onTabItemEvent(_ handler: @escaping (Int, Bool) -> Bool) -> some View {
+ func onTabItemEvent(_ handler: @escaping (Int?, String?, 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 1d3e7db2..14732fdd 100644
--- a/packages/react-native-bottom-tabs/ios/TabViewImpl.swift
+++ b/packages/react-native-bottom-tabs/ios/TabViewImpl.swift
@@ -46,8 +46,9 @@ struct TabViewImpl: View {
tabContent
.tabBarMinimizeBehavior(props.minimizeBehavior)
#if !os(tvOS) && !os(macOS) && !os(visionOS)
- .onTabItemEvent { index, isLongPress in
- let item = props.filteredItems[safe: index]
+ .onTabItemEvent { index, identifier, isLongPress in
+ let item = identifier.flatMap { props.filteredItems.findByKey($0) }
+ ?? index.flatMap { props.filteredItems[safe: $0] }
guard let key = item?.key else { return false }
if isLongPress {