Skip to content

Commit 5a9d9ed

Browse files
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 <cursoragent@cursor.com>
1 parent 4928dd9 commit 5a9d9ed

11 files changed

Lines changed: 212 additions & 4 deletions

ios/BottomTabPresenter.h

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
#import "RNNTabBarItemCreator.h"
33
@interface BottomTabPresenter : RNNBasePresenter
44

5+
@property(nonatomic, strong, readonly) RNNTabBarItemCreator *tabCreator;
6+
57
- (instancetype)initWithDefaultOptions:(RNNNavigationOptions *)defaultOptions
68
tabCreator:(RNNTabBarItemCreator *)tabCreator;
79

ios/BottomTabPresenter.mm

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,7 @@
33
#import "UIViewController+LayoutProtocol.h"
44
#import "UIViewController+RNNOptions.h"
55

6-
@implementation BottomTabPresenter {
7-
RNNTabBarItemCreator *_tabCreator;
8-
}
6+
@implementation BottomTabPresenter
97

108
- (instancetype)initWithDefaultOptions:(RNNNavigationOptions *)defaultOptions
119
tabCreator:(RNNTabBarItemCreator *)tabCreator {

ios/RNNBottomTabOptions.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
@property(nonatomic, strong) Bool *selectTabOnPress;
2626
@property(nonatomic, strong) Text *sfSymbol;
2727
@property(nonatomic, strong) Text *sfSelectedSymbol;
28+
@property(nonatomic, strong) Text *role;
2829

2930
- (BOOL)hasValue;
3031

ios/RNNBottomTabOptions.mm

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ - (instancetype)initWithDict:(NSDictionary *)dict {
3131
self.selectTabOnPress = [BoolParser parse:dict key:@"selectTabOnPress"];
3232
self.sfSymbol = [TextParser parse:dict key:@"sfSymbol"];
3333
self.sfSelectedSymbol = [TextParser parse:dict key:@"sfSelectedSymbol"];
34+
self.role = [TextParser parse:dict key:@"role"];
3435

3536
return self;
3637
}
@@ -76,6 +77,8 @@ - (void)mergeOptions:(RNNBottomTabOptions *)options {
7677
self.sfSymbol = options.sfSymbol;
7778
if (options.sfSelectedSymbol.hasValue)
7879
self.sfSelectedSymbol = options.sfSelectedSymbol;
80+
if (options.role.hasValue)
81+
self.role = options.role;
7982
}
8083

8184
- (BOOL)hasValue {
@@ -85,7 +88,7 @@ - (BOOL)hasValue {
8588
self.iconColor.hasValue || self.selectedIconColor.hasValue ||
8689
self.selectedTextColor.hasValue || self.iconInsets.hasValue || self.textColor.hasValue ||
8790
self.visible.hasValue || self.selectTabOnPress.hasValue || self.sfSymbol.hasValue ||
88-
self.sfSelectedSymbol.hasValue;
91+
self.sfSelectedSymbol.hasValue || self.role.hasValue;
8992
}
9093

9194
@end

ios/RNNBottomTabsController.mm

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
#import "RNNBottomTabsController.h"
2+
#import "RNNTabBarItemCreator.h"
23
#import "UITabBarController+RNNOptions.h"
34
#import "UITabBarController+RNNUtils.h"
45

@@ -95,6 +96,7 @@ - (void)viewDidLoad {
9596
}
9697

9798
- (void)createTabBarItems:(NSArray<UIViewController *> *)childViewControllers {
99+
_bottomTabPresenter.tabCreator.searchRoleUsed = NO;
98100
for (UIViewController *child in childViewControllers) {
99101
[_bottomTabPresenter applyOptions:child.resolveOptions child:child];
100102
}

ios/RNNTabBarItemCreator.h

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44

55
@interface RNNTabBarItemCreator : NSObject
66

7+
@property(nonatomic, assign) BOOL searchRoleUsed;
8+
79
- (UITabBarItem *)createTabBarItem:(RNNBottomTabOptions *)bottomTabOptions
810
mergeItem:(UITabBarItem *)mergeItem;
911

ios/RNNTabBarItemCreator.mm

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,46 @@
11
#import "RNNTabBarItemCreator.h"
22
#import "RNNFontAttributesCreator.h"
33
#import "UIImage+utils.h"
4+
#import <React/RCTLog.h>
45

56
@implementation RNNTabBarItemCreator
67

8+
+ (NSNumber *)systemItemForRole:(NSString *)role {
9+
static NSDictionary<NSString *, NSNumber *> *map = nil;
10+
static dispatch_once_t onceToken;
11+
dispatch_once(&onceToken, ^{
12+
map = @{
13+
@"search" : @(UITabBarSystemItemSearch),
14+
@"bookmarks" : @(UITabBarSystemItemBookmarks),
15+
@"contacts" : @(UITabBarSystemItemContacts),
16+
@"downloads" : @(UITabBarSystemItemDownloads),
17+
@"favorites" : @(UITabBarSystemItemFavorites),
18+
@"featured" : @(UITabBarSystemItemFeatured),
19+
@"history" : @(UITabBarSystemItemHistory),
20+
@"more" : @(UITabBarSystemItemMore),
21+
@"mostRecent" : @(UITabBarSystemItemMostRecent),
22+
@"mostViewed" : @(UITabBarSystemItemMostViewed),
23+
@"recents" : @(UITabBarSystemItemRecents),
24+
@"topRated" : @(UITabBarSystemItemTopRated),
25+
};
26+
});
27+
return map[role];
28+
}
29+
730
- (UITabBarItem *)createTabBarItem:(UITabBarItem *)mergeItem {
831
return mergeItem ?: [UITabBarItem new];
932
}
1033

1134
- (UITabBarItem *)createTabBarItem:(RNNBottomTabOptions *)bottomTabOptions
1235
mergeItem:(UITabBarItem *)mergeItem {
36+
37+
if (bottomTabOptions.role.hasValue) {
38+
UITabBarItem *roleItem = [self createSystemItemForRole:bottomTabOptions
39+
mergeItem:mergeItem];
40+
if (roleItem)
41+
return roleItem;
42+
}
43+
1344
UITabBarItem *tabItem = [self createTabBarItem:mergeItem];
1445
UIImage *icon = [bottomTabOptions.icon withDefault:nil];
1546
UIImage *selectedIcon = [bottomTabOptions.selectedIcon withDefault:icon];
@@ -55,6 +86,71 @@ - (UITabBarItem *)createTabBarItem:(RNNBottomTabOptions *)bottomTabOptions
5586
return tabItem;
5687
}
5788

89+
#pragma mark - Role (system item) creation
90+
91+
- (UITabBarItem *)createSystemItemForRole:(RNNBottomTabOptions *)bottomTabOptions
92+
mergeItem:(UITabBarItem *)mergeItem {
93+
NSString *role = [bottomTabOptions.role withDefault:nil];
94+
NSNumber *systemItemNumber = [RNNTabBarItemCreator systemItemForRole:role];
95+
96+
if (!systemItemNumber) {
97+
RCTLogWarn(@"[RNN] Unknown bottomTab role '%@' — falling back to normal tab.", role);
98+
return nil;
99+
}
100+
101+
if ([role isEqualToString:@"search"] && self.searchRoleUsed) {
102+
RCTLogWarn(@"[RNN] Only one tab per bottomTabs layout may use role:'search'. "
103+
@"Subsequent 'search' roles are ignored — creating a normal tab instead.");
104+
return nil;
105+
}
106+
107+
if ([role isEqualToString:@"search"]) {
108+
self.searchRoleUsed = YES;
109+
}
110+
111+
UITabBarItem *tabItem =
112+
[[UITabBarItem alloc] initWithTabBarSystemItem:(UITabBarSystemItem)[systemItemNumber integerValue]
113+
tag:bottomTabOptions.tag];
114+
115+
UITabBarItem *baseItem = [self createTabBarItem:mergeItem];
116+
tabItem.standardAppearance = baseItem.standardAppearance;
117+
#if __IPHONE_OS_VERSION_MAX_ALLOWED >= 150000
118+
if (@available(iOS 15.0, *)) {
119+
tabItem.scrollEdgeAppearance = baseItem.scrollEdgeAppearance;
120+
}
121+
#endif
122+
123+
UIImage *icon = [bottomTabOptions.icon withDefault:nil];
124+
UIImage *selectedIcon = [bottomTabOptions.selectedIcon withDefault:nil];
125+
UIColor *iconColor = [bottomTabOptions.iconColor withDefault:nil];
126+
UIColor *selectedIconColor = [bottomTabOptions.selectedIconColor withDefault:iconColor];
127+
128+
if (@available(iOS 13.0, *)) {
129+
if (bottomTabOptions.sfSymbol.hasValue) {
130+
icon = [UIImage systemImageNamed:[bottomTabOptions.sfSymbol withDefault:nil]];
131+
}
132+
if (bottomTabOptions.sfSelectedSymbol.hasValue) {
133+
selectedIcon = [UIImage systemImageNamed:[bottomTabOptions.sfSelectedSymbol withDefault:nil]];
134+
}
135+
}
136+
137+
if (icon) {
138+
tabItem.image = [self getIconImage:icon withTint:iconColor];
139+
}
140+
if (selectedIcon) {
141+
tabItem.selectedImage = [self getSelectedIconImage:selectedIcon
142+
selectedIconColor:selectedIconColor];
143+
}
144+
145+
tabItem.accessibilityIdentifier = [bottomTabOptions.testID withDefault:nil];
146+
tabItem.accessibilityLabel = [bottomTabOptions.accessibilityLabel withDefault:nil];
147+
[self appendTitleAttributes:tabItem bottomTabOptions:bottomTabOptions];
148+
149+
return tabItem;
150+
}
151+
152+
#pragma mark - Icon helpers
153+
58154
- (UIImage *)getSelectedIconImage:(UIImage *)selectedIcon
59155
selectedIconColor:(UIColor *)selectedIconColor {
60156
if (selectedIcon) {
@@ -82,6 +178,8 @@ - (UIImage *)getIconImage:(UIImage *)icon withTint:(UIColor *)tintColor {
82178
return nil;
83179
}
84180

181+
#pragma mark - Title attributes
182+
85183
- (void)appendTitleAttributes:(UITabBarItem *)tabItem
86184
bottomTabOptions:(RNNBottomTabOptions *)bottomTabOptions {
87185
UIColor *textColor = [bottomTabOptions.textColor withDefault:[UIColor blackColor]];

playground/e2e/BottomTabs.test.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,4 +178,15 @@ describe('BottomTabs', () => {
178178
elementByLabel('componentDidAppear | FirstBottomTabsScreen | Component')
179179
).toBeVisible();
180180
});
181+
182+
describe(':ios: BottomTab Role', () => {
183+
beforeEach(async () => {
184+
await device.launchApp({ newInstance: true });
185+
await elementById(TestIDs.BOTTOM_TABS_ROLE_BTN).tap();
186+
});
187+
188+
it('should render a search role tab', async () => {
189+
await expect(elementById(TestIDs.BOTTOM_TABS_ROLE_SEARCH_TAB)).toExist();
190+
});
191+
});
181192
});

playground/src/screens/LayoutsScreen.tsx

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ const {
2323
SIDE_MENU_BTN,
2424
KEYBOARD_SCREEN_BTN,
2525
SPLIT_VIEW_BUTTON,
26+
BOTTOM_TABS_ROLE_BTN,
27+
BOTTOM_TABS_ROLE_SEARCH_TAB,
2628
} = testIDs;
2729

2830
interface State {
@@ -76,6 +78,12 @@ export default class LayoutsScreen extends NavigationComponent<NavigationProps,
7678
<Button label="Stack" testID={STACK_BTN} onPress={this.stack} />
7779
<Button label="BottomTabs" testID={BOTTOM_TABS_BTN} onPress={this.bottomTabs} />
7880
<Button label="BottomTabs Styling" onPress={this.bottomTabsStyling} />
81+
<Button
82+
label="BottomTabs with Role"
83+
testID={BOTTOM_TABS_ROLE_BTN}
84+
platform="ios"
85+
onPress={this.bottomTabsWithRole}
86+
/>
7987
<Button label="SideMenu" testID={SIDE_MENU_BTN} onPress={this.sideMenu} />
8088
<Button label="Keyboard" testID={KEYBOARD_SCREEN_BTN} onPress={this.openKeyboardScreen} />
8189
<Button
@@ -139,6 +147,48 @@ export default class LayoutsScreen extends NavigationComponent<NavigationProps,
139147
});
140148
};
141149

150+
bottomTabsWithRole = () => {
151+
Navigation.showModal({
152+
bottomTabs: {
153+
children: [
154+
{
155+
stack: {
156+
children: [{ component: { name: Screens.FirstBottomTabsScreen } }],
157+
options: {
158+
bottomTab: {
159+
text: 'Home',
160+
icon: require('../../img/whatshot.png'),
161+
},
162+
},
163+
},
164+
},
165+
{
166+
stack: {
167+
children: [{ component: { name: Screens.Pushed } }],
168+
options: {
169+
bottomTab: {
170+
text: 'Content',
171+
icon: require('../../img/layouts.png'),
172+
},
173+
},
174+
},
175+
},
176+
{
177+
component: {
178+
name: Screens.Pushed,
179+
options: {
180+
bottomTab: {
181+
role: 'search',
182+
testID: BOTTOM_TABS_ROLE_SEARCH_TAB,
183+
},
184+
},
185+
},
186+
},
187+
],
188+
},
189+
});
190+
};
191+
142192
sideMenu = () =>
143193
Navigation.showModal({
144194
sideMenu: {

playground/src/testIDs.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,10 @@ const testIDs = {
259259
OPTIONS_SCREEN_HEADER: 'OPTIONS_SCREEN_HEADER',
260260
MODAL_SCREEN: 'MODAL_SCREEN',
261261
CENTERED_TEXT_HEADER: 'CENTERED_TEXT_HEADER',
262+
263+
// BottomTab Role
264+
BOTTOM_TABS_ROLE_BTN: 'BOTTOM_TABS_ROLE_BTN',
265+
BOTTOM_TABS_ROLE_SEARCH_TAB: 'BOTTOM_TABS_ROLE_SEARCH_TAB',
262266
};
263267

264268
export default testIDs;

0 commit comments

Comments
 (0)