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 {