Hi, If it's helping, here's a patch to fix it : ```diff --git a/node_modules/react-native-screens/ios/tabs/host/RNSTabBarController.mm b/node_modules/react-native-screens/ios/tabs/host/RNSTabBarController.mm index 481a9e6..a52f9ad 100644 --- a/node_modules/react-native-screens/ios/tabs/host/RNSTabBarController.mm +++ b/node_modules/react-native-screens/ios/tabs/host/RNSTabBarController.mm @@ -5,6 +5,7 @@ #import #import #import "NSString+RNSUtility.h" +#import "RNSDefines.h" #import "RNSLog.h" #import "RNSParentContainerItemRegistry.h" #import "RNSScreenWindowTraits.h" @@ -89,6 +90,20 @@ @implementation RNSTabBarController { RNSTabsNavigationStateObserverRegistry *_observerRegistry; RNSParentContainerItemRegistry *_Nonnull _parentContainerRegistry; + + /// iOS 27+: the children are installed as `UITab`s (`setTabs:`) instead of `viewControllers`. + /// A legacy `UITabBarSystemItemSearch` item is no longer detached from the tab bar nor morphed into + /// the bottom search field on iOS 27 — only a real `UISearchTab` made prominent gets that treatment. + /// Sticky once enabled: UIKit ignores `viewControllers` after `tabs` have been set. + BOOL _usesUITabs; + + /// Tab screen controllers currently installed as `UITab`s, in order. With `tabs`, + /// `UITabBarController.viewControllers` stays nil, so this is the source of truth in that mode. + NSArray *_Nullable _installedTabScreenControllers; + + /// Set by `tabBarController:shouldSelectTab:` when it lets a user selection through, so that + /// `tabBarController:didSelectTab:previousTab:` (also fired for programmatic selection) only handles taps. + BOOL _isHandlingUserTabSelection; } - (instancetype)init @@ -462,6 +477,44 @@ - (void)tabBarController:(UITabBarController *)tabBarController _isHandlingExplicitSelectionUpdate = NO; } +// With `tabs` (see `_usesUITabs`), UIKit calls these instead of the view controller based methods above, +// for user taps only in the case of `shouldSelectTab:`. We forward them to the same logic. + +- (BOOL)tabBarController:(UITabBarController *)tabBarController + shouldSelectTab:(UITab *)tab API_AVAILABLE(ios(18.0)) +{ + if (!_usesUITabs) { + return YES; + } + + UIViewController *viewController = tab.viewController; + if (viewController == nil) { + return YES; + } + + BOOL shouldSelect = [self tabBarController:tabBarController shouldSelectViewController:viewController]; + _isHandlingUserTabSelection = shouldSelect; + return shouldSelect; +} + +- (void)tabBarController:(UITabBarController *)tabBarController + didSelectTab:(UITab *)selectedTab + previousTab:(UITab *)previousTab API_AVAILABLE(ios(18.0)) +{ + if (!_usesUITabs || !_isHandlingUserTabSelection) { + return; + } + _isHandlingUserTabSelection = NO; + + UIViewController *viewController = selectedTab.viewController; + if (viewController == nil) { + _isHandlingExplicitSelectionUpdate = NO; + return; + } + + [self tabBarController:tabBarController didSelectViewController:viewController]; +} + #pragma mark - UINavigationControllerDelegate - (void)navigationController:(UINavigationController *)navigationController @@ -501,9 +554,137 @@ - (void)updateReactChildrenControllers return; } +#if RNS_IPHONE_OS_VERSION_AVAILABLE(27_0) + if (@available(iOS 27.0, *)) { + if (_usesUITabs || [self shouldInstallAsUITabs:_tabScreenControllers]) { + [self installTabScreenControllersAsUITabs:_tabScreenControllers]; + return; + } + } +#endif // RNS_IPHONE_OS_VERSION_AVAILABLE(27_0) + [self setViewControllers:_tabScreenControllers animated:[[self viewControllers] count] != 0]; } +#if RNS_IPHONE_OS_VERSION_AVAILABLE(27_0) + +- (BOOL)shouldInstallAsUITabs:(nonnull NSArray *)controllers +{ + // The More navigation controller is only handled with `viewControllers`. + if (controllers.count >= kMinCountOfVCsForMoreVCPresence) { + return NO; + } + for (RNSTabsScreenViewController *controller in controllers) { + if (controller.tabScreenComponentView.systemItem == RNSTabsScreenSystemItemSearch) { + return YES; + } + } + return NO; +} + +- (void)installTabScreenControllersAsUITabs:(nonnull NSArray *)controllers + API_AVAILABLE(ios(27.0)) +{ + _usesUITabs = YES; + + if ([_installedTabScreenControllers isEqualToArray:controllers]) { + return; + } + + // Reuse the tabs of controllers that stay, so that UIKit keeps their state. + NSMapTable *existingTabs = [NSMapTable weakToStrongObjectsMapTable]; + for (UITab *tab in self.tabs) { + UIViewController *viewController = tab.viewController; + if (viewController != nil) { + [existingTabs setObject:tab forKey:viewController]; + } + } + + NSMutableArray *tabs = [NSMutableArray arrayWithCapacity:controllers.count]; + NSMutableArray *> *itemSnapshots = [NSMutableArray arrayWithCapacity:controllers.count]; + BOOL hasSearchTab = NO; + + for (RNSTabsScreenViewController *controller in controllers) { + UITabBarItem *item = controller.tabBarItem; + [itemSnapshots addObject:[self snapshotOfTabBarItem:item]]; + + BOOL isSearch = controller.tabScreenComponentView.systemItem == RNSTabsScreenSystemItemSearch; + hasSearchTab = hasSearchTab || isSearch; + + UITab *tab = [existingTabs objectForKey:controller]; + if (tab == nil) { + UIViewController * (^provider)(__kindof UITab *) = ^UIViewController *(__kindof UITab *_Nonnull __unused tab) { + return controller; + }; + if (isSearch) { + tab = [[UISearchTab alloc] initWithViewControllerProvider:provider]; + } else { + NSString *identifier = [controller getScreenKeyOrNull] ?: [NSUUID UUID].UUIDString; + tab = [[UITab alloc] initWithTitle:item.title ?: @"" + image:item.image + identifier:identifier + viewControllerProvider:provider]; + } + } + + // UIKit pushes the tab's title, image and badge onto the controller's `tabBarItem` (and replaces + // that item altogether for a `UISearchTab`): keep them in line with what the screen configured. + if (item.title != nil) { + tab.title = item.title; + } + if (item.image != nil) { + tab.image = item.image; + } + tab.badgeValue = item.badgeValue; + [tabs addObject:tab]; + } + + _installedTabScreenControllers = [controllers copy]; + [self setTabs:tabs animated:self.tabs.count != 0]; + + // Without it the search tab stays in the tab bar group (iOS 27), with it the tab is detached and + // selecting it morphs the tab bar into the search field, as the legacy search item did on iOS 26. + [self setProminentTabIdentifier:hasSearchTab ? UISearchTab.identifier : nil animated:NO]; + + [controllers enumerateObjectsUsingBlock:^(RNSTabsScreenViewController *controller, NSUInteger idx, BOOL *stop) { + [self restoreTabBarItem:controller.tabBarItem fromSnapshot:itemSnapshots[idx]]; + controller.tabScreenComponentView.tabBarItemNeedsA11yUpdate = YES; + }]; + + // Icons loaded asynchronously target the item they were started for, which a `UISearchTab` replaces. + _needsUpdateOfTabBarAppearance = true; +} + +- (nonnull NSDictionary *)snapshotOfTabBarItem:(nonnull UITabBarItem *)item +{ + NSMutableDictionary *snapshot = [NSMutableDictionary dictionary]; + snapshot[@"title"] = item.title; + snapshot[@"image"] = item.image; + snapshot[@"selectedImage"] = item.selectedImage; + snapshot[@"badgeValue"] = item.badgeValue; + snapshot[@"standardAppearance"] = item.standardAppearance; + snapshot[@"scrollEdgeAppearance"] = item.scrollEdgeAppearance; + return snapshot; +} + +- (void)restoreTabBarItem:(nonnull UITabBarItem *)item fromSnapshot:(nonnull NSDictionary *)snapshot +{ + item.title = snapshot[@"title"]; + item.image = snapshot[@"image"]; + item.selectedImage = snapshot[@"selectedImage"]; + item.badgeValue = snapshot[@"badgeValue"]; + item.standardAppearance = snapshot[@"standardAppearance"]; + item.scrollEdgeAppearance = snapshot[@"scrollEdgeAppearance"]; +} + +#endif // RNS_IPHONE_OS_VERSION_AVAILABLE(27_0) + +/// The tab screen controllers managed by UIKit, whichever API installed them. +- (nullable NSArray *)managedViewControllers +{ + return _usesUITabs ? _installedTabScreenControllers : self.viewControllers; +} + - (void)updateSelectedViewControllerIfNeeded { if (_pendingStateUpdate != nil) { @@ -513,7 +694,7 @@ - (void)updateSelectedViewControllerIfNeeded - (void)updateSelectedViewController { - if (_pendingStateUpdate == nil || self.viewControllers.count == 0) { + if (_pendingStateUpdate == nil || [self managedViewControllers].count == 0) { return; } @@ -614,7 +795,7 @@ - (void)updateTabBarAppearance - (void)updateTabBarA11yIfNeeded { - for (UIViewController *tabViewController in self.viewControllers) { + for (UIViewController *tabViewController in [self managedViewControllers]) { auto screenView = static_cast(tabViewController).tabScreenComponentView; if (!screenView.tabBarItemNeedsA11yUpdate) { continue; @@ -633,7 +814,7 @@ - (nullable RNSTabsScreenViewController *)findChildViewControllerForKey:(nullabl if (screenKey == nil) { return nil; } - for (UIViewController *viewController in self.viewControllers) { + for (UIViewController *viewController in [self managedViewControllers]) { RCTAssert([viewController isKindOfClass:RNSTabsScreenViewController.class], @"[RNScreens] Unexpected type of controller: %@", viewController.class); @@ -769,7 +950,7 @@ - (BOOL)canHaveMoreNavigationController // https://developer.apple.com/documentation/uikit/uitabbarcontroller?language=objc#The-More-navigation-controller // The count is documented. Size class check is empirical, to tighten the condition and have less // false positives. If we ever find it not correct, we can safely remove it. - return self.viewControllers.count >= kMinCountOfVCsForMoreVCPresence && + return [self managedViewControllers].count >= kMinCountOfVCsForMoreVCPresence && self.traitCollection.horizontalSizeClass == UIUserInterfaceSizeClassCompact; #else return NO; @@ -783,7 +964,7 @@ - (BOOL)isViewControllerHostedByMoreNavigationController:(nonnull UIViewControll } // Guard: VC must be one we manage (excludes arbitrary external VCs). - if ([self.viewControllers indexOfObject:viewController] == NSNotFound) { + if ([[self managedViewControllers] indexOfObject:viewController] == NSNotFound) { return NO; } ```