From 71741dcc51bffcd5599748af1ca7542fb5e4d184 Mon Sep 17 00:00:00 2001 From: Alan Hughes Date: Wed, 7 Oct 2026 16:20:37 +0100 Subject: [PATCH 1/2] Fix StatusBar style and visibility on iOS 27 For apps built with the iOS 27 SDK, iOS 27 makes the UIApplication status bar setters no-ops. StatusBar now stores its state in RCTStatusBarAppearance, which the default root view controller and the modal controller report through the view controller status bar methods. Those apps need UIViewControllerBasedStatusBarAppearance set to YES. --- .../RCTDefaultReactNativeFactoryDelegate.mm | 25 ++- .../AppDelegate/RCTUIConfiguratorProtocol.h | 2 +- .../React/Base/RCTStatusBarAppearance.h | 26 +++ .../React/Base/RCTStatusBarAppearance.mm | 62 ++++++ .../React/CoreModules/RCTStatusBarManager.mm | 45 +++- .../Modal/RCTFabricModalHostViewController.h | 2 + .../Modal/RCTFabricModalHostViewController.mm | 32 ++- .../Modal/RCTModalHostViewComponentView.mm | 1 + packages/rn-tester/RNTester/Info.plist | 2 +- .../RNTesterPods.xcodeproj/project.pbxproj | 4 + .../RCTStatusBarAppearanceTests.mm | 194 ++++++++++++++++++ private/helloworld/ios/HelloWorld/Info.plist | 2 +- 12 files changed, 382 insertions(+), 15 deletions(-) create mode 100644 packages/react-native/React/Base/RCTStatusBarAppearance.h create mode 100644 packages/react-native/React/Base/RCTStatusBarAppearance.mm create mode 100644 packages/rn-tester/RNTesterUnitTests/RCTStatusBarAppearanceTests.mm diff --git a/packages/react-native/Libraries/AppDelegate/RCTDefaultReactNativeFactoryDelegate.mm b/packages/react-native/Libraries/AppDelegate/RCTDefaultReactNativeFactoryDelegate.mm index d94631a23d82..cc22ea05a4be 100644 --- a/packages/react-native/Libraries/AppDelegate/RCTDefaultReactNativeFactoryDelegate.mm +++ b/packages/react-native/Libraries/AppDelegate/RCTDefaultReactNativeFactoryDelegate.mm @@ -14,9 +14,32 @@ #endif #import +#import #import #import +@interface RCTRootViewController : UIViewController +@end + +@implementation RCTRootViewController + +- (UIStatusBarStyle)preferredStatusBarStyle +{ + return RCTStatusBarAppearance.style; +} + +- (BOOL)prefersStatusBarHidden +{ + return RCTStatusBarAppearance.hidden; +} + +- (UIStatusBarAnimation)preferredStatusBarUpdateAnimation +{ + return RCTStatusBarAppearance.updateAnimation; +} + +@end + @implementation RCTDefaultReactNativeFactoryDelegate { // C++ Native Animated provider, created once on first use (getTurboModule: may be called // concurrently for different module names). @@ -35,7 +58,7 @@ - (NSURL *_Nullable)sourceURLForBridge:(nonnull RCTBridge *)bridge - (UIViewController *)createRootViewController { - return [UIViewController new]; + return [RCTRootViewController new]; } - (void)setRootView:(UIView *)rootView toRootViewController:(UIViewController *)rootViewController diff --git a/packages/react-native/Libraries/AppDelegate/RCTUIConfiguratorProtocol.h b/packages/react-native/Libraries/AppDelegate/RCTUIConfiguratorProtocol.h index 3a97e122620e..bcfd9840cb89 100644 --- a/packages/react-native/Libraries/AppDelegate/RCTUIConfiguratorProtocol.h +++ b/packages/react-native/Libraries/AppDelegate/RCTUIConfiguratorProtocol.h @@ -41,7 +41,7 @@ NS_ASSUME_NONNULL_BEGIN /** * It creates the RootViewController. - * By default, it creates a new instance of a `UIViewController`. + * By default, it creates a view controller that returns the `RCTStatusBarAppearance` values. * You can override it to provide your own initial ViewController. * * @return: an instance of `UIViewController`. diff --git a/packages/react-native/React/Base/RCTStatusBarAppearance.h b/packages/react-native/React/Base/RCTStatusBarAppearance.h new file mode 100644 index 000000000000..5cbc3f631889 --- /dev/null +++ b/packages/react-native/React/Base/RCTStatusBarAppearance.h @@ -0,0 +1,26 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#import + +NS_ASSUME_NONNULL_BEGIN + +/** + * The status bar state set from JavaScript. Root view controllers return these values from their status bar methods. + */ +@interface RCTStatusBarAppearance : NSObject + +@property (class, nonatomic, readonly) UIStatusBarStyle style; +@property (class, nonatomic, readonly) BOOL hidden; +@property (class, nonatomic, readonly) UIStatusBarAnimation updateAnimation; + ++ (void)setStyle:(UIStatusBarStyle)style animated:(BOOL)animated; ++ (void)setHidden:(BOOL)hidden withAnimation:(UIStatusBarAnimation)animation; + +@end + +NS_ASSUME_NONNULL_END diff --git a/packages/react-native/React/Base/RCTStatusBarAppearance.mm b/packages/react-native/React/Base/RCTStatusBarAppearance.mm new file mode 100644 index 000000000000..2592910edcb2 --- /dev/null +++ b/packages/react-native/React/Base/RCTStatusBarAppearance.mm @@ -0,0 +1,62 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#import "RCTStatusBarAppearance.h" + +#import "RCTAssert.h" +#import "RCTUtils.h" + +static UIStatusBarStyle sStyle = UIStatusBarStyleDefault; +static BOOL sHidden = NO; +static UIStatusBarAnimation sUpdateAnimation = UIStatusBarAnimationNone; + +@implementation RCTStatusBarAppearance + ++ (UIStatusBarStyle)style +{ + return sStyle; +} + ++ (BOOL)hidden +{ + return sHidden; +} + ++ (UIStatusBarAnimation)updateAnimation +{ + return sUpdateAnimation; +} + ++ (void)setStyle:(UIStatusBarStyle)style animated:(BOOL)animated +{ + RCTAssertMainQueue(); + sStyle = style; + [self updateStatusBarAnimated:animated]; +} + ++ (void)setHidden:(BOOL)hidden withAnimation:(UIStatusBarAnimation)animation +{ + RCTAssertMainQueue(); + sHidden = hidden; + sUpdateAnimation = animation; + [self updateStatusBarAnimated:animation != UIStatusBarAnimationNone]; +} + ++ (void)updateStatusBarAnimated:(BOOL)animated +{ + UIViewController *viewController = RCTPresentedViewController(); + if (animated) { + [UIView animateWithDuration:UINavigationControllerHideShowBarDuration + animations:^{ + [viewController setNeedsStatusBarAppearanceUpdate]; + }]; + } else { + [viewController setNeedsStatusBarAppearanceUpdate]; + } +} + +@end diff --git a/packages/react-native/React/CoreModules/RCTStatusBarManager.mm b/packages/react-native/React/CoreModules/RCTStatusBarManager.mm index dc0a6568251d..34a91fe05c5c 100644 --- a/packages/react-native/React/CoreModules/RCTStatusBarManager.mm +++ b/packages/react-native/React/CoreModules/RCTStatusBarManager.mm @@ -11,6 +11,7 @@ #import #import #import +#import #import #import @@ -68,6 +69,28 @@ static BOOL RCTViewControllerBasedStatusBarAppearance() return value; } +// UIKit makes the app-level setters no-ops only for apps built with the iOS 27 SDK. +static BOOL RCTAppLevelStatusBarIsNoOp() +{ +#if __IPHONE_OS_VERSION_MAX_ALLOWED >= 270000 + if (@available(iOS 27.0, *)) { + return YES; + } +#endif + return NO; +} + +static void RCTWarnAppLevelStatusBarUnavailable() +{ + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + RCTLogWarn( + @"StatusBar has no effect because UIViewControllerBasedStatusBarAppearance is NO in Info.plist. " + @"Apps built with the iOS 27 SDK cannot use the app-level status bar API that this setting uses. " + @"Set UIViewControllerBasedStatusBarAppearance to YES to control the status bar from JavaScript."); + }); +} + RCT_EXPORT_MODULE() + (BOOL)requiresMainQueueSetup @@ -147,15 +170,17 @@ - (void)setStyle:(NSString *)style animated:(BOOL)animated { dispatch_async(dispatch_get_main_queue(), ^{ UIStatusBarStyle statusBarStyle = [RCTConvert UIStatusBarStyle:style]; + [RCTStatusBarAppearance setStyle:statusBarStyle animated:animated]; if (RCTViewControllerBasedStatusBarAppearance()) { - RCTLogWarn(@"RCTStatusBarManager is a no-op when \ - UIViewControllerBasedStatusBarAppearance is YES; set the status bar from your view controller instead"); - } else { + return; + } #pragma clang diagnostic push #pragma clang diagnostic ignored "-Wdeprecated-declarations" - [RCTSharedApplication() setStatusBarStyle:statusBarStyle animated:animated]; - } + [RCTSharedApplication() setStatusBarStyle:statusBarStyle animated:animated]; #pragma clang diagnostic pop + if (RCTAppLevelStatusBarIsNoOp()) { + RCTWarnAppLevelStatusBarUnavailable(); + } }); } @@ -163,14 +188,16 @@ - (void)setHidden:(BOOL)hidden withAnimation:(NSString *)withAnimation { dispatch_async(dispatch_get_main_queue(), ^{ UIStatusBarAnimation animation = [RCTConvert UIStatusBarAnimation:withAnimation]; + [RCTStatusBarAppearance setHidden:hidden withAnimation:animation]; if (RCTViewControllerBasedStatusBarAppearance()) { - RCTLogWarn(@"RCTStatusBarManager is a no-op when \ - UIViewControllerBasedStatusBarAppearance is YES; set the status bar from your view controller instead"); - } else { + return; + } #pragma clang diagnostic push #pragma clang diagnostic ignored "-Wdeprecated-declarations" - [RCTSharedApplication() setStatusBarHidden:hidden withAnimation:animation]; + [RCTSharedApplication() setStatusBarHidden:hidden withAnimation:animation]; #pragma clang diagnostic pop + if (RCTAppLevelStatusBarIsNoOp()) { + RCTWarnAppLevelStatusBarUnavailable(); } }); } diff --git a/packages/react-native/React/Fabric/Mounting/ComponentViews/Modal/RCTFabricModalHostViewController.h b/packages/react-native/React/Fabric/Mounting/ComponentViews/Modal/RCTFabricModalHostViewController.h index 149a9788e2c1..f0328dea9cf5 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/Modal/RCTFabricModalHostViewController.h +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/Modal/RCTFabricModalHostViewController.h @@ -17,4 +17,6 @@ @property (nonatomic, assign) UIInterfaceOrientationMask supportedInterfaceOrientations; +- (void)captureStatusBarAppearance; + @end diff --git a/packages/react-native/React/Fabric/Mounting/ComponentViews/Modal/RCTFabricModalHostViewController.mm b/packages/react-native/React/Fabric/Mounting/ComponentViews/Modal/RCTFabricModalHostViewController.mm index 35e6eafecf26..5febef18d1c4 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/Modal/RCTFabricModalHostViewController.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/Modal/RCTFabricModalHostViewController.mm @@ -8,6 +8,7 @@ #import "RCTFabricModalHostViewController.h" #import +#import #import @implementation RCTFabricModalHostViewController { @@ -15,6 +16,11 @@ @implementation RCTFabricModalHostViewController { #if !TARGET_OS_TV RCTSurfaceTouchHandler *_touchHandler; #endif + BOOL _hasCapturedStatusBar; + UIStatusBarStyle _capturedStatusBarStyle; + BOOL _capturedStatusBarHidden; + UIStatusBarStyle _jsStatusBarStyleAtCapture; + BOOL _jsStatusBarHiddenAtCapture; } - (instancetype)init @@ -46,15 +52,37 @@ - (void)loadView #endif } +- (void)captureStatusBarAppearance +{ + UIStatusBarManager *statusBarManager = RCTUIStatusBarManager(); + _hasCapturedStatusBar = YES; + _capturedStatusBarStyle = statusBarManager.statusBarStyle; + _capturedStatusBarHidden = statusBarManager.isStatusBarHidden; + _jsStatusBarStyleAtCapture = RCTStatusBarAppearance.style; + _jsStatusBarHiddenAtCapture = RCTStatusBarAppearance.hidden; +} + #if !TARGET_OS_TV +// Keep the status bar from before the modal until JS changes it. - (UIStatusBarStyle)preferredStatusBarStyle { - return [RCTUIStatusBarManager() statusBarStyle]; + if (_hasCapturedStatusBar && RCTStatusBarAppearance.style == _jsStatusBarStyleAtCapture) { + return _capturedStatusBarStyle; + } + return RCTStatusBarAppearance.style; } - (BOOL)prefersStatusBarHidden { - return [RCTUIStatusBarManager() isStatusBarHidden]; + if (_hasCapturedStatusBar && RCTStatusBarAppearance.hidden == _jsStatusBarHiddenAtCapture) { + return _capturedStatusBarHidden; + } + return RCTStatusBarAppearance.hidden; +} + +- (UIStatusBarAnimation)preferredStatusBarUpdateAnimation +{ + return RCTStatusBarAppearance.updateAnimation; } #endif diff --git a/packages/react-native/React/Fabric/Mounting/ComponentViews/Modal/RCTModalHostViewComponentView.mm b/packages/react-native/React/Fabric/Mounting/ComponentViews/Modal/RCTModalHostViewComponentView.mm index 7c09d9cf73ef..8f9fd4c30740 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/Modal/RCTModalHostViewComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/Modal/RCTModalHostViewComponentView.mm @@ -174,6 +174,7 @@ - (void)ensurePresentedOnlyIfNeeded [self saveAccessibilityFocusedView]; self.viewController.presentationController.delegate = self; self.viewController.modalInPresentation = _modalInPresentation; + [self.viewController captureStatusBarAppearance]; _isPresented = YES; [self presentViewController:self.viewController diff --git a/packages/rn-tester/RNTester/Info.plist b/packages/rn-tester/RNTester/Info.plist index 359e0f1be536..ce09c212a744 100644 --- a/packages/rn-tester/RNTester/Info.plist +++ b/packages/rn-tester/RNTester/Info.plist @@ -81,6 +81,6 @@ UIInterfaceOrientationPortraitUpsideDown UIViewControllerBasedStatusBarAppearance - + diff --git a/packages/rn-tester/RNTesterPods.xcodeproj/project.pbxproj b/packages/rn-tester/RNTesterPods.xcodeproj/project.pbxproj index 76c55b4f55e5..c323ea2c25e0 100644 --- a/packages/rn-tester/RNTesterPods.xcodeproj/project.pbxproj +++ b/packages/rn-tester/RNTesterPods.xcodeproj/project.pbxproj @@ -17,6 +17,7 @@ 5C60EB1C226440DB0018C04F /* AppDelegate.mm in Sources */ = {isa = PBXBuildFile; fileRef = 5C60EB1B226440DB0018C04F /* AppDelegate.mm */; }; 602160B9478DAFF2BAB56595 /* RCTUtilsTests.m in Sources */ = {isa = PBXBuildFile; fileRef = D5D13FB1445C5C19639921CC /* RCTUtilsTests.m */; }; 79B29C2E2E607A99007612A5 /* SceneDelegate.mm in Sources */ = {isa = PBXBuildFile; fileRef = 79B29C2D2E607A99007612A5 /* SceneDelegate.mm */; }; + 7F806D4D4D97AD6403558B35 /* RCTStatusBarAppearanceTests.mm in Sources */ = {isa = PBXBuildFile; fileRef = 67B0814814F7C07E196BC295 /* RCTStatusBarAppearanceTests.mm */; }; 8145AE06241172D900A3F8DA /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 8145AE05241172D900A3F8DA /* LaunchScreen.storyboard */; }; 832F45BB2A8A6E1F0097B4E6 /* SwiftTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 832F45BA2A8A6E1F0097B4E6 /* SwiftTest.swift */; }; A975CA6C2C05EADF0043F72A /* RCTNetworkTaskTests.m in Sources */ = {isa = PBXBuildFile; fileRef = A975CA6B2C05EADE0043F72A /* RCTNetworkTaskTests.m */; }; @@ -94,6 +95,7 @@ 4C706D402EE4AF9BE838CBA9 /* libPods-RNTester.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-RNTester.a"; sourceTree = BUILT_PRODUCTS_DIR; }; 51BC9297B6C3163C14532020 /* Pods-RNTester.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RNTester.release.xcconfig"; path = "Target Support Files/Pods-RNTester/Pods-RNTester.release.xcconfig"; sourceTree = ""; }; 5C60EB1B226440DB0018C04F /* AppDelegate.mm */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.objcpp; name = AppDelegate.mm; path = RNTester/AppDelegate.mm; sourceTree = ""; }; + 67B0814814F7C07E196BC295 /* RCTStatusBarAppearanceTests.mm */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.objcpp; path = RCTStatusBarAppearanceTests.mm; sourceTree = ""; }; 79B29C2C2E607A99007612A5 /* SceneDelegate.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = SceneDelegate.h; path = RNTester/SceneDelegate.h; sourceTree = ""; }; 79B29C2D2E607A99007612A5 /* SceneDelegate.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; name = SceneDelegate.mm; path = RNTester/SceneDelegate.mm; sourceTree = ""; }; 8145AE05241172D900A3F8DA /* LaunchScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = LaunchScreen.storyboard; path = RNTester/LaunchScreen.storyboard; sourceTree = ""; }; @@ -350,6 +352,7 @@ E7DB20D022B2BAA5005AC45F /* RCTURLUtilsTests.m */, D5D13FB1445C5C19639921CC /* RCTUtilsTests.m */, E7DB20B322B2BAA4005AC45F /* RNTesterUnitTestsBundle.js */, + 67B0814814F7C07E196BC295 /* RCTStatusBarAppearanceTests.mm */, ); path = RNTesterUnitTests; sourceTree = ""; @@ -778,6 +781,7 @@ E7DB20E922B2BAA6005AC45F /* RCTComponentPropsTests.m in Sources */, E7DB20D822B2BAA6005AC45F /* RCTJSONTests.m in Sources */, 602160B9478DAFF2BAB56595 /* RCTUtilsTests.m in Sources */, + 7F806D4D4D97AD6403558B35 /* RCTStatusBarAppearanceTests.mm in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/packages/rn-tester/RNTesterUnitTests/RCTStatusBarAppearanceTests.mm b/packages/rn-tester/RNTesterUnitTests/RCTStatusBarAppearanceTests.mm new file mode 100644 index 000000000000..ed6684e3e8e5 --- /dev/null +++ b/packages/rn-tester/RNTesterUnitTests/RCTStatusBarAppearanceTests.mm @@ -0,0 +1,194 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#import + +#import +#import +#import +#import +#import +#import + +@interface RCTStatusBarManager (RCTStatusBarAppearanceTests) +- (void)setStyle:(NSString *)style animated:(BOOL)animated; +- (void)setHidden:(BOOL)hidden withAnimation:(NSString *)withAnimation; +@end + +@interface RCTStatusBarAppearanceTestViewController : UIViewController +@property (nonatomic, assign) NSUInteger updateCount; +@end + +@implementation RCTStatusBarAppearanceTestViewController + +- (void)setNeedsStatusBarAppearanceUpdate +{ + [super setNeedsStatusBarAppearanceUpdate]; + self.updateCount++; +} + +@end + +@interface RCTStatusBarAppearanceTests : XCTestCase +@end + +@implementation RCTStatusBarAppearanceTests { + UIStatusBarStyle _initialStyle; + BOOL _initialHidden; + UIStatusBarAnimation _initialAnimation; + RCTStatusBarAppearanceTestViewController *_viewController; +} + +- (void)setUp +{ + [super setUp]; + _initialStyle = RCTStatusBarAppearance.style; + _initialHidden = RCTStatusBarAppearance.hidden; + _initialAnimation = RCTStatusBarAppearance.updateAnimation; + _viewController = [RCTStatusBarAppearanceTestViewController new]; + [RCTUtilsUIOverride setPresentedViewController:_viewController]; +} + +- (void)tearDown +{ + [RCTUtilsUIOverride setPresentedViewController:nil]; + [RCTStatusBarAppearance setStyle:_initialStyle animated:NO]; + [RCTStatusBarAppearance setHidden:_initialHidden withAnimation:_initialAnimation]; + [super tearDown]; +} + +- (void)waitForMainQueue +{ + XCTestExpectation *expectation = [self expectationWithDescription:@"main queue"]; + dispatch_async(dispatch_get_main_queue(), ^{ + [expectation fulfill]; + }); + [self waitForExpectations:@[ expectation ] timeout:1]; +} + +- (void)testSetStyleStoresStyle +{ + [RCTStatusBarAppearance setStyle:UIStatusBarStyleLightContent animated:NO]; + XCTAssertEqual(RCTStatusBarAppearance.style, UIStatusBarStyleLightContent); + + [RCTStatusBarAppearance setStyle:UIStatusBarStyleDarkContent animated:YES]; + XCTAssertEqual(RCTStatusBarAppearance.style, UIStatusBarStyleDarkContent); +} + +- (void)testSetHiddenStoresHiddenAndAnimation +{ + [RCTStatusBarAppearance setHidden:YES withAnimation:UIStatusBarAnimationSlide]; + XCTAssertTrue(RCTStatusBarAppearance.hidden); + XCTAssertEqual(RCTStatusBarAppearance.updateAnimation, UIStatusBarAnimationSlide); + + [RCTStatusBarAppearance setHidden:NO withAnimation:UIStatusBarAnimationFade]; + XCTAssertFalse(RCTStatusBarAppearance.hidden); + XCTAssertEqual(RCTStatusBarAppearance.updateAnimation, UIStatusBarAnimationFade); +} + +- (void)testSetStyleDoesNotChangeUpdateAnimation +{ + [RCTStatusBarAppearance setHidden:NO withAnimation:UIStatusBarAnimationSlide]; + [RCTStatusBarAppearance setStyle:UIStatusBarStyleLightContent animated:YES]; + XCTAssertEqual(RCTStatusBarAppearance.updateAnimation, UIStatusBarAnimationSlide); +} + +- (void)testUpdateTargetsPresentedViewController +{ + [RCTStatusBarAppearance setStyle:UIStatusBarStyleLightContent animated:NO]; + XCTAssertEqual(_viewController.updateCount, 1u); + + [RCTStatusBarAppearance setHidden:YES withAnimation:UIStatusBarAnimationNone]; + XCTAssertEqual(_viewController.updateCount, 2u); +} + +- (void)testAnimatedUpdateTargetsPresentedViewController +{ + [RCTStatusBarAppearance setStyle:UIStatusBarStyleLightContent animated:YES]; + XCTAssertEqual(_viewController.updateCount, 1u); + + [RCTStatusBarAppearance setHidden:YES withAnimation:UIStatusBarAnimationFade]; + XCTAssertEqual(_viewController.updateCount, 2u); +} + +- (void)testLastHiddenCallWins +{ + [RCTStatusBarAppearance setHidden:YES withAnimation:UIStatusBarAnimationSlide]; + [RCTStatusBarAppearance setHidden:NO withAnimation:UIStatusBarAnimationSlide]; + XCTAssertFalse(RCTStatusBarAppearance.hidden); + XCTAssertEqual(_viewController.updateCount, 2u); +} + +- (void)testStatusBarManagerSetStyleUpdatesAppearance +{ + [[RCTStatusBarManager new] setStyle:@"light-content" animated:NO]; + [self waitForMainQueue]; + XCTAssertEqual(RCTStatusBarAppearance.style, UIStatusBarStyleLightContent); + XCTAssertEqual(_viewController.updateCount, 1u); +} + +- (void)testStatusBarManagerSetHiddenUpdatesAppearance +{ + [[RCTStatusBarManager new] setHidden:YES withAnimation:@"fade"]; + [self waitForMainQueue]; + XCTAssertTrue(RCTStatusBarAppearance.hidden); + XCTAssertEqual(RCTStatusBarAppearance.updateAnimation, UIStatusBarAnimationFade); + XCTAssertEqual(_viewController.updateCount, 1u); +} + +- (void)testUnknownStyleFallsBackToDefault +{ + [[RCTStatusBarManager new] setStyle:@"light-content" animated:NO]; + [self waitForMainQueue]; + XCTAssertEqual(RCTStatusBarAppearance.style, UIStatusBarStyleLightContent); + + [[RCTStatusBarManager new] setStyle:@"not-a-style" animated:NO]; + [self waitForMainQueue]; + XCTAssertEqual(RCTStatusBarAppearance.style, UIStatusBarStyleDefault); +} + +- (void)testDefaultRootViewControllerReportsAppearance +{ + UIViewController *rootViewController = [[RCTDefaultReactNativeFactoryDelegate new] createRootViewController]; + + [RCTStatusBarAppearance setStyle:UIStatusBarStyleLightContent animated:NO]; + [RCTStatusBarAppearance setHidden:YES withAnimation:UIStatusBarAnimationSlide]; + + XCTAssertEqual(rootViewController.preferredStatusBarStyle, UIStatusBarStyleLightContent); + XCTAssertTrue(rootViewController.prefersStatusBarHidden); + XCTAssertEqual(rootViewController.preferredStatusBarUpdateAnimation, UIStatusBarAnimationSlide); +} + +- (void)testModalKeepsStatusBarCapturedBeforePresentation +{ + UIStatusBarManager *sceneStatusBar = RCTUIStatusBarManager(); + [RCTStatusBarAppearance setStyle:UIStatusBarStyleLightContent animated:NO]; + [RCTStatusBarAppearance setHidden:YES withAnimation:UIStatusBarAnimationNone]; + XCTAssertNotEqual(sceneStatusBar.statusBarStyle, UIStatusBarStyleLightContent); + XCTAssertFalse(sceneStatusBar.isStatusBarHidden); + + RCTFabricModalHostViewController *modalViewController = [RCTFabricModalHostViewController new]; + [modalViewController captureStatusBarAppearance]; + + XCTAssertEqual(modalViewController.preferredStatusBarStyle, sceneStatusBar.statusBarStyle); + XCTAssertFalse(modalViewController.prefersStatusBarHidden); +} + +- (void)testModalViewControllerReportsAppearance +{ + RCTFabricModalHostViewController *modalViewController = [RCTFabricModalHostViewController new]; + [modalViewController captureStatusBarAppearance]; + + [RCTStatusBarAppearance setStyle:UIStatusBarStyleLightContent animated:NO]; + [RCTStatusBarAppearance setHidden:YES withAnimation:UIStatusBarAnimationSlide]; + + XCTAssertEqual(modalViewController.preferredStatusBarStyle, UIStatusBarStyleLightContent); + XCTAssertTrue(modalViewController.prefersStatusBarHidden); + XCTAssertEqual(modalViewController.preferredStatusBarUpdateAnimation, UIStatusBarAnimationSlide); +} + +@end diff --git a/private/helloworld/ios/HelloWorld/Info.plist b/private/helloworld/ios/HelloWorld/Info.plist index 2dda775f5afd..032a0c493ca0 100644 --- a/private/helloworld/ios/HelloWorld/Info.plist +++ b/private/helloworld/ios/HelloWorld/Info.plist @@ -64,6 +64,6 @@ UIInterfaceOrientationLandscapeRight UIViewControllerBasedStatusBarAppearance - + From 593a8e63f5648676aa81578a6f9c5bfe9e46d5f2 Mon Sep 17 00:00:00 2001 From: Alan Hughes Date: Thu, 8 Oct 2026 10:15:39 +0100 Subject: [PATCH 2/2] Keep captureStatusBarAppearance private and update API snapshots --- .../Modal/RCTFabricModalHostViewController.h | 2 -- .../ComponentViews/Modal/RCTModalHostViewComponentView.mm | 4 ++++ .../RNTesterUnitTests/RCTStatusBarAppearanceTests.mm | 4 ++++ scripts/cxx-api/api-snapshots/ReactAppleDebugCxx.api | 8 ++++++++ scripts/cxx-api/api-snapshots/ReactAppleNewarchCxx.api | 8 ++++++++ scripts/cxx-api/api-snapshots/ReactAppleReleaseCxx.api | 8 ++++++++ 6 files changed, 32 insertions(+), 2 deletions(-) diff --git a/packages/react-native/React/Fabric/Mounting/ComponentViews/Modal/RCTFabricModalHostViewController.h b/packages/react-native/React/Fabric/Mounting/ComponentViews/Modal/RCTFabricModalHostViewController.h index f0328dea9cf5..149a9788e2c1 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/Modal/RCTFabricModalHostViewController.h +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/Modal/RCTFabricModalHostViewController.h @@ -17,6 +17,4 @@ @property (nonatomic, assign) UIInterfaceOrientationMask supportedInterfaceOrientations; -- (void)captureStatusBarAppearance; - @end diff --git a/packages/react-native/React/Fabric/Mounting/ComponentViews/Modal/RCTModalHostViewComponentView.mm b/packages/react-native/React/Fabric/Mounting/ComponentViews/Modal/RCTModalHostViewComponentView.mm index 8f9fd4c30740..378a8ae9d676 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/Modal/RCTModalHostViewComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/Modal/RCTModalHostViewComponentView.mm @@ -18,6 +18,10 @@ #import "RCTFabricModalHostViewController.h" +@interface RCTFabricModalHostViewController () +- (void)captureStatusBarAppearance; +@end + using namespace facebook::react; #if !TARGET_OS_TV diff --git a/packages/rn-tester/RNTesterUnitTests/RCTStatusBarAppearanceTests.mm b/packages/rn-tester/RNTesterUnitTests/RCTStatusBarAppearanceTests.mm index ed6684e3e8e5..17de4a4e9cf2 100644 --- a/packages/rn-tester/RNTesterUnitTests/RCTStatusBarAppearanceTests.mm +++ b/packages/rn-tester/RNTesterUnitTests/RCTStatusBarAppearanceTests.mm @@ -14,6 +14,10 @@ #import #import +@interface RCTFabricModalHostViewController () +- (void)captureStatusBarAppearance; +@end + @interface RCTStatusBarManager (RCTStatusBarAppearanceTests) - (void)setStyle:(NSString *)style animated:(BOOL)animated; - (void)setHidden:(BOOL)hidden withAnimation:(NSString *)withAnimation; diff --git a/scripts/cxx-api/api-snapshots/ReactAppleDebugCxx.api b/scripts/cxx-api/api-snapshots/ReactAppleDebugCxx.api index 193fb5bb0240..d07ed331ff83 100644 --- a/scripts/cxx-api/api-snapshots/ReactAppleDebugCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAppleDebugCxx.api @@ -1824,6 +1824,14 @@ interface RCTSourceCode : public NSObject { interface RCTSpringAnimation : public NSObject { } +interface RCTStatusBarAppearance : public NSObject { + public @property (assign, readonly) BOOL hidden; + public @property (assign, readonly) UIStatusBarAnimation updateAnimation; + public @property (assign, readonly) UIStatusBarStyle style; + public virtual static void setHidden:withAnimation:(BOOL hidden, UIStatusBarAnimation animation); + public virtual static void setStyle:animated:(UIStatusBarStyle style, BOOL animated); +} + interface RCTStatusBarManager : public RCTEventEmitter { } diff --git a/scripts/cxx-api/api-snapshots/ReactAppleNewarchCxx.api b/scripts/cxx-api/api-snapshots/ReactAppleNewarchCxx.api index 81674ace5e1c..fab5611c3b8d 100644 --- a/scripts/cxx-api/api-snapshots/ReactAppleNewarchCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAppleNewarchCxx.api @@ -1823,6 +1823,14 @@ interface RCTSourceCode : public NSObject { interface RCTSpringAnimation : public NSObject { } +interface RCTStatusBarAppearance : public NSObject { + public @property (assign, readonly) BOOL hidden; + public @property (assign, readonly) UIStatusBarAnimation updateAnimation; + public @property (assign, readonly) UIStatusBarStyle style; + public virtual static void setHidden:withAnimation:(BOOL hidden, UIStatusBarAnimation animation); + public virtual static void setStyle:animated:(UIStatusBarStyle style, BOOL animated); +} + interface RCTStatusBarManager : public RCTEventEmitter { } diff --git a/scripts/cxx-api/api-snapshots/ReactAppleReleaseCxx.api b/scripts/cxx-api/api-snapshots/ReactAppleReleaseCxx.api index db9d046d055f..efb5cd367ef5 100644 --- a/scripts/cxx-api/api-snapshots/ReactAppleReleaseCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAppleReleaseCxx.api @@ -1824,6 +1824,14 @@ interface RCTSourceCode : public NSObject { interface RCTSpringAnimation : public NSObject { } +interface RCTStatusBarAppearance : public NSObject { + public @property (assign, readonly) BOOL hidden; + public @property (assign, readonly) UIStatusBarAnimation updateAnimation; + public @property (assign, readonly) UIStatusBarStyle style; + public virtual static void setHidden:withAnimation:(BOOL hidden, UIStatusBarAnimation animation); + public virtual static void setStyle:animated:(UIStatusBarStyle style, BOOL animated); +} + interface RCTStatusBarManager : public RCTEventEmitter { }