diff --git a/.github/workflows/iphone-duo-ios-check.yml b/.github/workflows/iphone-duo-ios-check.yml new file mode 100644 index 000000000000..4b138bfe3292 --- /dev/null +++ b/.github/workflows/iphone-duo-ios-check.yml @@ -0,0 +1,91 @@ +name: iPhone Duo - iOS Native Check (Dimensions.getDisplayFeatures) + +# Standalone, manually-dispatched build that compiles the changed native file +# (RCTDeviceInfo.mm) via the traditional CocoaPods RNTester integration — +# self-contained, unlike the repo's normal iOS CI (test-ios-spm-rntester.yml / +# e2e-ios-rntester.yml), which depend on prebuilt xcframework artifacts from +# earlier jobs in test-all.yml and can't be dispatched on their own. + +on: + workflow_dispatch: {} + +jobs: + build: + runs-on: macos-15 + timeout-minutes: 60 + env: + APP_IOS_DIR: packages/rn-tester + XCODE_PROJECT: RNTesterPods.xcworkspace + XCODE_SCHEME: RNTester + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Select Xcode (prefer 27.1, else latest installed) + id: xcode + run: | + echo "Installed Xcode versions on this runner:" + ls /Applications | grep -i '^Xcode' || true + if [ -d "/Applications/Xcode_27.1.app" ]; then + SELECTED="/Applications/Xcode_27.1.app" + else + SELECTED=$(ls -d /Applications/Xcode_*.app 2>/dev/null | sort -V | tail -1) + fi + if [ -z "$SELECTED" ]; then + echo "::error::No Xcode installation found on this runner image." + exit 1 + fi + sudo xcode-select --switch "$SELECTED" + xcodebuild -version + echo "selected=$SELECTED" >> "$GITHUB_OUTPUT" + if [ "$SELECTED" != "/Applications/Xcode_27.1.app" ]; then + echo "::warning::Xcode 27.1 is not on this GitHub-hosted runner image yet; fell back to $SELECTED. This run does NOT confirm compatibility with Xcode 27.1 specifically." + fi + + - name: Setup node.js + uses: ./.github/actions/setup-node + + - name: Run yarn install + uses: ./.github/actions/yarn-install + + - name: Set Hermes prebuilt version + shell: bash + run: node ./scripts/releases/use-hermes-prebuilt.js + + - name: Run yarn install again, with the correct hermes version + uses: ./.github/actions/yarn-install + + - name: Install CocoaPods + run: pod --version || sudo gem install cocoapods --no-document + + - name: pod install (traditional integration, builds React-Core/CoreModules from source) + working-directory: ${{ env.APP_IOS_DIR }} + run: | + # Podfile.lock in the tree pins an older hermes-engine than the one + # "Set Hermes prebuilt version" just wrote into the podspec — drop + # the stale lock so CocoaPods resolves fresh instead of erroring. + rm -f Podfile.lock + pod install --repo-update + + - name: Build RNTester Debug for iOS Simulator (compiles RCTDeviceInfo.mm) + working-directory: ${{ env.APP_IOS_DIR }} + run: | + # Use xcodebuild's own exit code, not log-text grepping — a "error:" + # substring can show up harmlessly in retry/warning noise even on a + # build that ultimately succeeds. + set -o pipefail + xcodebuild \ + -workspace "$XCODE_PROJECT" \ + -scheme "$XCODE_SCHEME" \ + -configuration Debug \ + -sdk iphonesimulator \ + -destination 'generic/platform=iOS Simulator' \ + build 2>&1 | tee /tmp/xcodebuild.log + echo "RNTester (Debug, iphonesimulator) built cleanly, including the modified RCTDeviceInfo.mm." + + - name: Upload build log + if: always() + uses: actions/upload-artifact@v4 + with: + name: iphone-duo-rn-xcodebuild-log + path: /tmp/xcodebuild.log diff --git a/packages/react-native/Libraries/Utilities/Dimensions.js b/packages/react-native/Libraries/Utilities/Dimensions.js index 85a6fc5dc494..c7846fb59e2b 100644 --- a/packages/react-native/Libraries/Utilities/Dimensions.js +++ b/packages/react-native/Libraries/Utilities/Dimensions.js @@ -14,12 +14,18 @@ import EventEmitter, { } from '../vendor/emitter/EventEmitter'; import NativeDeviceInfo, { type DimensionsPayload, + type DisplayFeature, type DisplayMetrics, type DisplayMetricsAndroid, } from './NativeDeviceInfo'; import invariant from 'invariant'; -export type {DimensionsPayload, DisplayMetrics, DisplayMetricsAndroid}; +export type { + DimensionsPayload, + DisplayFeature, + DisplayMetrics, + DisplayMetricsAndroid, +}; /** @deprecated Use DisplayMetrics */ export type ScaledSize = DisplayMetrics; @@ -29,6 +35,7 @@ const eventEmitter = new EventEmitter<{ }>(); let dimensionsInitialized = false; let dimensions: DimensionsPayload; +let displayFeatures: $ReadOnlyArray = []; /** * Provides the application window's width and height. Prefer @@ -92,6 +99,7 @@ class Dimensions { } dimensions = {window, screen}; + displayFeatures = dims.displayFeatures ?? []; if (dimensionsInitialized) { // Don't fire 'change' the first time the dimensions are set. eventEmitter.emit('change', dimensions); @@ -100,6 +108,17 @@ class Dimensions { } } + /** + * Returns the display features (such as a hinge or a front-facing camera + * cutout) that content should avoid covering. Prefer `useDisplayFeatures` + * in React components. + * + * Empty on every platform today; see the `DisplayFeature` type. + */ + static getDisplayFeatures(): $ReadOnlyArray { + return displayFeatures; + } + /** * Add an event handler. Supported events: * diff --git a/packages/react-native/Libraries/Utilities/__tests__/Dimensions-displayFeatures-test.js b/packages/react-native/Libraries/Utilities/__tests__/Dimensions-displayFeatures-test.js new file mode 100644 index 000000000000..b992ef6a9306 --- /dev/null +++ b/packages/react-native/Libraries/Utilities/__tests__/Dimensions-displayFeatures-test.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 + */ + +'use strict'; + +import Dimensions from '../Dimensions'; + +describe('Dimensions.getDisplayFeatures', () => { + it('defaults to an empty array', () => { + Dimensions.set({window: {width: 1, height: 1, scale: 1, fontScale: 1}}); + expect(Dimensions.getDisplayFeatures()).toEqual([]); + }); + + it('reports the display features passed to set()', () => { + const displayFeatures = [ + { + type: 'hinge', + state: 'postureHalfOpened', + bounds: {x: 0, y: 410, width: 820, height: 24}, + }, + ]; + Dimensions.set({ + window: {width: 820, height: 1180, scale: 3, fontScale: 1}, + displayFeatures, + }); + expect(Dimensions.getDisplayFeatures()).toEqual(displayFeatures); + }); + + it('falls back to an empty array when a later update omits displayFeatures', () => { + Dimensions.set({ + window: {width: 820, height: 1180, scale: 3, fontScale: 1}, + displayFeatures: [ + { + type: 'cutout', + state: 'unknown', + bounds: {x: 0, y: 0, width: 40, height: 40}, + }, + ], + }); + expect(Dimensions.getDisplayFeatures()).toHaveLength(1); + + Dimensions.set({window: {width: 390, height: 844, scale: 3, fontScale: 1}}); + expect(Dimensions.getDisplayFeatures()).toEqual([]); + }); +}); diff --git a/packages/react-native/Libraries/Utilities/useDisplayFeatures.js b/packages/react-native/Libraries/Utilities/useDisplayFeatures.js new file mode 100644 index 000000000000..64df6caa08a7 --- /dev/null +++ b/packages/react-native/Libraries/Utilities/useDisplayFeatures.js @@ -0,0 +1,40 @@ +/** + * 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 Dimensions from './Dimensions'; +import {type DisplayFeature} from './NativeDeviceInfo'; +import {useEffect, useState} from 'react'; + +/** + * React hook that provides the display features (such as a hinge or a + * front-facing camera cutout) that content should avoid covering. + * Automatically updates when the device pose changes. + * + * Returns an empty array on every platform today. iOS reserves this for the + * iOS 27.1 SDK, which is not public as of 2026-09-10; see RCTDeviceInfo.mm. + */ +export default function useDisplayFeatures(): $ReadOnlyArray { + const [displayFeatures, setDisplayFeatures] = useState(() => + Dimensions.getDisplayFeatures(), + ); + useEffect(() => { + function handleChange() { + setDisplayFeatures(Dimensions.getDisplayFeatures()); + } + const subscription = Dimensions.addEventListener('change', handleChange); + // We might have missed an update between calling `getDisplayFeatures` in + // render and `addEventListener` in this handler. + handleChange(); + return () => { + subscription.remove(); + }; + }, []); + return displayFeatures; +} diff --git a/packages/react-native/React/CoreModules/RCTDeviceInfo.mm b/packages/react-native/React/CoreModules/RCTDeviceInfo.mm index 1761214fc3bd..4c86a376ca7a 100644 --- a/packages/react-native/React/CoreModules/RCTDeviceInfo.mm +++ b/packages/react-native/React/CoreModules/RCTDeviceInfo.mm @@ -215,7 +215,22 @@ static BOOL RCTIsIPhoneNotched() @"scale" : @(screen.scale), @"fontScale" : @(fontScale) }; - return @{@"window" : dimsWindow, @"screen" : dimsScreen}; + // Reserved regions (hinge, camera cutouts) that content should avoid, such + // as on iPhone Duo - see the "Designing for iPhone Duo" Human Interface + // Guidelines and Apple Tech Talk 111463, "Strike a pose with adaptive + // layouts on iPhone Duo". The underlying UIView.reservedRegions(kind:) API + // ships with the iOS 27.1 SDK, which is not part of any Xcode release + // publicly available as of 2026-09-10 (Apple lists Xcode 27.1 beta as + // "coming later this month"). RCTDeviceInfo.mm is plain Objective-C++ with + // no Swift compilation set up in this podspec target, so there is no safe + // way to reference that (Swift-only, not-yet-shipping) symbol from here + // yet without risking a build break for everyone. This always reports an + // empty array until that SDK is available and a proper native bridge (most + // likely a small Swift helper, once Swift sources are wired into this + // target's podspec, mirroring how other iPhone Duo-aware codebases in this + // ecosystem add a dedicated Swift provider file) can be added. + NSArray *displayFeatures = @[]; + return @{@"window" : dimsWindow, @"screen" : dimsScreen, @"displayFeatures" : displayFeatures}; } - (NSDictionary *)_exportedDimensions diff --git a/packages/react-native/src/private/specs_DEPRECATED/modules/NativeDeviceInfo.js b/packages/react-native/src/private/specs_DEPRECATED/modules/NativeDeviceInfo.js index 57d18b6bd3ab..7f8f00a58f87 100644 --- a/packages/react-native/src/private/specs_DEPRECATED/modules/NativeDeviceInfo.js +++ b/packages/react-native/src/private/specs_DEPRECATED/modules/NativeDeviceInfo.js @@ -27,11 +27,42 @@ export type DisplayMetrics = { fontScale: number, }; +// A region of the display that content should avoid, such as a hinge or a +// front-facing camera. Modeled after the reserved regions of iPhone Duo (see +// the "Designing for iPhone Duo" Human Interface Guidelines and Apple Tech +// Talk 111463, "Strike a pose with adaptive layouts on iPhone Duo") so that a +// future foldable/dual-display Android device can report through the same +// shape. +export type DisplayFeatureType = 'hinge' | 'cutout'; + +// The posture of a 'hinge' DisplayFeature. Always 'unknown' for 'cutout'. +export type DisplayFeatureState = + | 'unknown' + | 'postureFlat' + | 'postureHalfOpened'; + +export type DisplayFeatureRect = { + x: number, + y: number, + width: number, + height: number, +}; + +export type DisplayFeature = { + type: DisplayFeatureType, + state: DisplayFeatureState, + // In the same coordinate space as the 'window' DisplayMetrics, in points. + bounds: DisplayFeatureRect, +}; + export type DimensionsPayload = { window?: DisplayMetrics, screen?: DisplayMetrics, windowPhysicalPixels?: DisplayMetricsAndroid, screenPhysicalPixels?: DisplayMetricsAndroid, + // Empty on every platform today. iOS reserves the field pending the iOS + // 27.1 SDK (not public as of 2026-09-10); see RCTDeviceInfo.mm. + displayFeatures?: $ReadOnlyArray, }; export type DeviceInfoConstants = {