Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions ios/BottomTabPresenter.h
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
#import "RNNTabBarItemCreator.h"
@interface BottomTabPresenter : RNNBasePresenter

@property(nonatomic, strong, readonly) RNNTabBarItemCreator *tabCreator;

- (instancetype)initWithDefaultOptions:(RNNNavigationOptions *)defaultOptions
tabCreator:(RNNTabBarItemCreator *)tabCreator;

Expand Down
4 changes: 1 addition & 3 deletions ios/BottomTabPresenter.mm
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions ios/RNNBottomTabOptions.h
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
5 changes: 4 additions & 1 deletion ios/RNNBottomTabOptions.mm
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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 {
Expand All @@ -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
2 changes: 2 additions & 0 deletions ios/RNNBottomTabsController.mm
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
#import "RNNBottomTabsController.h"
#import "RNNTabBarItemCreator.h"
#import "UITabBarController+RNNOptions.h"
#import "UITabBarController+RNNUtils.h"

Expand Down Expand Up @@ -126,6 +127,7 @@ - (void)rnn_cycleAllTabsThenRestoreInitialSelection {
}

- (void)createTabBarItems:(NSArray<UIViewController *> *)childViewControllers {
_bottomTabPresenter.tabCreator.searchRoleUsed = NO;
for (UIViewController *child in childViewControllers) {
[_bottomTabPresenter applyOptions:child.resolveOptions child:child];
}
Expand Down
2 changes: 2 additions & 0 deletions ios/RNNTabBarItemCreator.h
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

@interface RNNTabBarItemCreator : NSObject

@property(nonatomic, assign) BOOL searchRoleUsed;

- (UITabBarItem *)createTabBarItem:(RNNBottomTabOptions *)bottomTabOptions
mergeItem:(UITabBarItem *)mergeItem;

Expand Down
98 changes: 98 additions & 0 deletions ios/RNNTabBarItemCreator.mm
Original file line number Diff line number Diff line change
@@ -1,15 +1,46 @@
#import "RNNTabBarItemCreator.h"
#import "RNNFontAttributesCreator.h"
#import "UIImage+utils.h"
#import <React/RCTLog.h>

@implementation RNNTabBarItemCreator

+ (NSNumber *)systemItemForRole:(NSString *)role {
static NSDictionary<NSString *, NSNumber *> *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];
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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]];
Expand Down
11 changes: 11 additions & 0 deletions playground/e2e/BottomTabs.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
});
50 changes: 50 additions & 0 deletions playground/src/screens/LayoutsScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -76,6 +78,12 @@ export default class LayoutsScreen extends NavigationComponent<NavigationProps,
<Button label="Stack" testID={STACK_BTN} onPress={this.stack} />
<Button label="BottomTabs" testID={BOTTOM_TABS_BTN} onPress={this.bottomTabs} />
<Button label="BottomTabs Styling" onPress={this.bottomTabsStyling} />
<Button
label="BottomTabs with Role"
testID={BOTTOM_TABS_ROLE_BTN}
platform="ios"
onPress={this.bottomTabsWithRole}
/>
<Button label="SideMenu" testID={SIDE_MENU_BTN} onPress={this.sideMenu} />
<Button label="Keyboard" testID={KEYBOARD_SCREEN_BTN} onPress={this.openKeyboardScreen} />
<Button
Expand Down Expand Up @@ -139,6 +147,48 @@ export default class LayoutsScreen extends NavigationComponent<NavigationProps,
});
};

bottomTabsWithRole = () => {
Navigation.showModal({
bottomTabs: {
children: [
{
stack: {
children: [{ component: { name: Screens.FirstBottomTabsScreen } }],
options: {
bottomTab: {
text: 'Home',
icon: require('../../img/whatshot.png'),
},
},
},
},
{
stack: {
children: [{ component: { name: Screens.Pushed } }],
options: {
bottomTab: {
text: 'Content',
icon: require('../../img/layouts.png'),
},
},
},
},
{
component: {
name: Screens.Pushed,
options: {
bottomTab: {
role: 'search',
testID: BOTTOM_TABS_ROLE_SEARCH_TAB,
},
},
},
},
],
},
});
};

sideMenu = () =>
Navigation.showModal({
sideMenu: {
Expand Down
4 changes: 4 additions & 0 deletions playground/src/testIDs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,10 @@ const testIDs = {
OPTIONS_SCREEN_HEADER: 'OPTIONS_SCREEN_HEADER',
MODAL_SCREEN: 'MODAL_SCREEN',
CENTERED_TEXT_HEADER: 'CENTERED_TEXT_HEADER',

// BottomTab Role
BOTTOM_TABS_ROLE_BTN: 'BOTTOM_TABS_ROLE_BTN',
BOTTOM_TABS_ROLE_SEARCH_TAB: 'BOTTOM_TABS_ROLE_SEARCH_TAB',
};

export default testIDs;
37 changes: 37 additions & 0 deletions src/interfaces/Options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1148,6 +1148,43 @@ export interface OptionsBottomTab {
* #### (iOS 13+ specific)
*/
sfSelectedSymbol?: string;
/**
* Set the tab bar item to a system-provided role.
* Uses `UITabBarItem(tabBarSystemItem:)` on iOS.
*
* By default the system provides the icon and title. You can
* override the icon by providing `icon`, `selectedIcon`,
* `sfSymbol`, or `sfSelectedSymbol` — they are applied after
* creation and replace the system-provided image.
* `iconColor` and `selectedIconColor` are applied as a tint
* to the custom icon when one is provided.
*
* **`'search'`** — on iOS 26+ this renders as a floating
* Liquid Glass button. Only one tab per `bottomTabs` layout
* may use this role; duplicates fall back to a normal tab.
*
* **`'more'`** — activates UIKit's built-in "More" navigation
* controller. Tabs beyond the visible limit are moved into
* it automatically.
*
* Properties that remain ignored for role tabs: `text`,
* `fontSize`, `fontFamily`, `fontWeight`, `iconInsets`.
*
* #### (iOS specific)
*/
role?:
| 'search'
| 'bookmarks'
| 'contacts'
| 'downloads'
| 'favorites'
| 'featured'
| 'history'
| 'more'
| 'mostRecent'
| 'mostViewed'
| 'recents'
| 'topRated';
}

export interface SideMenuSide {
Expand Down