diff --git a/packages/react-native/Libraries/Components/StatusBar/StatusBar.js b/packages/react-native/Libraries/Components/StatusBar/StatusBar.js index bab23467263c..1f397b7e9751 100644 --- a/packages/react-native/Libraries/Components/StatusBar/StatusBar.js +++ b/packages/react-native/Libraries/Components/StatusBar/StatusBar.js @@ -40,6 +40,12 @@ export type StatusBarStyle = keyof { ... }; +/** + * The edge where the system places the vertical bar that holds the status bar, + * such as the side rail on iPhone Duo. + */ +export type StatusBarVerticalBarEdgeIOS = 'leading' | 'trailing'; + /** * Status bar animation */ @@ -72,7 +78,8 @@ export type StatusBarPropsIOS = Readonly<{ type StatusBarBaseProps = Readonly<{ /** - * If the status bar is hidden. + * If the status bar is hidden. Has no effect on iPhone Duo, where the + * status bar is in a vertical bar. * * @default `false` */ @@ -273,7 +280,8 @@ class StatusBar extends React.Component { // See the corresponding prop for more detail. /** - * Show or hide the status bar + * Show or hide the status bar. Has no effect on iPhone Duo, where the status + * bar is in a vertical bar. * @param hidden Hide the status bar. * @param animation Optional animation when * changing the status bar hidden property. @@ -304,6 +312,22 @@ class StatusBar extends React.Component { } } + /** + * Get the edge where the system places the vertical bar that holds the + * status bar beside the content, such as the side rail on iPhone Duo. + * Returns `null` when the system places no vertical bar, such as on other + * devices, in orientations that keep the status bar at the top, or before + * iOS 27.1. + * + * @platform ios + */ + static getVerticalBarEdgeIOS(): ?StatusBarVerticalBarEdgeIOS { + if (Platform.OS !== 'ios') { + return null; + } + return toVerticalBarEdge(NativeStatusBarManagerIOS.getVerticalBarEdge()); + } + /** * Push a StatusBar entry onto the stack. * The return value should be passed to `popStackEntry` when complete. @@ -448,4 +472,8 @@ class StatusBar extends React.Component { export type StatusBarInstance = StatusBar; +function toVerticalBarEdge(edge: ?string): ?StatusBarVerticalBarEdgeIOS { + return edge === 'leading' || edge === 'trailing' ? edge : null; +} + export default StatusBar; diff --git a/packages/react-native/Libraries/Components/StatusBar/__tests__/useVerticalBarEdgeIOS-test.js b/packages/react-native/Libraries/Components/StatusBar/__tests__/useVerticalBarEdgeIOS-test.js new file mode 100644 index 000000000000..9828be41a2f3 --- /dev/null +++ b/packages/react-native/Libraries/Components/StatusBar/__tests__/useVerticalBarEdgeIOS-test.js @@ -0,0 +1,36 @@ +/** + * 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. + * + * @flow strict-local + * @format + */ + +'use strict'; + +jest.mock('../NativeStatusBarManagerIOS', () => ({ + __esModule: true, + default: { + addListener: jest.fn(), + removeListeners: jest.fn(), + getVerticalBarEdge: jest.fn(() => 'trailing'), + }, +})); + +const useVerticalBarEdgeIOS = require('../useVerticalBarEdgeIOS').default; +const {create} = require('@react-native/jest-preset/jest/renderer'); +const React = require('react'); + +test('returns the vertical bar edge from the native module', async () => { + let edge; + function Probe() { + edge = useVerticalBarEdgeIOS(); + return null; + } + + await create(); + + expect(edge).toBe('trailing'); +}); diff --git a/packages/react-native/Libraries/Components/StatusBar/useVerticalBarEdgeIOS.js b/packages/react-native/Libraries/Components/StatusBar/useVerticalBarEdgeIOS.js new file mode 100644 index 000000000000..2a65948d8520 --- /dev/null +++ b/packages/react-native/Libraries/Components/StatusBar/useVerticalBarEdgeIOS.js @@ -0,0 +1,52 @@ +/** + * 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. + * + * @flow strict-local + * @format + */ + +import type {StatusBarVerticalBarEdgeIOS} from './StatusBar'; + +import NativeEventEmitter from '../../EventEmitter/NativeEventEmitter'; +import Platform from '../../Utilities/Platform'; +import NativeStatusBarManagerIOS from './NativeStatusBarManagerIOS'; +import StatusBar from './StatusBar'; +import {useSyncExternalStore} from 'react'; + +// Read once and refreshed by events, so renders don't each call into native. +let cachedEdge: ?StatusBarVerticalBarEdgeIOS; + +function subscribe(onStoreChange: () => void): () => void { + if (Platform.OS !== 'ios') { + return () => {}; + } + cachedEdge = StatusBar.getVerticalBarEdgeIOS(); + const subscription = new NativeEventEmitter<{ + verticalBarEdgeDidChange: [{edge: ?string}], + }>(NativeStatusBarManagerIOS).addListener('verticalBarEdgeDidChange', () => { + cachedEdge = StatusBar.getVerticalBarEdgeIOS(); + onStoreChange(); + }); + return () => subscription.remove(); +} + +function getSnapshot(): ?StatusBarVerticalBarEdgeIOS { + if (cachedEdge === undefined) { + cachedEdge = StatusBar.getVerticalBarEdgeIOS(); + } + return cachedEdge; +} + +/** + * React hook that returns the edge where the system places the vertical bar + * that holds the status bar, such as the side rail on iPhone Duo, and updates + * when it changes. Returns `null` when the system places no vertical bar. + * + * @platform ios + */ +export default function useVerticalBarEdgeIOS(): ?StatusBarVerticalBarEdgeIOS { + return useSyncExternalStore(subscribe, getSnapshot); +} diff --git a/packages/react-native/React/CoreModules/RCTStatusBarManager.mm b/packages/react-native/React/CoreModules/RCTStatusBarManager.mm index dc0a6568251d..2b3f0bb84e3b 100644 --- a/packages/react-native/React/CoreModules/RCTStatusBarManager.mm +++ b/packages/react-native/React/CoreModules/RCTStatusBarManager.mm @@ -17,6 +17,24 @@ static NSString *const kStatusBarFrameDidChange = @"statusBarFrameDidChange"; static NSString *const kStatusBarFrameWillChange = @"statusBarFrameWillChange"; +static NSString *const kVerticalBarEdgeDidChange = @"verticalBarEdgeDidChange"; + +static NSString *_Nullable RCTVerticalBarEdge(UITraitCollection *traitCollection) +{ +#if defined(__IPHONE_OS_VERSION_MAX_ALLOWED) && __IPHONE_OS_VERSION_MAX_ALLOWED >= 270100 /* __IPHONE_27_1 */ + if (@available(iOS 27.1, tvOS 27.1, *)) { + switch (traitCollection.verticalBarEdge) { + case UIVerticalBarEdgeLeading: + return @"leading"; + case UIVerticalBarEdgeTrailing: + return @"trailing"; + default: + return nil; + } + } +#endif + return nil; +} @implementation RCTConvert (UIStatusBar) @@ -53,6 +71,8 @@ @interface RCTStatusBarManager () _constants; + __weak UIWindow *_verticalBarEdgeWindow; + id _verticalBarEdgeRegistration; } static BOOL RCTViewControllerBasedStatusBarAppearance() @@ -85,7 +105,7 @@ - (void)initialize - (NSArray *)supportedEvents { - return @[ kStatusBarFrameDidChange, kStatusBarFrameWillChange ]; + return @[ kStatusBarFrameDidChange, kStatusBarFrameWillChange, kVerticalBarEdgeDidChange ]; } - (void)startObserving @@ -102,11 +122,48 @@ - (void)startObserving name:UIApplicationWillChangeStatusBarFrameNotification object:nil]; #pragma clang diagnostic pop + + dispatch_async(dispatch_get_main_queue(), ^{ + [self observeVerticalBarEdge]; + }); } - (void)stopObserving { [[NSNotificationCenter defaultCenter] removeObserver:self]; + + dispatch_async(dispatch_get_main_queue(), ^{ +#if defined(__IPHONE_OS_VERSION_MAX_ALLOWED) && __IPHONE_OS_VERSION_MAX_ALLOWED >= 270100 /* __IPHONE_27_1 */ + if (self->_verticalBarEdgeRegistration != nil) { + [self->_verticalBarEdgeWindow unregisterForTraitChanges:self->_verticalBarEdgeRegistration]; + } +#endif + self->_verticalBarEdgeRegistration = nil; + self->_verticalBarEdgeWindow = nil; + }); +} + +- (void)observeVerticalBarEdge +{ +#if defined(__IPHONE_OS_VERSION_MAX_ALLOWED) && __IPHONE_OS_VERSION_MAX_ALLOWED >= 270100 /* __IPHONE_27_1 */ + if (@available(iOS 27.1, tvOS 27.1, *)) { + UIWindow *window = RCTKeyWindow(); + if (_verticalBarEdgeRegistration != nil || window == nil) { + return; + } + __weak __typeof(self) weakSelf = self; + _verticalBarEdgeWindow = window; + _verticalBarEdgeRegistration = [window + registerForTraitChanges:UITraitCollection.systemTraitsAffectingVerticalBarEdge + withHandler:^(id traitEnvironment, UITraitCollection *previousTraitCollection) { + NSString *edge = RCTVerticalBarEdge(traitEnvironment.traitCollection); + NSString *previousEdge = RCTVerticalBarEdge(previousTraitCollection); + if (edge != previousEdge && ![edge isEqualToString:previousEdge]) { + [weakSelf sendEventWithName:kVerticalBarEdgeDidChange body:@{@"edge" : edge ?: [NSNull null]}]; + } + }]; + } +#endif } - (void)emitEvent:(NSString *)eventName forNotification:(NSNotification *)notification @@ -143,6 +200,15 @@ - (void)getHeight:(RCTResponseSenderBlock)callback } ]); } +- (NSString *)getVerticalBarEdge +{ + __block NSString *edge; + RCTUnsafeExecuteOnMainQueueSync(^{ + edge = RCTVerticalBarEdge(RCTKeyWindow().traitCollection); + }); + return edge; +} + - (void)setStyle:(NSString *)style animated:(BOOL)animated { dispatch_async(dispatch_get_main_queue(), ^{ diff --git a/packages/react-native/ReactNativeApi.d.ts b/packages/react-native/ReactNativeApi.d.ts index 89b0fe69edd8..bafa042560ee 100644 --- a/packages/react-native/ReactNativeApi.d.ts +++ b/packages/react-native/ReactNativeApi.d.ts @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<<29785e09db994cf0d43e7426f47c1b30>> + * @generated SignedSource<> * * This file was generated by scripts/js-api/build-types/index.js. */ @@ -4753,6 +4753,7 @@ declare class StatusBar extends React.Component { componentDidMount(): void componentDidUpdate(): void componentWillUnmount(): void + static getVerticalBarEdgeIOS(): null | StatusBarVerticalBarEdgeIOS | undefined static popStackEntry(entry: StackProps): void static pushStackEntry(props: StatusBarProps): StackProps render(): React.ReactNode @@ -4781,6 +4782,7 @@ declare type StatusBarStyle = keyof { default: string "light-content": string } +declare type StatusBarVerticalBarEdgeIOS = "leading" | "trailing" declare type StickyHeaderComponentType = ( props: ScrollViewStickyHeaderProps & { ref?: React.Ref<{ @@ -5454,6 +5456,8 @@ declare function useColorScheme(): ColorSchemeName | null declare function usePressability( config: null | PressabilityConfig | undefined, ): null | PressabilityEventHandlers +declare function useVerticalBarEdgeIOS(): + null | StatusBarVerticalBarEdgeIOS | undefined declare function useWindowDimensions(): DisplayMetrics | DisplayMetricsAndroid declare type UTFSequence = typeof UTFSequence declare type Value = null | { @@ -6007,11 +6011,12 @@ export { ShareActionSheetIOSOptions, // eff574f5 ShareContent, // 7c627896 ShareOptions, // 800c3a4e - StatusBar, // 5748d574 + StatusBar, // 8bda633d StatusBarAnimation, // 7fd047e6 - StatusBarInstance, // de13e4a1 + StatusBarInstance, // 6696fd7f StatusBarProps, // c2a44d88 StatusBarStyle, // 78f53eea + StatusBarVerticalBarEdgeIOS, // f0ffbe54 StyleProp, // fa0e9b4a StyleSheet, // 8bb13f55 SubmitBehavior, // c4ddf490 @@ -6088,5 +6093,6 @@ export { useAppState, // 9338748a useColorScheme, // d585efdb usePressability, // af291ad3 + useVerticalBarEdgeIOS, // a0670270 useWindowDimensions, // bb4b683f } diff --git a/packages/react-native/index.js b/packages/react-native/index.js index a86064115ca6..532da6d815b5 100644 --- a/packages/react-native/index.js +++ b/packages/react-native/index.js @@ -392,6 +392,10 @@ module.exports = { get usePressability() { return require('./Libraries/Pressability/usePressability').default; }, + get useVerticalBarEdgeIOS() { + return require('./Libraries/Components/StatusBar/useVerticalBarEdgeIOS') + .default; + }, get useWindowDimensions() { return require('./Libraries/Utilities/useWindowDimensions').default; }, diff --git a/packages/react-native/index.js.flow b/packages/react-native/index.js.flow index 113127b308b2..fa141a07cbfd 100644 --- a/packages/react-native/index.js.flow +++ b/packages/react-native/index.js.flow @@ -136,6 +136,7 @@ export type { StatusBarInstance, StatusBarProps, StatusBarStyle, + StatusBarVerticalBarEdgeIOS, } from './Libraries/Components/StatusBar/StatusBar'; export {default as StatusBar} from './Libraries/Components/StatusBar/StatusBar'; @@ -461,6 +462,7 @@ export type { export {default as usePressability} from './Libraries/Pressability/usePressability'; export {default as useAppState} from './Libraries/AppState/useAppState'; export {default as useColorScheme} from './Libraries/Utilities/useColorScheme'; +export {default as useVerticalBarEdgeIOS} from './Libraries/Components/StatusBar/useVerticalBarEdgeIOS'; export {default as useWindowDimensions} from './Libraries/Utilities/useWindowDimensions'; export {default as UTFSequence} from './Libraries/UTFSequence'; export {default as Vibration} from './Libraries/Vibration/Vibration'; diff --git a/packages/react-native/src/private/specs_DEPRECATED/modules/NativeStatusBarManagerIOS.js b/packages/react-native/src/private/specs_DEPRECATED/modules/NativeStatusBarManagerIOS.js index 3b221ee7a572..333dbdba757d 100644 --- a/packages/react-native/src/private/specs_DEPRECATED/modules/NativeStatusBarManagerIOS.js +++ b/packages/react-native/src/private/specs_DEPRECATED/modules/NativeStatusBarManagerIOS.js @@ -34,6 +34,10 @@ export interface Spec extends TurboModule { * - withAnimation can be: 'none' | 'fade' | 'slide' */ readonly setHidden: (hidden: boolean, withAnimation: string) => void; + /** + * - returns 'leading', 'trailing', or null when there is no vertical bar + */ + readonly getVerticalBarEdge?: () => ?string; } const NativeModule = TurboModuleRegistry.getEnforcing('StatusBarManager'); @@ -79,6 +83,13 @@ const NativeStatusBarManager = { setHidden(hidden: boolean, withAnimation: string): void { NativeModule.setHidden(hidden, withAnimation); }, + + /** + * - returns 'leading', 'trailing', or null when there is no vertical bar + */ + getVerticalBarEdge(): ?string { + return NativeModule.getVerticalBarEdge?.() ?? null; + }, }; export default NativeStatusBarManager; diff --git a/packages/rn-tester/js/examples/StatusBar/StatusBarExample.js b/packages/rn-tester/js/examples/StatusBar/StatusBarExample.js index 188889245304..46b57844c6d8 100644 --- a/packages/rn-tester/js/examples/StatusBar/StatusBarExample.js +++ b/packages/rn-tester/js/examples/StatusBar/StatusBarExample.js @@ -20,6 +20,7 @@ import { StyleSheet, TouchableHighlight, View, + useVerticalBarEdgeIOS, } from 'react-native'; const barStyles = ['default', 'auto', 'light-content', 'dark-content']; @@ -282,6 +283,18 @@ class StatusBarStaticAndroidExample extends React.Component<{...}> { } } +function StatusBarVerticalBarEdgeExample(): React.Node { + const edge = useVerticalBarEdgeIOS(); + + return ( + + + Vertical bar edge: {edge ?? 'none'} + + + ); +} + class ModalExample extends React.Component<{...}, $FlowFixMe> { state: $FlowFixMe | {modalVisible: boolean} = { modalVisible: false, @@ -352,6 +365,14 @@ exports.examples = [ }, platform: 'ios', }, + { + title: 'StatusBar vertical bar edge', + name: 'vertical-bar-edge', + render(): React.Node { + return ; + }, + platform: 'ios', + }, { title: 'StatusBar static API', render(): React.Node { diff --git a/scripts/cxx-api/api-snapshots/ReactAppleDebugCxx.api b/scripts/cxx-api/api-snapshots/ReactAppleDebugCxx.api index 483efc9b46d2..e9802edcef3c 100644 --- a/scripts/cxx-api/api-snapshots/ReactAppleDebugCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAppleDebugCxx.api @@ -2440,6 +2440,7 @@ protocol NativeSourceCodeSpec : public NSObjectRCTBridgeModule, public RCTTurboM } protocol NativeStatusBarManagerIOSSpec : public NSObjectRCTBridgeModule, public RCTTurboModule { + public virtual NSString* _Nullable getVerticalBarEdge(); public virtual facebook::react::ModuleConstants constantsToExport(); public virtual facebook::react::ModuleConstants getConstants(); public virtual void addListener:(NSString* eventType); diff --git a/scripts/cxx-api/api-snapshots/ReactAppleFrameworksCxx.api b/scripts/cxx-api/api-snapshots/ReactAppleFrameworksCxx.api index b1f98e0f87fc..a1861d3f5a9f 100644 --- a/scripts/cxx-api/api-snapshots/ReactAppleFrameworksCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAppleFrameworksCxx.api @@ -2498,6 +2498,7 @@ protocol NativeSourceCodeSpec : public NSObjectRCTBridgeModule, public RCTTurboM } protocol NativeStatusBarManagerIOSSpec : public NSObjectRCTBridgeModule, public RCTTurboModule { + public virtual NSString* _Nullable getVerticalBarEdge(); public virtual facebook::react::ModuleConstants constantsToExport(); public virtual facebook::react::ModuleConstants getConstants(); public virtual void addListener:(NSString* eventType); diff --git a/scripts/cxx-api/api-snapshots/ReactAppleNewarchCxx.api b/scripts/cxx-api/api-snapshots/ReactAppleNewarchCxx.api index e9ea4c1f0530..1686786ba135 100644 --- a/scripts/cxx-api/api-snapshots/ReactAppleNewarchCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAppleNewarchCxx.api @@ -2433,6 +2433,7 @@ protocol NativeSourceCodeSpec : public NSObjectRCTBridgeModule, public RCTTurboM } protocol NativeStatusBarManagerIOSSpec : public NSObjectRCTBridgeModule, public RCTTurboModule { + public virtual NSString* _Nullable getVerticalBarEdge(); public virtual facebook::react::ModuleConstants constantsToExport(); public virtual facebook::react::ModuleConstants getConstants(); public virtual void addListener:(NSString* eventType); diff --git a/scripts/cxx-api/api-snapshots/ReactAppleReleaseCxx.api b/scripts/cxx-api/api-snapshots/ReactAppleReleaseCxx.api index 57197975be4b..83f9385cc989 100644 --- a/scripts/cxx-api/api-snapshots/ReactAppleReleaseCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAppleReleaseCxx.api @@ -2440,6 +2440,7 @@ protocol NativeSourceCodeSpec : public NSObjectRCTBridgeModule, public RCTTurboM } protocol NativeStatusBarManagerIOSSpec : public NSObjectRCTBridgeModule, public RCTTurboModule { + public virtual NSString* _Nullable getVerticalBarEdge(); public virtual facebook::react::ModuleConstants constantsToExport(); public virtual facebook::react::ModuleConstants getConstants(); public virtual void addListener:(NSString* eventType);