Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
14c53c5
feat(iOS, Tabs): Migrate to UITab API for iOS >= 18
kmichalikk Sep 22, 2026
898e5ad
refactor the implementation to be closer to 4706
kmichalikk Sep 22, 2026
c78e705
use UITab for iOS >= 26.1
kmichalikk Sep 22, 2026
0ed8a5a
update comments
kmichalikk Sep 23, 2026
a7eac47
cleanup
kmichalikk Sep 23, 2026
688bb38
handle badges init
kmichalikk Sep 23, 2026
68002cc
extract workaround
kmichalikk Sep 23, 2026
b2c7f8a
bring back updateNavigationStateOnModelUpdate and generalize
kmichalikk Sep 24, 2026
70f4aa7
add separate macros for UITab enabling
kmichalikk Sep 24, 2026
1f8362d
reorder functions
kmichalikk Sep 24, 2026
aaeb0b6
remove tab count check
kmichalikk Sep 24, 2026
58ff129
add comment explaining more navigation entrance check
kmichalikk Sep 24, 2026
5135ea3
query viewController.tab instead of searching
kmichalikk Sep 25, 2026
3587014
reword comment on stale system item
kmichalikk Sep 25, 2026
311fba9
rework badge assignment
kmichalikk Sep 25, 2026
c337d9f
use installedScreenControllers
kmichalikk Sep 25, 2026
1a8da36
bring back updateSelectedViewControllerTo
kmichalikk Sep 25, 2026
6a46716
merge viewController and tab api where possible
kmichalikk Sep 25, 2026
f6afb31
move tab item config to separate coordinator invoked later
kmichalikk Sep 28, 2026
cad5455
refactor api checks
kmichalikk Sep 28, 2026
3d5a2f8
offload all tab delegate calls to viewController equivalents
kmichalikk Sep 28, 2026
3fdd70d
source title for ipad
kmichalikk Sep 28, 2026
1be84c9
refactor isScreenControllerCurrentlySelected
kmichalikk Sep 28, 2026
e046518
Find the effctive displayed viewController, emit correct events
kmichalikk Sep 29, 2026
13917a7
use setTabs:animated
kmichalikk Sep 29, 2026
690b090
defensively clear isHandlingUserTabSelection flag
kmichalikk Sep 29, 2026
7cfdd70
fix stale comments
kmichalikk Sep 29, 2026
06ac680
set the tab title in addition to item title
kmichalikk Sep 29, 2026
157f9e8
correctly render symbols and images on tabs on iPad
kmichalikk Sep 29, 2026
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
29 changes: 23 additions & 6 deletions ios/tabs/RNSTabBarAppearanceCoordinator.mm
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
#import <React/RCTLog.h>
#import "RCTConvert+RNSTabs.h"
#import "RNSConversions-Tabs.h"
#import "RNSDefines.h"
#import "RNSImageLoadingHelper.h"
#import "RNSTabBarController.h"
#import "RNSTabsHostComponentView.h"
Expand Down Expand Up @@ -56,6 +57,20 @@ - (void)configureTabBarItemForTabScreenController:(nonnull RNSTabsScreenViewCont
withImageLoader:imageLoader];
}

/// Sets the normal icon on both the item (renders the iPhone bar) and the UITab (renders the
/// iPad floating bar / sidebar, which never reads the item).
- (void)setNormalImage:(nullable UIImage *)image
forTabBarItem:(nonnull UITabBarItem *)tabBarItem
ofScreenView:(nullable RNSTabsScreenComponentView *)screenView
{
tabBarItem.image = image;
#if RNS_UITAB_API_SDK_AVAILABLE
if (RNS_UITAB_API_ENABLED) {
screenView.controller.tab.image = image;
}
#endif // RNS_UITAB_API_SDK_AVAILABLE
}

- (void)setIconsForTabBarItem:(UITabBarItem *)tabBarItem
fromScreenView:(RNSTabsScreenComponentView *)screenView
withImageLoader:(RCTImageLoader *_Nullable)imageLoader
Expand All @@ -67,13 +82,13 @@ - (void)setIconsForTabBarItem:(UITabBarItem *)tabBarItem
if (image == nil) {
RCTLogWarn(@"[RNScreens] Failed to load SF Symbol \"%@\" for tab bar item", screenView.iconResourceName);
}
tabBarItem.image = image;
[self setNormalImage:image forTabBarItem:tabBarItem ofScreenView:screenView];
} else {
UIImage *image = [UIImage imageNamed:screenView.iconResourceName];
if (image == nil) {
RCTLogWarn(@"[RNScreens] Failed to load xcasset \"%@\" for tab bar item", screenView.iconResourceName);
}
tabBarItem.image = image;
[self setNormalImage:image forTabBarItem:tabBarItem ofScreenView:screenView];
}
} else if (screenView.systemItem != RNSTabsScreenSystemItemNone) {
// Restore default system item icon
Expand All @@ -85,9 +100,11 @@ - (void)setIconsForTabBarItem:(UITabBarItem *)tabBarItem
(long)screenView.systemItem);
return;
}
tabBarItem.image = [[UITabBarItem alloc] initWithTabBarSystemItem:systemItem.value() tag:0].image;
UIImage *_Nullable systemItemImage =
[[UITabBarItem alloc] initWithTabBarSystemItem:systemItem.value() tag:0].image;
[self setNormalImage:systemItemImage forTabBarItem:tabBarItem ofScreenView:screenView];
} else {
tabBarItem.image = nil;
[self setNormalImage:nil forTabBarItem:tabBarItem ofScreenView:screenView];
}

if (screenView.selectedIconResourceName != nil) {
Expand Down Expand Up @@ -141,7 +158,7 @@ - (void)setIconsForTabBarItem:(UITabBarItem *)tabBarItem
forScreenView:weakScreenView];
}];
} else {
tabBarItem.image = nil;
[self setNormalImage:nil forTabBarItem:tabBarItem ofScreenView:screenView];
}

// Selected icon
Expand Down Expand Up @@ -183,7 +200,7 @@ - (void)updateTabBarItem:(nullable UITabBarItem *)tabBarItem
if (isSelected) {
tabBarItem.selectedImage = image;
} else {
tabBarItem.image = image;
[self setNormalImage:image forTabBarItem:tabBarItem ofScreenView:screenView];
}

// A layout pass is required because the image might be loaded asynchronously,
Expand Down
16 changes: 16 additions & 0 deletions ios/tabs/RNSTabBarItemCoordinator.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
#pragma once

#import <Foundation/Foundation.h>
#import "RNSTabsScreenViewController.h"

NS_ASSUME_NONNULL_BEGIN

@interface RNSTabBarItemCoordinator : NSObject

- (void)createTabBarItemsForTabScreenControllers:(nullable NSArray<RNSTabsScreenViewController *> *)tabScreenCtrls;

- (void)updateTabBarItemsForTabScreenControllers:(nullable NSArray<RNSTabsScreenViewController *> *)tabScreenCtrls;

@end

NS_ASSUME_NONNULL_END
143 changes: 143 additions & 0 deletions ios/tabs/RNSTabBarItemCoordinator.mm
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
#import "RNSTabBarItemCoordinator.h"
#import <React/RCTLog.h>
#import "RNSConversions-Tabs.h"
#import "RNSDefines.h"
#import "RNSTabsScreenComponentView.h"

@implementation RNSTabBarItemCoordinator

/**
* Creates UITabBarItem instance with set systemItem. Needs to be called before UITab creation for the same
* viewController.
*/
- (void)createTabBarItemsForTabScreenControllers:(nullable NSArray<RNSTabsScreenViewController *> *)tabScreenCtrls
{
for (RNSTabsScreenViewController *tabScreenCtrl in tabScreenCtrls) {
RNSTabsScreenComponentView *screenView = tabScreenCtrl.tabScreenComponentView;
if (screenView == nil) {
RCTLogWarn(@"[RNScreens] Nullish component view of TabScreen while tab bar item creation!");
continue;
}

if (screenView.tabBarItemNeedsRecreation) {
screenView.tabBarItemNeedsRecreation = NO;
[self createTabBarItemForTabScreenController:tabScreenCtrl];
}
}
}

/**
* Updates the runtime properties of UITabBarItem: title and badge. If used with UITab API, it should be called after
* the tab is built.
*/
- (void)updateTabBarItemsForTabScreenControllers:(nullable NSArray<RNSTabsScreenViewController *> *)tabScreenCtrls
{
for (RNSTabsScreenViewController *tabScreenCtrl in tabScreenCtrls) {
RNSTabsScreenComponentView *screenView = tabScreenCtrl.tabScreenComponentView;
if (screenView == nil) {
RCTLogWarn(@"[RNScreens] Nullish component view of TabScreen while tab bar item update!");
continue;
}

if (screenView.tabBarItemNeedsUpdate) {
screenView.tabBarItemNeedsUpdate = NO;
[self updateTabBarItemForTabScreenController:tabScreenCtrl];
}
}
}

- (void)createTabBarItemForTabScreenController:(nonnull RNSTabsScreenViewController *)tabScreenCtrl
{
RNSTabsScreenComponentView *screenView = tabScreenCtrl.tabScreenComponentView;

UITabBarItem *tabBarItem = nil;
if (screenView.systemItem != RNSTabsScreenSystemItemNone) {
std::optional<UITabBarSystemItem> systemItem =
rnscreens::conversion::RNSTabsScreenSystemItemToUITabBarSystemItem(screenView.systemItem);
if (!systemItem) {
RCTLogError(
@"[RNScreens] Conversion from tabs screen systemItem to UITabBarSystemItem failed for systemItem [%ld]",
(long)screenView.systemItem);
return;
}
tabBarItem = [[UITabBarItem alloc] initWithTabBarSystemItem:systemItem.value() tag:0];
} else {
tabBarItem = [[UITabBarItem alloc] init];
}

[self applyTabBarItemRepaintWorkaroundForTabScreenController:tabScreenCtrl];
tabScreenCtrl.tabBarItem = tabBarItem;
}

/**
* TODO: This is an ugly workaround and I would love to see it replaced.
* With UITab-managed children (iOS >= 26.1) any change to the systemItem for the first time
* results in missing icon and wrong title. Assigning a throwaway item first flips the internal logic
* so that the real assignment that follows paints synchronously.
* Remove once UIKit internals no longer require it.
*/
- (void)applyTabBarItemRepaintWorkaroundForTabScreenController:(nonnull RNSTabsScreenViewController *)tabScreenCtrl
{
#if RNS_UITAB_API_SDK_AVAILABLE
if (RNS_UITAB_API_ENABLED) {
tabScreenCtrl.tabBarItem = [[UITabBarItem alloc] init];
}
#endif // RNS_UITAB_API_SDK_AVAILABLE
}

- (void)updateTabBarItemForTabScreenController:(nonnull RNSTabsScreenViewController *)tabScreenCtrl
{
RNSTabsScreenComponentView *screenView = tabScreenCtrl.tabScreenComponentView;

NSString *evaluatedTitle = screenView.title;
if (screenView.title == nil && screenView.systemItem != RNSTabsScreenSystemItemNone) {
// Restore default system item title
std::optional<UITabBarSystemItem> systemItem =
rnscreens::conversion::RNSTabsScreenSystemItemToUITabBarSystemItem(screenView.systemItem);
if (!systemItem) {
RCTLogError(
@"[RNScreens] Conversion from tabs screen systemItem to UITabBarSystemItem failed for systemItem [%ld]",
(long)screenView.systemItem);
return;
}
evaluatedTitle = [[UITabBarItem alloc] initWithTabBarSystemItem:systemItem.value() tag:0].title;
}

[self updateTabBarItemTitle:evaluatedTitle forTabScreenController:tabScreenCtrl];
[self updateTabBarItemBadge:screenView.badgeValue forTabScreenController:tabScreenCtrl];
}

- (void)updateTabBarItemTitle:(NSString *)newTitle
forTabScreenController:(nonnull RNSTabsScreenViewController *)tabScreenCtrl
{
// Setting controller title updates also controller's tabBarItem.title but only if there
// is a change to controller title. After creating new tabBarItem, controller title
// remains the same but tabBarItem.title is nil. For consistency, we always
// update both.
if (![tabScreenCtrl.tabBarItem.title isEqualToString:newTitle] || ![tabScreenCtrl.title isEqualToString:newTitle]) {
tabScreenCtrl.title = newTitle;
tabScreenCtrl.tabBarItem.title = newTitle;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Runtime title changes never reach UITab.title, so iPad shows stale titles.

updateTabBarItemTitle:forTabScreenController: writes only controller.title and tabBarItem.title. tab.title is set once, in makeTabForTabScreenController:. Commit 3fdd70d ("source title for ipad") shows that iPad reads the title from the UITab itself, so:

  • changing the title prop at runtime on iPad (iOS 26.1+) leaves the bar showing the title from when the tab was created;
  • a systemItem tab with no title gets tab.title = @"" (the component view title is nil). The evaluated system title only lands on the item, so iPad shows an empty label.

The badge path already writes to both item and tab ("must land on both to render"). The title should do the same: set tabScreenCtrl.tab.title = newTitle under RNS_UITAB_API_ENABLED, including the evaluated system-item title.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One more thing here I don't understand is why do we set both. Setting UITabBarItem.title should be enough, right? IIRC UIViewController.title is used only as a default in case the UITabBarItem.title is not set:

1, 2.

Documentation also gives important context here - the title MUST be initialized before the tabbaritem is added to the tab bar.

@kmichalikk kmichalikk Sep 29, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

actually needed to set also the tab.title 06ac680 for tabSidebar on iPad to work on first render

haven't verified the other claims, will do later

#if RNS_UITAB_API_SDK_AVAILABLE
if (RNS_UITAB_API_ENABLED) {
// iPad sources the rendered title from the tab, not the item.
tabScreenCtrl.tab.title = newTitle;
}
#endif // RNS_UITAB_API_SDK_AVAILABLE
}
}

- (void)updateTabBarItemBadge:(NSString *)badgeValue
forTabScreenController:(nonnull RNSTabsScreenViewController *)tabScreenCtrl
{
if (![tabScreenCtrl.tabBarItem.badgeValue isEqualToString:badgeValue]) {
// The badge must land on both the item (viewController API) and the tab (UITab API) to render.
tabScreenCtrl.tabBarItem.badgeValue = badgeValue;
#if RNS_UITAB_API_SDK_AVAILABLE
if (RNS_UITAB_API_ENABLED) {
tabScreenCtrl.tab.badgeValue = badgeValue;
}
#endif // RNS_UITAB_API_SDK_AVAILABLE
}
}

@end
3 changes: 3 additions & 0 deletions ios/tabs/host/RNSTabBarController.h
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
#import "RNSContainer.h"
#import "RNSReactMountingTransactionObserving.h"
#import "RNSTabBarAppearanceCoordinator.h"
#import "RNSTabBarItemCoordinator.h"
#import "RNSTabsNavigationState.h"
#import "RNSTabsScreenViewController.h"

Expand Down Expand Up @@ -117,6 +118,8 @@ NS_ASSUME_NONNULL_BEGIN
*/
@property (nonatomic, readonly, strong, nonnull) RNSTabBarAppearanceCoordinator *tabBarAppearanceCoordinator;

@property (nonatomic, readonly, strong, nonnull) RNSTabBarItemCoordinator *tabBarItemCoordinator;

/**
* If true, the controller will reject any navigation state updates if the provenance of the
* update is stale.
Expand Down
Loading
Loading