Skip to content
Open
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
16 changes: 0 additions & 16 deletions .buildkite/jobs/pipeline.android_rn_77.yml

This file was deleted.

15 changes: 0 additions & 15 deletions .buildkite/jobs/pipeline.ios_rn_77.yml

This file was deleted.

2 changes: 0 additions & 2 deletions .buildkite/pipeline.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,9 @@
echo "steps:"

cat .buildkite/jobs/pipeline.release.yml
cat .buildkite/jobs/pipeline.android_rn_77.yml
cat .buildkite/jobs/pipeline.android_rn_78.yml
Comment thread
guyca marked this conversation as resolved.
cat .buildkite/jobs/pipeline.android_rn_84.yml
cat .buildkite/jobs/pipeline.android_rn_85.yml
cat .buildkite/jobs/pipeline.ios_rn_77.yml
cat .buildkite/jobs/pipeline.ios_rn_78.yml
cat .buildkite/jobs/pipeline.ios_rn_84.yml
cat .buildkite/jobs/pipeline.ios_rn_85.yml
Expand Down
33 changes: 25 additions & 8 deletions ios/BottomTabsBasePresenter.mm
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,31 @@
#import "RNNConvert.h"
#import "UIImage+utils.h"

@implementation BottomTabsBasePresenter
@implementation BottomTabsBasePresenter {
BOOL _didApplyInitialTabBarVisibility;
}

- (BOOL)tabBarVisibilityAnimation:(BOOL)animated {
if (@available(iOS 18.0, *)) {
return animated;
}
if (_didApplyInitialTabBarVisibility) {
Comment thread
guyca marked this conversation as resolved.
return animated;
}

_didApplyInitialTabBarVisibility = YES;
return NO;
}

- (void)applyOptionsOnInit:(RNNNavigationOptions *)options {
[super applyOptionsOnInit:options];
UITabBarController *bottomTabs = self.tabBarController;
RNNNavigationOptions *withDefault = [options withDefault:[self defaultOptions]];
if (@available(iOS 18.0, *)) {
[bottomTabs
setTabBarHidden:![withDefault.bottomTabs.visible withDefault:YES]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This calls UIKit's setTabBarHidden:animated: directly rather than going through showTabBar:/hideTabBar: like every other visibility path in the controller, so the initial application skips _tabBarNeedsRestore bookkeeping and the custom-row sync. Routing it through the category helpers would keep a single code path for visibility changes.

animated:NO];
}
[bottomTabs setCurrentTabIndex:[withDefault.bottomTabs.currentTabIndex withDefault:0]];
if (withDefault.bottomTabs.currentTabId.hasValue) {
[bottomTabs setCurrentTabID:withDefault.bottomTabs.currentTabId.get];
Expand All @@ -24,7 +43,9 @@ - (void)applyOptions:(RNNNavigationOptions *)options {
RNNNavigationOptions *withDefault = [options withDefault:[self defaultOptions]];

[bottomTabs setTabBarTestID:[withDefault.bottomTabs.testID withDefault:nil]];
[bottomTabs setTabBarVisible:[withDefault.bottomTabs.visible withDefault:YES]];
[bottomTabs reconcileTabBarVisible:[withDefault.bottomTabs.visible withDefault:YES]
animated:[self tabBarVisibilityAnimation:
[withDefault.bottomTabs.animate withDefault:YES]]];

[bottomTabs.view setBackgroundColor:[withDefault.layout.backgroundColor withDefault:nil]];
[bottomTabs setTabBarHideShadow:[withDefault.bottomTabs.hideShadow withDefault:NO]];
Expand Down Expand Up @@ -74,12 +95,8 @@ - (void)mergeOptions:(RNNNavigationOptions *)mergeOptions
}

if (mergeOptions.bottomTabs.visible.hasValue) {
if (mergeOptions.bottomTabs.animate.hasValue) {
[bottomTabs setTabBarVisible:mergeOptions.bottomTabs.visible.get
animated:[mergeOptions.bottomTabs.animate withDefault:NO]];
} else {
[bottomTabs setTabBarVisible:mergeOptions.bottomTabs.visible.get animated:NO];
}
[bottomTabs setTabBarVisible:mergeOptions.bottomTabs.visible.get
animated:[withDefault.bottomTabs.animate withDefault:YES]];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This flips the effective default for bottomTabs.animate from off to on, and not just on iOS 18. Previously applyOptions always applied visibility non-animated via setTabBarVisible:, and mergeOptions animated only when animate was explicitly provided. With withDefault:YES here and at line 48, pushing a screen with bottomTabs.visible: false now animates the tab bar on iOS 13–17 too.

Aligning with Android (BottomTabsPresenter.kt uses animate.isTrueOrUndefined) seems right, but the new default is load-bearing now and isn't documented — OptionsBottomTabs.animate in src/interfaces/Options.ts carries no @default. Worth adding @default true there and calling the change out, since it's visible on every iOS version rather than only the one in the PR title.

}

if (mergeOptions.layout.backgroundColor.hasValue) {
Expand Down
2 changes: 2 additions & 0 deletions ios/RNNBottomTabsController.h
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@

- (void)setTabBarVisible:(BOOL)visible;

- (void)reconcileTabBarVisible:(BOOL)visible animated:(BOOL)animated;

- (void)handleTabBarLongPress:(CGPoint)locationInTabBar;

@end
17 changes: 15 additions & 2 deletions ios/RNNBottomTabsController.mm
Original file line number Diff line number Diff line change
Expand Up @@ -290,7 +290,7 @@ - (void)layoutCustomRow {
tabBarInView.size.width, desiredHeight);

_customRow.frame = rowFrame;
_customRow.hidden = self.tabBar.hidden;
_customRow.hidden = [self rnn_isTabBarHidden];
[_customRow setSelectedIndex:_currentTabIndex];
}

Expand Down Expand Up @@ -414,8 +414,21 @@ - (void)setTabBarVisible:(BOOL)visible animated:(BOOL)animated {
}

- (void)setTabBarVisible:(BOOL)visible {
[self reconcileTabBarVisible:visible animated:NO];
}

- (void)reconcileTabBarVisible:(BOOL)visible animated:(BOOL)animated {
if (@available(iOS 18.0, *)) {
BOOL shouldHide = !visible;
if (self.tabBarHidden != shouldHide) {
[self setTabBarVisible:visible animated:animated];
}
_tabBarNeedsRestore = NO;
return;
}

if (_tabBarNeedsRestore || !self.presentedComponentViewController.navigationController) {
[self setTabBarVisible:visible animated:NO];
[self setTabBarVisible:visible animated:animated];
_tabBarNeedsRestore = NO;
}
}
Expand Down
3 changes: 2 additions & 1 deletion ios/RNNComponentViewController.mm
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
#import "RNNComponentViewController.h"
#import "AnimationObserver.h"
#import "UITabBarController+RNNOptions.h"

@implementation RNNComponentViewController {
NSArray *_reactViewConstraints;
Expand Down Expand Up @@ -126,7 +127,7 @@ - (void)updateReactViewFrame {
}

- (BOOL)shouldDrawBehindBottomTabs {
return !self.tabBarController.tabBar || self.tabBarController.tabBar.isHidden ||
return !self.tabBarController.tabBar || [self.tabBarController rnn_isTabBarHidden] ||
_drawBehindBottomTabs;
}

Expand Down
2 changes: 2 additions & 0 deletions ios/UITabBarController+RNNOptions.h
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@

- (void)hideTabBar:(BOOL)animated;

- (BOOL)rnn_isTabBarHidden;

- (void)syncTabBarItemTestIDs;

@end
18 changes: 18 additions & 0 deletions ios/UITabBarController+RNNOptions.mm
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,11 @@ - (void)centerTabItems {
}

- (void)showTabBar:(BOOL)animated {
if (@available(iOS 18.0, *)) {
[self setTabBarHidden:NO animated:animated];
return;
}

static const CGFloat animationDuration = 0.15;
const CGRect tabBarVisibleFrame = CGRectMake(
self.tabBar.frame.origin.x, self.view.frame.size.height - self.tabBar.frame.size.height,
Expand All @@ -161,6 +166,11 @@ - (void)showTabBar:(BOOL)animated {
}

- (void)hideTabBar:(BOOL)animated {
if (@available(iOS 18.0, *)) {
[self setTabBarHidden:YES animated:animated];
return;
}

static const CGFloat animationDuration = 0.15;
const CGRect tabBarHiddenFrame =
CGRectMake(self.tabBar.frame.origin.x, self.view.frame.size.height,
Expand All @@ -182,6 +192,14 @@ - (void)hideTabBar:(BOOL)animated {
}
}

- (BOOL)rnn_isTabBarHidden {
if (@available(iOS 18.0, *)) {
return self.tabBarHidden;
}

return self.tabBar.hidden;
}
Comment thread
guyca marked this conversation as resolved.

- (void)forEachTab:(void (^)(UIView *, UIViewController *tabViewController,
int tabIndex))performOnTab {
int tabIndex = 0;
Expand Down
121 changes: 121 additions & 0 deletions playground/ios/NavigationTests/BottomTabsControllerTest.mm
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,127 @@ - (void)testGetCurrentChild_shouldReturnSelectedViewController {
[(RNNBottomTabsController *)self.uut selectedViewController]);
}

- (void)testSetTabBarVisible_shouldUseActiveTabBarVisibilityOnIOS18 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

None of the coverage added for this fix actually runs on an iOS 18+ runtime in CI, from either direction:

  • The iOS unit tests run against iOS 15.5 — scripts/test-unit.js:29 is testTarget('playground', 'iPhone 13', '15.5'). Every case added here is wrapped in if (@available(iOS 18.0, *)), so those bodies are skipped and the tests pass without asserting anything. Same for the new cases in RNNBottomTabsAppearancePresenterTest and UITabBarController+RNNOptionsTest. Only the BeforeIOS18 branches execute.
  • The e2e job does run where the new code path is live (playground/e2e/detox.config.js:24, iPhone 17 Pro Max / 26.1), but the playground reproduction added in this PR has no Detox spec — SINGLE_BOTTOM_TAB_MODAL_BTN is wired to a button and never referenced by a test.

So the suite that runs on a modern OS doesn't exercise this, and the tests that exercise it don't run on a modern OS. An e2e case on the new repro screen asserting the tab bar stays hidden looks like the cheapest fix, since that job is already on iOS 26. Bumping the unit-test destination to iOS 18+ would work too, though it changes what the whole suite verifies.

if (@available(iOS 18.0, *)) {
[self.originalUut setTabBarVisible:NO];
XCTAssertTrue(self.originalUut.tabBarHidden);

[self.originalUut setTabBarVisible:YES];
XCTAssertFalse(self.originalUut.tabBarHidden);
}
}

- (void)testSetTabBarVisible_shouldShowHiddenTabBarForStackChildOnIOS18 {
if (@available(iOS 18.0, *)) {
UIViewController *component =
[RNNComponentViewController createWithComponentId:@"componentId"
initialOptions:[RNNNavigationOptions emptyOptions]];
UINavigationController *stack =
[[UINavigationController alloc] initWithRootViewController:component];
RNNBottomTabsController *uut =
[RNNBottomTabsController createWithChildren:@[ stack ]];

[uut setTabBarHidden:YES animated:NO];
[uut setTabBarVisible:YES];
XCTAssertFalse(uut.tabBarHidden);
}
}

- (void)testSetTabBarVisible_shouldHideVisibleTabBarForStackChildOnIOS18 {
if (@available(iOS 18.0, *)) {
UIViewController *component =
[RNNComponentViewController createWithComponentId:@"componentId"
initialOptions:[RNNNavigationOptions emptyOptions]];
UINavigationController *stack =
[[UINavigationController alloc] initWithRootViewController:component];
RNNBottomTabsController *uut =
[RNNBottomTabsController createWithChildren:@[ stack ]];

[uut setTabBarHidden:NO animated:NO];
[uut setTabBarVisible:NO];
XCTAssertTrue(uut.tabBarHidden);
}
}

- (void)testSetTabBarVisible_shouldNotUpdateMatchingVisibilityOnIOS18 {
if (@available(iOS 18.0, *)) {
[self.originalUut setTabBarHidden:YES animated:NO];
id uutMock = self.uut;
[[uutMock reject] setTabBarVisible:NO animated:NO];
[[uutMock reject] setTabBarVisible:NO animated:YES];

[uutMock reconcileTabBarVisible:NO animated:YES];

[uutMock verify];
XCTAssertTrue(self.originalUut.tabBarHidden);
}
}

- (void)testReconcileTabBarVisible_shouldUseRequestedAnimationOnIOS18 {
if (@available(iOS 18.0, *)) {
[self.originalUut setTabBarHidden:YES animated:NO];
id uutMock = self.uut;
[[uutMock expect] setTabBarVisible:YES animated:YES];

[uutMock reconcileTabBarVisible:YES animated:YES];

[uutMock verify];
}
}

- (void)testReconcileTabBarVisible_shouldUseRequestedHideAnimationOnIOS18 {
if (@available(iOS 18.0, *)) {
[self.originalUut setTabBarHidden:NO animated:NO];
id uutMock = self.uut;
[[uutMock expect] setTabBarVisible:NO animated:YES];

[uutMock reconcileTabBarVisible:NO animated:YES];

[uutMock verify];
}
}

- (void)testReconcileTabBarVisible_shouldClearRestoreBookkeepingOnIOS18 {
if (@available(iOS 18.0, *)) {
[self.originalUut setTabBarVisible:NO animated:NO];
XCTAssertTrue([[self.originalUut valueForKey:@"tabBarNeedsRestore"] boolValue]);

[self.originalUut reconcileTabBarVisible:NO animated:NO];

XCTAssertFalse([[self.originalUut valueForKey:@"tabBarNeedsRestore"] boolValue]);
}
}

- (void)testReconcileTabBarVisible_shouldPreserveStackVisibilityBeforeIOS18 {
if (@available(iOS 18.0, *)) {
return;
}

UIViewController *component =
[RNNComponentViewController createWithComponentId:@"componentId"
initialOptions:[RNNNavigationOptions emptyOptions]];
UINavigationController *stack =
[[UINavigationController alloc] initWithRootViewController:component];
RNNBottomTabsController *uut = [RNNBottomTabsController createWithChildren:@[ stack ]];

[uut reconcileTabBarVisible:NO animated:YES];

XCTAssertFalse(uut.tabBar.hidden);
}

- (void)testReconcileTabBarVisible_shouldUseRequestedAnimationWithoutStackBeforeIOS18 {
if (@available(iOS 18.0, *)) {
return;
}

id uutMock = self.uut;
[[uutMock expect] setTabBarVisible:NO animated:YES];

[uutMock reconcileTabBarVisible:NO animated:YES];

[uutMock verify];
}

- (void)testPreferredStatusBarStyle_shouldInvokeSelectedViewControllerPreferredStatusBarStyle {
[[self.mockTabBarPresenter expect] getStatusBarStyle];
[self.uut preferredStatusBarStyle];
Expand Down
Loading