From 4a39c9f220ce3f1c8dcd265a729b7cb252eb86e5 Mon Sep 17 00:00:00 2001 From: Konrad Michalik Date: Mon, 21 Sep 2026 12:46:31 +0200 Subject: [PATCH 1/2] feat(iOS, Tabs): Use UISearchTab with automaticallyActivatesSearch prop --- .../tabs/test-tabs-system-item-ios.e2e.ts | 16 +- .../tests/single-feature-tests/tabs/index.ts | 3 + .../index.tsx | 267 ++++++++++++++++++ .../scenario-description.ts | 11 + .../scenario.md | 106 +++++++ ios/tabs/host/RNSTabBarController.mm | 39 +++ ios/tabs/screen/RNSTabsScreenComponentView.h | 12 + ios/tabs/screen/RNSTabsScreenComponentView.mm | 18 ++ .../screen/RNSTabsScreenViewController.mm | 1 + src/components/tabs/screen/TabsScreen.ios.tsx | 2 + .../tabs/screen/TabsScreen.ios.types.ts | 43 ++- .../tabs/TabsScreenIOSNativeComponent.ts | 2 + 12 files changed, 515 insertions(+), 5 deletions(-) create mode 100644 apps/src/tests/single-feature-tests/tabs/test-tabs-search-tab-activation-ios/index.tsx create mode 100644 apps/src/tests/single-feature-tests/tabs/test-tabs-search-tab-activation-ios/scenario-description.ts create mode 100644 apps/src/tests/single-feature-tests/tabs/test-tabs-search-tab-activation-ios/scenario.md diff --git a/FabricExample/e2e/single-feature-tests/tabs/test-tabs-system-item-ios.e2e.ts b/FabricExample/e2e/single-feature-tests/tabs/test-tabs-system-item-ios.e2e.ts index ce0d7fbc42..e5c8012f03 100644 --- a/FabricExample/e2e/single-feature-tests/tabs/test-tabs-system-item-ios.e2e.ts +++ b/FabricExample/e2e/single-feature-tests/tabs/test-tabs-system-item-ios.e2e.ts @@ -31,6 +31,14 @@ const tabBarButtonType = isIOSVersionAtLeast('26.0') ? CLASS_NAME_UI_TAB_BAR_BUTTON_IOS26 : CLASS_NAME_UI_TAB_BAR_BUTTON_LEGACY; +// Throughout iOS 26.x UIKit gives a tab whose `UITabBarItem` uses the `search` system item the +// separate platter view and a runtime switch to/from `search` moves the item. Starting with +// iOS 27.0 a plain search item stays inline — the separation is tied to `UISearchTab` +// (the `searchRole` prop) with `automaticallyActivatesSearch` on, covered by the separate +// `test-tabs-search-tab-activation-ios` scenario. +const shouldMoveTabBarItemOnRuntimeSearchSwitch = + isIOSVersionAtLeast('26.0') && !isIOSVersionAtLeast('27.0'); + describeIfIOS('Tab Bar System Item', () => { beforeAll(async () => { await device.reloadReactNative(); @@ -151,7 +159,7 @@ describeIfIOS('Tab Bar System Item', () => { const frameXAfterSearch = await getTabBarItemFrameX('Search'); - if (isIOSVersionAtLeast(`26.0`)) { + if (shouldMoveTabBarItemOnRuntimeSearchSwitch) { jestExpect(frameXAfterSearch).toBeGreaterThan(frameXBeforeSearch); } else { jestExpect(frameXAfterSearch).toEqual(frameXBeforeSearch); @@ -177,7 +185,7 @@ describeIfIOS('Tab Bar System Item', () => { const frameXAfterFavorites = await getTabBarItemFrameX('Favorites'); - if (isIOSVersionAtLeast(`26.0`)) { + if (shouldMoveTabBarItemOnRuntimeSearchSwitch) { jestExpect(frameXAfterFavorites).toBeLessThan(frameXBeforeFavorites); } else { jestExpect(frameXAfterFavorites).toEqual(frameXBeforeFavorites); @@ -366,7 +374,7 @@ describeIfIOS('Tab Bar System Item', () => { const frameXAfterSearch = await getTabBarItemFrameX('Custom'); - if (isIOSVersionAtLeast(`26.0`)) { + if (shouldMoveTabBarItemOnRuntimeSearchSwitch) { jestExpect(frameXAfterSearch).toBeGreaterThan(frameXBeforeSearch); } else { jestExpect(frameXAfterSearch).toEqual(frameXBeforeSearch); @@ -429,7 +437,7 @@ describeIfIOS('Tab Bar System Item', () => { const frameXAfterHistory = await getTabBarItemFrameX('Custom'); - if (isIOSVersionAtLeast(`26.0`)) { + if (shouldMoveTabBarItemOnRuntimeSearchSwitch) { jestExpect(frameXAfterHistory).toBeLessThan(frameXBeforeHistory); } else { jestExpect(frameXAfterHistory).toEqual(frameXBeforeHistory); diff --git a/apps/src/tests/single-feature-tests/tabs/index.ts b/apps/src/tests/single-feature-tests/tabs/index.ts index 7bc4c20b9f..f58091e32c 100644 --- a/apps/src/tests/single-feature-tests/tabs/index.ts +++ b/apps/src/tests/single-feature-tests/tabs/index.ts @@ -22,6 +22,7 @@ import TestTabsItemIcon from './test-tabs-item-icon'; import TestTabsItemTitle from './test-tabs-item-title'; import TestTabsItemBadge from './test-tabs-item-badge'; import TestTabsSystemItem from './test-tabs-system-item-ios'; +import TestTabsSearchTabActivation from './test-tabs-search-tab-activation-ios'; import TestTabsMoreNavigationController from './test-tabs-more-navigation-controller-ios'; import TestTabsTabBarMinimizeBehavior from './test-tabs-tab-bar-minimize-behavior-ios'; import TestTabsTabBarControllerMode from './test-tabs-tab-bar-controller-mode-ios'; @@ -51,6 +52,7 @@ export { default as TestTabsItemIcon } from './test-tabs-item-icon'; export { default as TestTabsItemTitle } from './test-tabs-item-title'; export { default as TestTabsItemBadge } from './test-tabs-item-badge'; export { default as TestTabsSystemItem } from './test-tabs-system-item-ios'; +export { default as TestTabsSearchTabActivation } from './test-tabs-search-tab-activation-ios'; export { default as TestTabsMoreNavigationController } from './test-tabs-more-navigation-controller-ios'; export { default as TestTabsTabBarMinimizeBehavior } from './test-tabs-tab-bar-minimize-behavior-ios'; export { default as TestTabsTabBarControllerMode } from './test-tabs-tab-bar-controller-mode-ios'; @@ -80,6 +82,7 @@ const scenarios = { TestTabsItemTitle, TestTabsItemBadge, TestTabsSystemItem, + TestTabsSearchTabActivation, TestTabsMoreNavigationController, TestTabsTabBarMinimizeBehavior, TestTabsTabBarControllerMode, diff --git a/apps/src/tests/single-feature-tests/tabs/test-tabs-search-tab-activation-ios/index.tsx b/apps/src/tests/single-feature-tests/tabs/test-tabs-search-tab-activation-ios/index.tsx new file mode 100644 index 0000000000..a0249802e3 --- /dev/null +++ b/apps/src/tests/single-feature-tests/tabs/test-tabs-search-tab-activation-ios/index.tsx @@ -0,0 +1,267 @@ +import React, { + createContext, + useContext, + useLayoutEffect, + useState, +} from 'react'; +import { ScrollView, StyleSheet, Text, View } from 'react-native'; +import { + NavigationContainer, + NavigationIndependentTree, + ParamListBase, + type RouteProp, +} from '@react-navigation/native'; +import { + createNativeStackNavigator, + NativeStackNavigationProp, +} from '@react-navigation/native-stack'; +import { ListItem, SettingsSwitch } from '@apps/shared'; +import { + TabsContainer, + useTabsNavigationContext, + type TabRouteConfig, +} from '@apps/shared/containers/tabs'; +import { Colors } from '@apps/shared/styling'; +import { scenarioDescription } from './scenario-description'; +import { createScenario } from '@apps/tests/shared/helpers'; + +// The search tab hosts a legacy (v4) native stack - v5 stack does not integrate +// the search bar into the header yet, and UISearchTab activation on iOS 26.1+ +// works with the search controller mirrored from the nested stack's header. + +type SearchStackParamList = { + List: undefined; + Details: { place: string }; +}; + +type SearchStackNavigationProp = NativeStackNavigationProp< + SearchStackParamList & ParamListBase +>; + +const SearchStack = createNativeStackNavigator(); + +// The toggle lives on the Config tab, while the option applies to the search +// bar of the search stack's root screen - shared through context. +// UIKit defaults hidesNavigationBarDuringPresentation to true. +const HideNavigationBarContext = createContext<{ + hideNavigationBar: boolean; + setHideNavigationBar: (value: boolean) => void; +}>({ + hideNavigationBar: true, + setHideNavigationBar: () => {}, +}); + +const PLACES = [ + '🏝️ Desert Island', + '🏞️ National Park', + '⛰️ Mountain', + '🏰 Castle', + '🗽 Statue of Liberty', + '🌉 Bridge at Night', + '🏛️ Classical Building', + '🏟️ Stadium', + '🏫 School', + '⛲ Fountain', + '🌄 Sunrise Over Mountains', + '🎡 Ferris Wheel', +]; + +function SearchListScreen({ + navigation, +}: { + navigation: SearchStackNavigationProp; +}) { + const [search, setSearch] = useState(''); + const { hideNavigationBar } = useContext(HideNavigationBarContext); + + useLayoutEffect(() => { + navigation.setOptions({ + headerSearchBarOptions: { + placeholder: 'Search places', + hideNavigationBar, + onChangeText: event => setSearch(event.nativeEvent.text), + }, + }); + }, [navigation, hideNavigationBar]); + + return ( + + {PLACES.filter( + item => item.toLowerCase().indexOf(search.toLowerCase()) !== -1, + ).map(place => ( + navigation.push('Details', { place })} + /> + ))} + + ); +} + +function SearchDetailsScreen({ + navigation, + route, +}: { + navigation: SearchStackNavigationProp; + route: RouteProp; +}) { + const [search, setSearch] = useState(''); + + // This screen mounts its own search bar. UIKit hosts (and auto-activates) + // only the search bar of the ROOT screen of the search stack - this one is + // a regular in-header search bar, activated by tapping it. + useLayoutEffect(() => { + navigation.setOptions({ + headerSearchBarOptions: { + placeholder: 'Search in details', + onChangeText: event => setSearch(event.nativeEvent.text), + }, + }); + }, [navigation]); + + return ( + + {route.params.place} + + Typed in the Details search bar: "{search}" + + + With this screen on top the stack is not at its root,{'\n'}so UIKit + removes the tab-hosted search field: switching{'\n'}to Config and + reselecting the search tab must NOT{'\n'}activate any search. This + screen's bar activates by{'\n'}tapping it in the header. + {'\n'} + {'\n'} + Go back with the back gesture - the "Search places" + {'\n'}field returns to the tab bar, and reselecting the{'\n'}search tab + auto-activates it again. + + + ); +} + +function SearchStackTab() { + return ( + + + + + ); +} + +function ConfigTab() { + const { setRouteOptions } = useTabsNavigationContext(); + const { hideNavigationBar, setHideNavigationBar } = useContext( + HideNavigationBarContext, + ); + const [automaticallyActivatesSearch, setAutomaticallyActivatesSearch] = + useState(false); + + const onAutomaticallyActivatesSearchChange = (value: boolean) => { + setAutomaticallyActivatesSearch(value); + // Options set at mount are snapshotted by the container - runtime changes + // must go through setRouteOptions. `ios` is replaced wholesale, so repeat + // the full search tab config here. + setRouteOptions('Search', { + ios: { + systemItem: 'search', + searchRole: true, + automaticallyActivatesSearch: value, + }, + }); + }; + + return ( + + Search Tab Activation + + + + iOS 26.1+: with the switch on, selecting the search tab{'\n'}should + immediately activate the search field; cancelling{'\n'}the search should + restore this tab. + {'\n'} + {'\n'} + With the switch off, the search tab behaves like{'\n'}a regular tab. + + + ); +} + +const ROUTE_CONFIGS: TabRouteConfig[] = [ + { + name: 'Config', + element: , + options: { + title: 'Config', + tabBarItemTestID: 'config-tab-item', + ios: { + icon: { type: 'sfSymbol', name: 'gear' }, + }, + }, + }, + { + name: 'Search', + element: , + options: { + tabBarItemTestID: 'search-tab-item', + ios: { + systemItem: 'search', + searchRole: true, + }, + }, + }, +]; + +function TestTabsSearchTabActivation() { + const [hideNavigationBar, setHideNavigationBar] = useState(true); + + return ( + + + + + + + + ); +} + +const styles = StyleSheet.create({ + screen: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + padding: 24, + gap: 12, + }, + label: { + fontSize: 17, + fontWeight: '600', + textAlign: 'center', + }, + hint: { + fontSize: 13, + color: Colors.LightOffNavy, + textAlign: 'center', + lineHeight: 20, + }, +}); + +export default createScenario(TestTabsSearchTabActivation, scenarioDescription); diff --git a/apps/src/tests/single-feature-tests/tabs/test-tabs-search-tab-activation-ios/scenario-description.ts b/apps/src/tests/single-feature-tests/tabs/test-tabs-search-tab-activation-ios/scenario-description.ts new file mode 100644 index 0000000000..66ee481367 --- /dev/null +++ b/apps/src/tests/single-feature-tests/tabs/test-tabs-search-tab-activation-ios/scenario-description.ts @@ -0,0 +1,11 @@ +import type { ScenarioDescription } from '@apps/tests/shared/helpers'; + +export const scenarioDescription: ScenarioDescription = { + name: 'Search Tab Activation', + key: 'test-tabs-search-tab-activation-ios', + details: + 'Validates automaticallyActivatesSearch on a searchRole tab with a search bar in the nested (legacy) stack header, including the UIKit hosted-search contract across push/pop (hosted field exists only at the stack root).', + platforms: ['ios'], + e2eCoverage: 'tbd', + smokeTest: false, +}; diff --git a/apps/src/tests/single-feature-tests/tabs/test-tabs-search-tab-activation-ios/scenario.md b/apps/src/tests/single-feature-tests/tabs/test-tabs-search-tab-activation-ios/scenario.md new file mode 100644 index 0000000000..4b230befe3 --- /dev/null +++ b/apps/src/tests/single-feature-tests/tabs/test-tabs-search-tab-activation-ios/scenario.md @@ -0,0 +1,106 @@ +# Test Scenario: Search Tab Activation (iOS) + +## Details + +**Description:** Validates the `ios.automaticallyActivatesSearch` prop on a +tab screen with `searchRole: true`. On iOS 26.1+ the search-role tab is backed by +`UISearchTab` and UIKit can activate the search controller mirrored from +the header of the stack nested in the tab. The nested stack is a legacy +(v4) `@react-navigation/native-stack` navigator, since the v5 stack does +not integrate the search bar into the header yet. + +**OS test creation version:** iOS 27.0 + +## E2E test + +TBD. + +## Prerequisites + +- iOS simulator or device running iOS 26.1 or later + +## Note + +- iOS-only; `automaticallyActivatesSearch` has no effect on Android. +- UIKit decides the placement of a `UISearchTab` and it differs between OS + versions (observed on simulators): iOS 26.x renders it detached from the + other items unconditionally; iOS 27.0 detaches it only while + `automaticallyActivatesSearch` is enabled. +- Cancelling an automatically activated search restores the previously + selected tab (UIKit behavior). + +## Steps (iOS 27) + +### Rendering & manual activation + +1. Launch the app and navigate to the **Search Tab Activation** screen. + + - [ ] Two tabs are visible: `Config` and the search tab (magnifier icon). + + - [ ] The search tab renders with the system magnifier icon, adjacent to the Config item. + + - [ ] The switch on the Config tab is off. + +2. Tap the search tab. + + - [ ] The nested stack's List screen appears with a search bar in the header. + + - [ ] The search field is NOT focused automatically (switch is off). + +3. Tap the search field, type a query, then cancel. Return to **Config**. + + - [ ] The list filters while typing. + + - [ ] Cancel dismisses the keyboard, tab selection does not change. + +### Automatic activation + +4. On the Config tab, enable **automaticallyActivatesSearch**. + + - [ ] The search tab moves to the right, with separate platter. + +5. Tap the search tab. + + - [ ] The search field activates immediately (keyboard up, field focused). + +6. Cancel the search. + + - [ ] The previously selected tab (`Config`) becomes selected again. + +7. On the Config tab, disable `hideNavigationBar`. Tap the search tab. + + - [ ] The large title no longer hides + +### Hosted search across push/pop in the nested stack + +8. Tap the search tab again, dismiss the keyboard by dragging the list + (do not cancel - cancel restores the previous tab), then tap a list + item to push **Details**. + + - [ ] The push removes the tab-hosted search field (the regular tab + bar returns). + + - [ ] The Details screen has its own search bar with the + "Search in details" placeholder in the header. + +9. With Details on top, switch to `Config`, then reselect the search tab. + + - [ ] No search activates automatically (the stack is not at its + root, so there is no hosted search field). The Details screen + stays visible. + + - [ ] Tapping the "Search in details" field focuses it and typing + updates the "Typed in the Details search bar" text. + +10. Go back to **List** with the back gesture. + + - [ ] The "Search places" field returns to the tab bar area. + +11. Switch to `Config`, then reselect the search tab. + + - [ ] Activation focuses the "Search places" field of the List screen + again (the hosted field re-binds after the pop). + +12. Disable **automaticallyActivatesSearch** and tap the search tab. + + - [ ] The search field is not focused automatically anymore. diff --git a/ios/tabs/host/RNSTabBarController.mm b/ios/tabs/host/RNSTabBarController.mm index 7fe3a1a112..48da53aae8 100644 --- a/ios/tabs/host/RNSTabBarController.mm +++ b/ios/tabs/host/RNSTabBarController.mm @@ -321,6 +321,7 @@ - (void)performContainerUpdate [self updateTabBarItemsIfNeeded]; [self updateTabBarAppearanceIfNeeded]; [self updateTabBarA11yIfNeeded]; + [self updateSearchTabsIfNeeded]; [self updateOrientationIfNeeded]; } @@ -653,6 +654,10 @@ - (BOOL)isScreenControllerCurrentlySelected:(nonnull UIViewController *)screenCo - (UITab *)tabForTabScreenController:(RNSTabsScreenViewController *)screenController API_AVAILABLE(ios(18.0)) { + // Return existing tab if present. Note that this "latches" + // the type of the tab: you cannot swap regular UITab with UISearchTab; + // and we want it this way because tabs hold view controller references + // and UIKit asserts they are unique even for the detached, not-deallocated tabs if (screenController.tab) { return screenController.tab; } @@ -666,6 +671,20 @@ - (UITab *)makeTabForTabScreenController:(RNSTabsScreenViewController *)screenCo { __weak RNSTabsScreenViewController *weakScreenController = screenController; + if (screenController.tabScreenComponentView.searchRole) { + // The designated initializer of `UISearchTab` takes no identifier - UIKit assigns a system one. + UISearchTab *searchTab = [[UISearchTab alloc] initWithViewControllerProvider:^UIViewController *(UITab *) { + return weakScreenController; + }]; +#if RNS_IPHONE_OS_VERSION_AVAILABLE(26_0) && !TARGET_OS_TV && !TARGET_OS_VISION + if (@available(iOS 26.0, *)) { + searchTab.automaticallyActivatesSearch = screenController.tabScreenComponentView.automaticallyActivatesSearch; + } +#endif // RNS_IPHONE_OS_VERSION_AVAILABLE(26_0) && !TARGET_OS_TV && !TARGET_OS_VISION + + return searchTab; + } + return [[UITab alloc] initWithTitle:[screenController.tabScreenComponentView title] ?: @"" image:nil identifier:[screenController.tabScreenComponentView screenKey] @@ -843,6 +862,26 @@ - (void)updateTabBarA11yIfNeeded } } +- (void)updateSearchTabsIfNeeded +{ +#if RNS_IPHONE_OS_VERSION_AVAILABLE(26_0) && !TARGET_OS_TV && !TARGET_OS_VISION + if (@available(iOS 26.0, *)) { + for (UITab *tab in self.tabs) { + if (![tab isKindOfClass:UISearchTab.class]) { + continue; + } + auto *searchTab = static_cast(tab); + auto *screenController = static_cast(tab.viewController); + auto *screenView = screenController.tabScreenComponentView; + + if (searchTab.automaticallyActivatesSearch != screenView.automaticallyActivatesSearch) { + searchTab.automaticallyActivatesSearch = screenView.automaticallyActivatesSearch; + } + } + } +#endif // RNS_IPHONE_OS_VERSION_AVAILABLE(26_0) && !TARGET_OS_TV && !TARGET_OS_VISION +} + #pragma mark - Utility - (nullable RNSTabsScreenViewController *)findChildViewControllerForKey:(nullable NSString *)screenKey diff --git a/ios/tabs/screen/RNSTabsScreenComponentView.h b/ios/tabs/screen/RNSTabsScreenComponentView.h index 830a7d9d9b..c030774653 100644 --- a/ios/tabs/screen/RNSTabsScreenComponentView.h +++ b/ios/tabs/screen/RNSTabsScreenComponentView.h @@ -81,6 +81,18 @@ NS_ASSUME_NONNULL_BEGIN @property (nonatomic, readonly) RNSTabsScreenSystemItem systemItem; +/** + * Whether this screen is the system search tab. On the UITab path (iOS 26.1+) it decides the + * backing tab class: `UISearchTab` vs plain `UITab`. Fixed for the screen's lifetime. + */ +@property (nonatomic, readonly) BOOL searchRole; + +/** + * Whether selecting the search tab should automatically activate its search controller. + * Effective only on iOS 26.1+ for screens with `searchRole` set. + */ +@property (nonatomic, readonly) BOOL automaticallyActivatesSearch; + @end #pragma mark - Experimental diff --git a/ios/tabs/screen/RNSTabsScreenComponentView.mm b/ios/tabs/screen/RNSTabsScreenComponentView.mm index ea6ba0c10e..2473a92e4f 100644 --- a/ios/tabs/screen/RNSTabsScreenComponentView.mm +++ b/ios/tabs/screen/RNSTabsScreenComponentView.mm @@ -93,6 +93,8 @@ - (void)resetProps _selectedIconResourceName = nil; _systemItem = RNSTabsScreenSystemItemNone; + _searchRole = NO; + _automaticallyActivatesSearch = NO; _userInterfaceStyle = UIUserInterfaceStyleUnspecified; } @@ -321,6 +323,22 @@ - (void)updateProps:(const facebook::react::Props::Shared &)props _tabBarItemNeedsRecreation = YES; } + if (newComponentProps.searchRole != oldComponentProps.searchRole) { +#if RNS_IPHONE_OS_VERSION_AVAILABLE(26_1) + // Only the UITab-managed path (iOS >= 26.1) fixes the tab class (`UISearchTab` vs `UITab`) + // at creation; below that the prop has no effect. + if (@available(iOS 26.1, *)) { + RCTAssert(_controller.tabBarController == nil, + @"[RNScreens] Changing `searchRole` on a mounted tab screen is not supported."); + } +#endif // RNS_IPHONE_OS_VERSION_AVAILABLE(26_1) + _searchRole = newComponentProps.searchRole; + } + + if (newComponentProps.automaticallyActivatesSearch != oldComponentProps.automaticallyActivatesSearch) { + _automaticallyActivatesSearch = newComponentProps.automaticallyActivatesSearch; + } + if (newComponentProps.userInterfaceStyle != oldComponentProps.userInterfaceStyle) { _userInterfaceStyle = rnscreens::conversion::UIUserInterfaceStyleFromTabsScreenCppEquivalent(newComponentProps.userInterfaceStyle); diff --git a/ios/tabs/screen/RNSTabsScreenViewController.mm b/ios/tabs/screen/RNSTabsScreenViewController.mm index 24bb71ee7e..e47f2576cd 100644 --- a/ios/tabs/screen/RNSTabsScreenViewController.mm +++ b/ios/tabs/screen/RNSTabsScreenViewController.mm @@ -1,6 +1,7 @@ #import "RNSTabsScreenViewController.h" #import "RNSContainer.h" #import "RNSContainerItemSupport.h" +#import "RNSDefines.h" #import "RNSLog.h" #import "RNSTabBarController.h" #import "UIScrollView+RNScreens.h" diff --git a/src/components/tabs/screen/TabsScreen.ios.tsx b/src/components/tabs/screen/TabsScreen.ios.tsx index 209fdcd838..a278f0e9e0 100644 --- a/src/components/tabs/screen/TabsScreen.ios.tsx +++ b/src/components/tabs/screen/TabsScreen.ios.tsx @@ -72,6 +72,8 @@ function TabsScreen(props: TabsScreenProps) { )} userInterfaceStyle={ios?.experimental_userInterfaceStyle} systemItem={ios?.systemItem} + searchRole={ios?.searchRole} + automaticallyActivatesSearch={ios?.automaticallyActivatesSearch} overrideScrollViewContentInsetAdjustmentBehavior={ ios?.overrideScrollViewContentInsetAdjustmentBehavior }> diff --git a/src/components/tabs/screen/TabsScreen.ios.types.ts b/src/components/tabs/screen/TabsScreen.ios.types.ts index d2153ebf1f..075fcc534c 100644 --- a/src/components/tabs/screen/TabsScreen.ios.types.ts +++ b/src/components/tabs/screen/TabsScreen.ios.types.ts @@ -269,13 +269,54 @@ export interface TabsScreenPropsIOS { * overridden by providing a custom `title`. * * @remarks - * On iOS 26, `systemItem: 'search'` acts as a detached tab bar item, which does not display any title (system or custom). + * On iOS 26, `systemItem: 'search'` acts as a detached tab bar item, which does not display + * any title (system or custom). This no longer applies on iOS 27; to get a similar separation + * use `searchRole: true` together with `automaticallyActivatesSearch`. * * @see {@link https://developer.apple.com/documentation/uikit/uitabbaritem/systemitem|UITabBarItem.SystemItem} * * @platform ios */ systemItem?: TabsScreenSystemItem | undefined; + /** + * @summary Marks this tab as the system search tab. + * + * On iOS 26.1+ (where tabs are managed through the `UITab` API) a tab screen with + * `searchRole: true` is backed natively by `UISearchTab`, which enables the system + * search behavior: `automaticallyActivatesSearch` and the detached trailing + * placement UIKit gives search tabs. Behavior differs between OS versions: iOS 26.x + * detaches `UISearchTab` unconditionally, iOS 27.0 only while `automaticallyActivatesSearch` + * is enabled. This prop does not affect the tab bar item's icon or title - combine + * it with `systemItem: 'search'` for the system magnifier item. + * + * The native tab class cannot change during the screen's lifetime - changing + * `searchRole` on a mounted tab screen is not supported and triggers an assertion + * in development builds. + * + * @see {@link https://developer.apple.com/documentation/uikit/uisearchtab|UISearchTab} + * + * @default false + * + * @platform ios + * @supported iOS 26.1 or higher + */ + searchRole?: boolean | undefined; + /** + * @summary Specifies whether selecting this tab automatically activates its search field. + * + * Effective only for tab screens with `searchRole: true` and only when a search bar + * is configured in the header of the screen stack nested in this tab + * (see `headerSearchBarOptions`). When enabled, selecting the search tab immediately + * activates the search field. Cancelling the search restores the previously selected tab. + * + * @see {@link https://developer.apple.com/documentation/uikit/uisearchtab/automaticallyactivatessearch|UISearchTab.automaticallyActivatesSearch} + * + * @default false + * + * @platform ios + * @supported iOS 26.1 or higher + */ + automaticallyActivatesSearch?: boolean | undefined; /** * @summary Specifies if `contentInsetAdjustmentBehavior` of first ScrollView * in first descendant chain from tab screen should be overridden back from `never` diff --git a/src/fabric/tabs/TabsScreenIOSNativeComponent.ts b/src/fabric/tabs/TabsScreenIOSNativeComponent.ts index 80a2fec945..8f836b4d4d 100644 --- a/src/fabric/tabs/TabsScreenIOSNativeComponent.ts +++ b/src/fabric/tabs/TabsScreenIOSNativeComponent.ts @@ -146,6 +146,8 @@ export interface NativeProps extends ViewProps { // Tab config isTitleUndefined?: CT.WithDefault; systemItem?: CT.WithDefault; + searchRole?: CT.WithDefault; + automaticallyActivatesSearch?: CT.WithDefault; // Appearance standardAppearance?: UnsafeMixed | undefined; From 6bef9f0edd0e637ca9022b195320b3d9af4233eb Mon Sep 17 00:00:00 2001 From: Konrad Michalik Date: Mon, 28 Sep 2026 14:47:24 +0200 Subject: [PATCH 2/2] move SFT to CIT --- .../tests/component-integration-tests/index.tsx | 3 +++ .../tabs-stack-v4/index.ts | 16 ++++++++++++++++ .../index.tsx | 0 .../scenario-description.ts | 0 .../scenario.md | 0 .../src/tests/single-feature-tests/tabs/index.ts | 3 --- 6 files changed, 19 insertions(+), 3 deletions(-) create mode 100644 apps/src/tests/component-integration-tests/tabs-stack-v4/index.ts rename apps/src/tests/{single-feature-tests/tabs => component-integration-tests/tabs-stack-v4}/test-tabs-search-tab-activation-ios/index.tsx (100%) rename apps/src/tests/{single-feature-tests/tabs => component-integration-tests/tabs-stack-v4}/test-tabs-search-tab-activation-ios/scenario-description.ts (100%) rename apps/src/tests/{single-feature-tests/tabs => component-integration-tests/tabs-stack-v4}/test-tabs-search-tab-activation-ios/scenario.md (100%) diff --git a/apps/src/tests/component-integration-tests/index.tsx b/apps/src/tests/component-integration-tests/index.tsx index d951e11ecc..9d3f33f81d 100644 --- a/apps/src/tests/component-integration-tests/index.tsx +++ b/apps/src/tests/component-integration-tests/index.tsx @@ -11,6 +11,7 @@ import OrientationScenarioGroup from './orientation'; import ScrollViewScenarioGroup from './scroll-view'; import FormSheetScenarioGroup from './form-sheet'; import TabsInStackV5ScenarioGroup from './tabs-stack-v5'; +import TabsStackV4ScenarioGroup from './tabs-stack-v4'; import SvmScenarioGroup from './scroll-view-marker'; import ScenarioSelectionScreen from '@apps/tests/shared/ScenarioScreen'; @@ -18,6 +19,7 @@ export * from './orientation'; export * from './scroll-view'; export * from './form-sheet'; export * from './tabs-stack-v5'; +export * from './tabs-stack-v4'; export * from './scroll-view-marker'; export const COMPONENT_SCENARIOS = { @@ -25,6 +27,7 @@ export const COMPONENT_SCENARIOS = { ScrollView: ScrollViewScenarioGroup, FormSheet: FormSheetScenarioGroup, TabsInStackV5: TabsInStackV5ScenarioGroup, + TabsStackV4: TabsStackV4ScenarioGroup, ScrollViewMarker: SvmScenarioGroup, } as const; diff --git a/apps/src/tests/component-integration-tests/tabs-stack-v4/index.ts b/apps/src/tests/component-integration-tests/tabs-stack-v4/index.ts new file mode 100644 index 0000000000..cf37501e24 --- /dev/null +++ b/apps/src/tests/component-integration-tests/tabs-stack-v4/index.ts @@ -0,0 +1,16 @@ +import type { ScenarioGroup } from '@apps/tests/shared/helpers'; +import TestTabsSearchTabActivation from './test-tabs-search-tab-activation-ios'; + +export { default as TestTabsSearchTabActivation } from './test-tabs-search-tab-activation-ios'; + +const scenarios = { + TestTabsSearchTabActivation, +}; + +const TabsStackV4ScenarioGroup: ScenarioGroup = { + name: 'Stack V4 & Native Tabs Integration Tests', + details: 'Test interaction between TabsContainer and the legacy (v4) stack', + scenarios, +}; + +export default TabsStackV4ScenarioGroup; diff --git a/apps/src/tests/single-feature-tests/tabs/test-tabs-search-tab-activation-ios/index.tsx b/apps/src/tests/component-integration-tests/tabs-stack-v4/test-tabs-search-tab-activation-ios/index.tsx similarity index 100% rename from apps/src/tests/single-feature-tests/tabs/test-tabs-search-tab-activation-ios/index.tsx rename to apps/src/tests/component-integration-tests/tabs-stack-v4/test-tabs-search-tab-activation-ios/index.tsx diff --git a/apps/src/tests/single-feature-tests/tabs/test-tabs-search-tab-activation-ios/scenario-description.ts b/apps/src/tests/component-integration-tests/tabs-stack-v4/test-tabs-search-tab-activation-ios/scenario-description.ts similarity index 100% rename from apps/src/tests/single-feature-tests/tabs/test-tabs-search-tab-activation-ios/scenario-description.ts rename to apps/src/tests/component-integration-tests/tabs-stack-v4/test-tabs-search-tab-activation-ios/scenario-description.ts diff --git a/apps/src/tests/single-feature-tests/tabs/test-tabs-search-tab-activation-ios/scenario.md b/apps/src/tests/component-integration-tests/tabs-stack-v4/test-tabs-search-tab-activation-ios/scenario.md similarity index 100% rename from apps/src/tests/single-feature-tests/tabs/test-tabs-search-tab-activation-ios/scenario.md rename to apps/src/tests/component-integration-tests/tabs-stack-v4/test-tabs-search-tab-activation-ios/scenario.md diff --git a/apps/src/tests/single-feature-tests/tabs/index.ts b/apps/src/tests/single-feature-tests/tabs/index.ts index f58091e32c..7bc4c20b9f 100644 --- a/apps/src/tests/single-feature-tests/tabs/index.ts +++ b/apps/src/tests/single-feature-tests/tabs/index.ts @@ -22,7 +22,6 @@ import TestTabsItemIcon from './test-tabs-item-icon'; import TestTabsItemTitle from './test-tabs-item-title'; import TestTabsItemBadge from './test-tabs-item-badge'; import TestTabsSystemItem from './test-tabs-system-item-ios'; -import TestTabsSearchTabActivation from './test-tabs-search-tab-activation-ios'; import TestTabsMoreNavigationController from './test-tabs-more-navigation-controller-ios'; import TestTabsTabBarMinimizeBehavior from './test-tabs-tab-bar-minimize-behavior-ios'; import TestTabsTabBarControllerMode from './test-tabs-tab-bar-controller-mode-ios'; @@ -52,7 +51,6 @@ export { default as TestTabsItemIcon } from './test-tabs-item-icon'; export { default as TestTabsItemTitle } from './test-tabs-item-title'; export { default as TestTabsItemBadge } from './test-tabs-item-badge'; export { default as TestTabsSystemItem } from './test-tabs-system-item-ios'; -export { default as TestTabsSearchTabActivation } from './test-tabs-search-tab-activation-ios'; export { default as TestTabsMoreNavigationController } from './test-tabs-more-navigation-controller-ios'; export { default as TestTabsTabBarMinimizeBehavior } from './test-tabs-tab-bar-minimize-behavior-ios'; export { default as TestTabsTabBarControllerMode } from './test-tabs-tab-bar-controller-mode-ios'; @@ -82,7 +80,6 @@ const scenarios = { TestTabsItemTitle, TestTabsItemBadge, TestTabsSystemItem, - TestTabsSearchTabActivation, TestTabsMoreNavigationController, TestTabsTabBarMinimizeBehavior, TestTabsTabBarControllerMode,