From 5a9d9ed421ff12a623b9519ca4404e5f69e3c0dd Mon Sep 17 00:00:00 2001 From: Isaac Israel Date: Mon, 4 May 2026 09:48:19 +0300 Subject: [PATCH] feat(ios): add `role` option to bottomTab for UITabBarSystemItem support Adds a new `role` property to `OptionsBottomTab` that maps to `UITabBarItem(tabBarSystemItem:)`. Supports all 12 system item types. Key behaviors: - `search` on iOS 26+ renders as a floating Liquid Glass button - `more` activates UIKit's built-in More navigation controller - Custom icons (`icon`, `selectedIcon`, `sfSymbol`, `sfSelectedSymbol`) override the system image after creation - `iconColor` and `selectedIconColor` tint the custom icon - Only one `search` role per bottomTabs layout (warns on duplicates) - standardAppearance/scrollEdgeAppearance copied from the subclass chain onto system items to preserve tab bar styling Includes playground demo and Detox e2e test. Co-authored-by: Cursor --- ios/BottomTabPresenter.h | 2 + ios/BottomTabPresenter.mm | 4 +- ios/RNNBottomTabOptions.h | 1 + ios/RNNBottomTabOptions.mm | 5 +- ios/RNNBottomTabsController.mm | 2 + ios/RNNTabBarItemCreator.h | 2 + ios/RNNTabBarItemCreator.mm | 98 ++++++++++++++++++++++++ playground/e2e/BottomTabs.test.js | 11 +++ playground/src/screens/LayoutsScreen.tsx | 50 ++++++++++++ playground/src/testIDs.ts | 4 + src/interfaces/Options.ts | 37 +++++++++ 11 files changed, 212 insertions(+), 4 deletions(-) diff --git a/ios/BottomTabPresenter.h b/ios/BottomTabPresenter.h index ef7ba673b9d..5ba0cdd15c8 100644 --- a/ios/BottomTabPresenter.h +++ b/ios/BottomTabPresenter.h @@ -2,6 +2,8 @@ #import "RNNTabBarItemCreator.h" @interface BottomTabPresenter : RNNBasePresenter +@property(nonatomic, strong, readonly) RNNTabBarItemCreator *tabCreator; + - (instancetype)initWithDefaultOptions:(RNNNavigationOptions *)defaultOptions tabCreator:(RNNTabBarItemCreator *)tabCreator; diff --git a/ios/BottomTabPresenter.mm b/ios/BottomTabPresenter.mm index e8fda7f80c1..ebb1a679361 100644 --- a/ios/BottomTabPresenter.mm +++ b/ios/BottomTabPresenter.mm @@ -3,9 +3,7 @@ #import "UIViewController+LayoutProtocol.h" #import "UIViewController+RNNOptions.h" -@implementation BottomTabPresenter { - RNNTabBarItemCreator *_tabCreator; -} +@implementation BottomTabPresenter - (instancetype)initWithDefaultOptions:(RNNNavigationOptions *)defaultOptions tabCreator:(RNNTabBarItemCreator *)tabCreator { diff --git a/ios/RNNBottomTabOptions.h b/ios/RNNBottomTabOptions.h index 7d79e68bb76..809482f3f30 100644 --- a/ios/RNNBottomTabOptions.h +++ b/ios/RNNBottomTabOptions.h @@ -25,6 +25,7 @@ @property(nonatomic, strong) Bool *selectTabOnPress; @property(nonatomic, strong) Text *sfSymbol; @property(nonatomic, strong) Text *sfSelectedSymbol; +@property(nonatomic, strong) Text *role; - (BOOL)hasValue; diff --git a/ios/RNNBottomTabOptions.mm b/ios/RNNBottomTabOptions.mm index a164e64aae4..2a959c99d83 100644 --- a/ios/RNNBottomTabOptions.mm +++ b/ios/RNNBottomTabOptions.mm @@ -31,6 +31,7 @@ - (instancetype)initWithDict:(NSDictionary *)dict { self.selectTabOnPress = [BoolParser parse:dict key:@"selectTabOnPress"]; self.sfSymbol = [TextParser parse:dict key:@"sfSymbol"]; self.sfSelectedSymbol = [TextParser parse:dict key:@"sfSelectedSymbol"]; + self.role = [TextParser parse:dict key:@"role"]; return self; } @@ -76,6 +77,8 @@ - (void)mergeOptions:(RNNBottomTabOptions *)options { self.sfSymbol = options.sfSymbol; if (options.sfSelectedSymbol.hasValue) self.sfSelectedSymbol = options.sfSelectedSymbol; + if (options.role.hasValue) + self.role = options.role; } - (BOOL)hasValue { @@ -85,7 +88,7 @@ - (BOOL)hasValue { self.iconColor.hasValue || self.selectedIconColor.hasValue || self.selectedTextColor.hasValue || self.iconInsets.hasValue || self.textColor.hasValue || self.visible.hasValue || self.selectTabOnPress.hasValue || self.sfSymbol.hasValue || - self.sfSelectedSymbol.hasValue; + self.sfSelectedSymbol.hasValue || self.role.hasValue; } @end diff --git a/ios/RNNBottomTabsController.mm b/ios/RNNBottomTabsController.mm index 9fbe4784c21..dd8489d93dd 100644 --- a/ios/RNNBottomTabsController.mm +++ b/ios/RNNBottomTabsController.mm @@ -1,4 +1,5 @@ #import "RNNBottomTabsController.h" +#import "RNNTabBarItemCreator.h" #import "UITabBarController+RNNOptions.h" #import "UITabBarController+RNNUtils.h" @@ -95,6 +96,7 @@ - (void)viewDidLoad { } - (void)createTabBarItems:(NSArray *)childViewControllers { + _bottomTabPresenter.tabCreator.searchRoleUsed = NO; for (UIViewController *child in childViewControllers) { [_bottomTabPresenter applyOptions:child.resolveOptions child:child]; } diff --git a/ios/RNNTabBarItemCreator.h b/ios/RNNTabBarItemCreator.h index 127e0c31c7d..bf1a0db2ab6 100644 --- a/ios/RNNTabBarItemCreator.h +++ b/ios/RNNTabBarItemCreator.h @@ -4,6 +4,8 @@ @interface RNNTabBarItemCreator : NSObject +@property(nonatomic, assign) BOOL searchRoleUsed; + - (UITabBarItem *)createTabBarItem:(RNNBottomTabOptions *)bottomTabOptions mergeItem:(UITabBarItem *)mergeItem; diff --git a/ios/RNNTabBarItemCreator.mm b/ios/RNNTabBarItemCreator.mm index a6161cd0d69..da16233bc0d 100644 --- a/ios/RNNTabBarItemCreator.mm +++ b/ios/RNNTabBarItemCreator.mm @@ -1,15 +1,46 @@ #import "RNNTabBarItemCreator.h" #import "RNNFontAttributesCreator.h" #import "UIImage+utils.h" +#import @implementation RNNTabBarItemCreator ++ (NSNumber *)systemItemForRole:(NSString *)role { + static NSDictionary *map = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + map = @{ + @"search" : @(UITabBarSystemItemSearch), + @"bookmarks" : @(UITabBarSystemItemBookmarks), + @"contacts" : @(UITabBarSystemItemContacts), + @"downloads" : @(UITabBarSystemItemDownloads), + @"favorites" : @(UITabBarSystemItemFavorites), + @"featured" : @(UITabBarSystemItemFeatured), + @"history" : @(UITabBarSystemItemHistory), + @"more" : @(UITabBarSystemItemMore), + @"mostRecent" : @(UITabBarSystemItemMostRecent), + @"mostViewed" : @(UITabBarSystemItemMostViewed), + @"recents" : @(UITabBarSystemItemRecents), + @"topRated" : @(UITabBarSystemItemTopRated), + }; + }); + return map[role]; +} + - (UITabBarItem *)createTabBarItem:(UITabBarItem *)mergeItem { return mergeItem ?: [UITabBarItem new]; } - (UITabBarItem *)createTabBarItem:(RNNBottomTabOptions *)bottomTabOptions mergeItem:(UITabBarItem *)mergeItem { + + if (bottomTabOptions.role.hasValue) { + UITabBarItem *roleItem = [self createSystemItemForRole:bottomTabOptions + mergeItem:mergeItem]; + if (roleItem) + return roleItem; + } + UITabBarItem *tabItem = [self createTabBarItem:mergeItem]; UIImage *icon = [bottomTabOptions.icon withDefault:nil]; UIImage *selectedIcon = [bottomTabOptions.selectedIcon withDefault:icon]; @@ -55,6 +86,71 @@ - (UITabBarItem *)createTabBarItem:(RNNBottomTabOptions *)bottomTabOptions return tabItem; } +#pragma mark - Role (system item) creation + +- (UITabBarItem *)createSystemItemForRole:(RNNBottomTabOptions *)bottomTabOptions + mergeItem:(UITabBarItem *)mergeItem { + NSString *role = [bottomTabOptions.role withDefault:nil]; + NSNumber *systemItemNumber = [RNNTabBarItemCreator systemItemForRole:role]; + + if (!systemItemNumber) { + RCTLogWarn(@"[RNN] Unknown bottomTab role '%@' — falling back to normal tab.", role); + return nil; + } + + if ([role isEqualToString:@"search"] && self.searchRoleUsed) { + RCTLogWarn(@"[RNN] Only one tab per bottomTabs layout may use role:'search'. " + @"Subsequent 'search' roles are ignored — creating a normal tab instead."); + return nil; + } + + if ([role isEqualToString:@"search"]) { + self.searchRoleUsed = YES; + } + + UITabBarItem *tabItem = + [[UITabBarItem alloc] initWithTabBarSystemItem:(UITabBarSystemItem)[systemItemNumber integerValue] + tag:bottomTabOptions.tag]; + + UITabBarItem *baseItem = [self createTabBarItem:mergeItem]; + tabItem.standardAppearance = baseItem.standardAppearance; +#if __IPHONE_OS_VERSION_MAX_ALLOWED >= 150000 + if (@available(iOS 15.0, *)) { + tabItem.scrollEdgeAppearance = baseItem.scrollEdgeAppearance; + } +#endif + + UIImage *icon = [bottomTabOptions.icon withDefault:nil]; + UIImage *selectedIcon = [bottomTabOptions.selectedIcon withDefault:nil]; + UIColor *iconColor = [bottomTabOptions.iconColor withDefault:nil]; + UIColor *selectedIconColor = [bottomTabOptions.selectedIconColor withDefault:iconColor]; + + if (@available(iOS 13.0, *)) { + if (bottomTabOptions.sfSymbol.hasValue) { + icon = [UIImage systemImageNamed:[bottomTabOptions.sfSymbol withDefault:nil]]; + } + if (bottomTabOptions.sfSelectedSymbol.hasValue) { + selectedIcon = [UIImage systemImageNamed:[bottomTabOptions.sfSelectedSymbol withDefault:nil]]; + } + } + + if (icon) { + tabItem.image = [self getIconImage:icon withTint:iconColor]; + } + if (selectedIcon) { + tabItem.selectedImage = [self getSelectedIconImage:selectedIcon + selectedIconColor:selectedIconColor]; + } + + tabItem.accessibilityIdentifier = [bottomTabOptions.testID withDefault:nil]; + tabItem.accessibilityLabel = [bottomTabOptions.accessibilityLabel withDefault:nil]; + [self appendTitleAttributes:tabItem bottomTabOptions:bottomTabOptions]; + + return tabItem; +} + +#pragma mark - Icon helpers + - (UIImage *)getSelectedIconImage:(UIImage *)selectedIcon selectedIconColor:(UIColor *)selectedIconColor { if (selectedIcon) { @@ -82,6 +178,8 @@ - (UIImage *)getIconImage:(UIImage *)icon withTint:(UIColor *)tintColor { return nil; } +#pragma mark - Title attributes + - (void)appendTitleAttributes:(UITabBarItem *)tabItem bottomTabOptions:(RNNBottomTabOptions *)bottomTabOptions { UIColor *textColor = [bottomTabOptions.textColor withDefault:[UIColor blackColor]]; diff --git a/playground/e2e/BottomTabs.test.js b/playground/e2e/BottomTabs.test.js index 7b2f272892e..e5ecea4cbcd 100644 --- a/playground/e2e/BottomTabs.test.js +++ b/playground/e2e/BottomTabs.test.js @@ -178,4 +178,15 @@ describe('BottomTabs', () => { elementByLabel('componentDidAppear | FirstBottomTabsScreen | Component') ).toBeVisible(); }); + + describe(':ios: BottomTab Role', () => { + beforeEach(async () => { + await device.launchApp({ newInstance: true }); + await elementById(TestIDs.BOTTOM_TABS_ROLE_BTN).tap(); + }); + + it('should render a search role tab', async () => { + await expect(elementById(TestIDs.BOTTOM_TABS_ROLE_SEARCH_TAB)).toExist(); + }); + }); }); diff --git a/playground/src/screens/LayoutsScreen.tsx b/playground/src/screens/LayoutsScreen.tsx index 05395d99e81..0dc044aee3d 100644 --- a/playground/src/screens/LayoutsScreen.tsx +++ b/playground/src/screens/LayoutsScreen.tsx @@ -23,6 +23,8 @@ const { SIDE_MENU_BTN, KEYBOARD_SCREEN_BTN, SPLIT_VIEW_BUTTON, + BOTTOM_TABS_ROLE_BTN, + BOTTOM_TABS_ROLE_SEARCH_TAB, } = testIDs; interface State { @@ -76,6 +78,12 @@ export default class LayoutsScreen extends NavigationComponent