From 2e2ad343224182bab5110021f7928b2ff958f837 Mon Sep 17 00:00:00 2001 From: Alan Hughes Date: Wed, 28 Oct 2026 06:35:54 -0700 Subject: [PATCH] Reland iOS 27 StatusBar fix with tvOS guards (#59542) Summary: Fixes https://github.com/react/react-native/issues/58758. Apps built with the iOS 27 SDK can no longer change the status bar through the `UIApplication` setters. Restore the view-controller-based implementation: `RCTStatusBarAppearance` stores the requested style, hidden state and animation, and the default root and Fabric modal controllers return those values. The modal preserves the appearance captured before presentation until JavaScript changes it. Apps must set `UIViewControllerBasedStatusBarAppearance` to `YES` on iOS 27. Custom root view controllers must return the `RCTStatusBarAppearance` values. Apps using `NO` retain the legacy setters on older SDKs and receive a one-time warning where those setters are ineffective. Exclude the appearance helper and status-bar controller code on tvOS, where UIKit's status-bar types and APIs are unavailable. Guard the modal capture declaration and call as well as its state and implementation. The iOS code paths are unchanged. Test Plan: - Built the full `AirwaveBurbn` app and package for appletvos-arm64 with the Airwave mode, local build flavour, Pika 27 and Xcode 27.1. Code signing was skipped for local validation. Both targets passed. Buck run: https://www.internalfb.com/buck2/9c21f926-63ea-4fd5-8370-286f70fc829b. - Built `ReactApple`, `RCTFabricApple` and `RCTAppDelegateApple` with `AirwavePlatform-appletvos-arm64-local`. All passed. Buck run: https://www.internalfb.com/buck2/0aaabc5f-e893-4d9d-8ce1-a346c3315bbf. - Built `ReactApple`, `CoreModulesApple`, `RCTFabricApple` and `RCTAppDelegateApple` for iphonesimulator-arm64. All passed. Buck run: https://www.internalfb.com/buck2/876de6d7-fbd8-439f-bdbe-d4fed3d29a91. - Regenerated all C++ API snapshots. Only the four ReactApple snapshots changed, each adding `RCTStatusBarAppearance`; no private API appeared. Ran the snapshot target with `--validate`; all 12 snapshots passed. Validation run: https://www.internalfb.com/buck2/a8fc85da-ba96-4b3b-a741-afede29b5f0d. - Meta-managed clang-format, `arc f` and `arc lint` passed. - Independent read-only review found no blockers in the tvOS guards and confirmed that the iOS implementation matches the original landed change apart from platform guards. - Restored the original 12 `RCTStatusBarAppearanceTests` XCTest cases. These remain registered in the OSS Xcode test target, have no internal Buck owner, and were not run locally. No simulator runtime verification was performed. - The manual Sandcastle smoke launcher defaulted to iphoneos rather than appletvos. Its build and baseline retry failed target-compatibility checks before compiling. Do not count the launcher success as a passing tvOS build; the full tvOS validation is the local Buck run above. Child run: https://www.internalfb.com/intern/sandcastle/instance/40532397437851209/. - Diff-time CI currently has a non-bypassable `github-export-checks` failure because this new diff has no linked/exported GitHub PR. Export at https://www.internalfb.com/intern/opensource/export/preview/D127817413/ and rerun the check before landing. Other CI jobs are still running. - General Reviewer flagged a modal-state edge case also present in the original patch: changing a JS status-bar value and then returning to its capture-time value can restore the pre-modal native appearance. That warning remains unresolved for review; this re-land deliberately preserves the original iOS behaviour. Reviewed By: andrewdacenko Differential Revision: D127817413 Pulled By: cipolleschi --- .../RCTDefaultReactNativeFactoryDelegate.mm | 27 ++- .../AppDelegate/RCTUIConfiguratorProtocol.h | 2 +- .../React/Base/RCTStatusBarAppearance.h | 28 +++ .../React/Base/RCTStatusBarAppearance.mm | 64 ++++++ .../React/CoreModules/RCTStatusBarManager.mm | 43 +++- .../Modal/RCTFabricModalHostViewController.mm | 32 ++- .../Modal/RCTModalHostViewComponentView.mm | 9 + packages/rn-tester/RNTester/Info.plist | 2 +- .../RNTesterPods.xcodeproj/project.pbxproj | 4 + .../RCTStatusBarAppearanceTests.mm | 198 ++++++++++++++++++ private/helloworld/ios/HelloWorld/Info.plist | 2 +- .../api-snapshots/ReactAppleDebugCxx.api | 8 + .../api-snapshots/ReactAppleFrameworksCxx.api | 8 + .../api-snapshots/ReactAppleNewarchCxx.api | 8 + .../api-snapshots/ReactAppleReleaseCxx.api | 8 + 15 files changed, 428 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 e72ceced8275..b0aa0ce3d1de 100644 --- a/packages/react-native/Libraries/AppDelegate/RCTDefaultReactNativeFactoryDelegate.mm +++ b/packages/react-native/Libraries/AppDelegate/RCTDefaultReactNativeFactoryDelegate.mm @@ -14,9 +14,34 @@ #endif #import +#import #import #import +@interface RCTRootViewController : UIViewController +@end + +@implementation RCTRootViewController + +#if !TARGET_OS_TV +- (UIStatusBarStyle)preferredStatusBarStyle +{ + return RCTStatusBarAppearance.style; +} + +- (BOOL)prefersStatusBarHidden +{ + return RCTStatusBarAppearance.hidden; +} + +- (UIStatusBarAnimation)preferredStatusBarUpdateAnimation +{ + return RCTStatusBarAppearance.updateAnimation; +} +#endif + +@end + @implementation RCTDefaultReactNativeFactoryDelegate { // C++ Native Animated provider, created once on first use (getTurboModule: may be called // concurrently for different module names). @@ -28,7 +53,7 @@ @implementation RCTDefaultReactNativeFactoryDelegate { - (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..e6303758aad7 --- /dev/null +++ b/packages/react-native/React/Base/RCTStatusBarAppearance.h @@ -0,0 +1,28 @@ +/* + * 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 + +#if !TARGET_OS_TV +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 +#endif diff --git a/packages/react-native/React/Base/RCTStatusBarAppearance.mm b/packages/react-native/React/Base/RCTStatusBarAppearance.mm new file mode 100644 index 000000000000..ec0df37eed6d --- /dev/null +++ b/packages/react-native/React/Base/RCTStatusBarAppearance.mm @@ -0,0 +1,64 @@ +/* + * 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" + +#if !TARGET_OS_TV +#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 +#endif diff --git a/packages/react-native/React/CoreModules/RCTStatusBarManager.mm b/packages/react-native/React/CoreModules/RCTStatusBarManager.mm index dc0a6568251d..350edb366f78 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,16 @@ - (void)setStyle:(NSString *)style animated:(BOOL)animated { dispatch_async(dispatch_get_main_queue(), ^{ UIStatusBarStyle statusBarStyle = [RCTConvert UIStatusBarStyle:style]; - if (RCTViewControllerBasedStatusBarAppearance()) { - RCTLogWarn(@"RCTStatusBarManager is a no-op when \ - UIViewControllerBasedStatusBarAppearance is YES; set the status bar from your view controller instead"); - } else { + [RCTStatusBarAppearance setStyle:statusBarStyle animated:animated]; + if (!RCTViewControllerBasedStatusBarAppearance()) { #pragma clang diagnostic push #pragma clang diagnostic ignored "-Wdeprecated-declarations" [RCTSharedApplication() setStatusBarStyle:statusBarStyle animated:animated]; - } #pragma clang diagnostic pop + if (RCTAppLevelStatusBarIsNoOp()) { + RCTWarnAppLevelStatusBarUnavailable(); + } + } }); } @@ -163,14 +187,15 @@ - (void)setHidden:(BOOL)hidden withAnimation:(NSString *)withAnimation { dispatch_async(dispatch_get_main_queue(), ^{ UIStatusBarAnimation animation = [RCTConvert UIStatusBarAnimation:withAnimation]; - if (RCTViewControllerBasedStatusBarAppearance()) { - RCTLogWarn(@"RCTStatusBarManager is a no-op when \ - UIViewControllerBasedStatusBarAppearance is YES; set the status bar from your view controller instead"); - } else { + [RCTStatusBarAppearance setHidden:hidden withAnimation:animation]; + if (!RCTViewControllerBasedStatusBarAppearance()) { #pragma clang diagnostic push #pragma clang diagnostic ignored "-Wdeprecated-declarations" [RCTSharedApplication() setStatusBarHidden:hidden withAnimation:animation]; #pragma clang diagnostic pop + if (RCTAppLevelStatusBarIsNoOp()) { + RCTWarnAppLevelStatusBarUnavailable(); + } } }); } 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..8d88275e3406 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/Modal/RCTFabricModalHostViewController.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/Modal/RCTFabricModalHostViewController.mm @@ -8,12 +8,18 @@ #import "RCTFabricModalHostViewController.h" #import +#import #import @implementation RCTFabricModalHostViewController { CGRect _lastViewBounds; #if !TARGET_OS_TV RCTSurfaceTouchHandler *_touchHandler; + BOOL _hasCapturedStatusBar; + UIStatusBarStyle _capturedStatusBarStyle; + BOOL _capturedStatusBarHidden; + UIStatusBarStyle _jsStatusBarStyleAtCapture; + BOOL _jsStatusBarHiddenAtCapture; #endif } @@ -47,14 +53,36 @@ - (void)loadView } #if !TARGET_OS_TV +- (void)captureStatusBarAppearance +{ + UIStatusBarManager *statusBarManager = RCTUIStatusBarManager(); + _hasCapturedStatusBar = YES; + _capturedStatusBarStyle = statusBarManager.statusBarStyle; + _capturedStatusBarHidden = statusBarManager.isStatusBarHidden; + _jsStatusBarStyleAtCapture = RCTStatusBarAppearance.style; + _jsStatusBarHiddenAtCapture = RCTStatusBarAppearance.hidden; +} + +// 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 73de2f509e46..1a65511230ad 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/Modal/RCTModalHostViewComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/Modal/RCTModalHostViewComponentView.mm @@ -17,6 +17,12 @@ #import "RCTFabricModalHostViewController.h" +#if !TARGET_OS_TV +@interface RCTFabricModalHostViewController () +- (void)captureStatusBarAppearance; +@end +#endif + using namespace facebook::react; #if !TARGET_OS_TV @@ -173,6 +179,9 @@ - (void)ensurePresentedOnlyIfNeeded [self saveAccessibilityFocusedView]; self.viewController.presentationController.delegate = self; self.viewController.modalInPresentation = _modalInPresentation; +#if !TARGET_OS_TV + [self.viewController captureStatusBarAppearance]; +#endif _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 1d336b424136..2573a501ff30 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..17de4a4e9cf2 --- /dev/null +++ b/packages/rn-tester/RNTesterUnitTests/RCTStatusBarAppearanceTests.mm @@ -0,0 +1,198 @@ +/* + * 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 RCTFabricModalHostViewController () +- (void)captureStatusBarAppearance; +@end + +@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 - + diff --git a/scripts/cxx-api/api-snapshots/ReactAppleDebugCxx.api b/scripts/cxx-api/api-snapshots/ReactAppleDebugCxx.api index e04f3b738889..4b092c633d5b 100644 --- a/scripts/cxx-api/api-snapshots/ReactAppleDebugCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAppleDebugCxx.api @@ -1749,6 +1749,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/ReactAppleFrameworksCxx.api b/scripts/cxx-api/api-snapshots/ReactAppleFrameworksCxx.api index b6fcaa20bde3..cf404bb18e53 100644 --- a/scripts/cxx-api/api-snapshots/ReactAppleFrameworksCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAppleFrameworksCxx.api @@ -1792,6 +1792,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 9010fcf23951..a05d6368c1a5 100644 --- a/scripts/cxx-api/api-snapshots/ReactAppleNewarchCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAppleNewarchCxx.api @@ -1748,6 +1748,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 6bb9397bd2e4..2084c008caad 100644 --- a/scripts/cxx-api/api-snapshots/ReactAppleReleaseCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAppleReleaseCxx.api @@ -1749,6 +1749,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 { }