Skip to content
Draft
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
91 changes: 91 additions & 0 deletions .github/workflows/iphone-duo-ios-check.yml
Original file line number Diff line number Diff line change
@@ -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
21 changes: 20 additions & 1 deletion packages/react-native/Libraries/Utilities/Dimensions.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -29,6 +35,7 @@ const eventEmitter = new EventEmitter<{
}>();
let dimensionsInitialized = false;
let dimensions: DimensionsPayload;
let displayFeatures: $ReadOnlyArray<DisplayFeature> = [];

/**
* Provides the application window's width and height. Prefer
Expand Down Expand Up @@ -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);
Expand All @@ -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<DisplayFeature> {
return displayFeatures;
}

/**
* Add an event handler. Supported events:
*
Expand Down
Original file line number Diff line number Diff line change
@@ -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([]);
});
});
40 changes: 40 additions & 0 deletions packages/react-native/Libraries/Utilities/useDisplayFeatures.js
Original file line number Diff line number Diff line change
@@ -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<DisplayFeature> {
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;
}
17 changes: 16 additions & 1 deletion packages/react-native/React/CoreModules/RCTDeviceInfo.mm
Original file line number Diff line number Diff line change
Expand Up @@ -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<NSDictionary *> *displayFeatures = @[];
return @{@"window" : dimsWindow, @"screen" : dimsScreen, @"displayFeatures" : displayFeatures};
}

- (NSDictionary *)_exportedDimensions
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<DisplayFeature>,
};

export type DeviceInfoConstants = {
Expand Down
Loading