From fd21f1ba1aca645957ba8d04ef8c23a5ec4aa7cc Mon Sep 17 00:00:00 2001 From: gladiuscode Date: Fri, 25 Sep 2026 12:13:41 +0200 Subject: [PATCH 1/9] fix(ios): report correct orientations on foldable devices (iPhone Duo) - Drive the interface orientation from the window scene effectiveGeometry (KVO) on iOS 16+, so fold / unfold and orientations applied by the system in resizable environments are reported, even when the app is locked. - Read effectiveGeometry.interfaceOrientation instead of the deprecated UIWindowScene.interfaceOrientation. - Make the device orientation relative to the active display: the iPhone Duo inner display is mounted rotated by 90 degrees relative to the chassis. - Only emit orientation events when the value actually changes. Fixes #115 Co-Authored-By: Claude Opus 5.5 --- .../OrientationDirectorImpl.swift | 71 +++++++++++++-- .../SceneGeometryListener.swift | 88 +++++++++++++++++++ ios/implementation/Utils.swift | 48 ++++++++++ 3 files changed, 201 insertions(+), 6 deletions(-) create mode 100644 ios/implementation/SceneGeometryListener.swift diff --git a/ios/implementation/OrientationDirectorImpl.swift b/ios/implementation/OrientationDirectorImpl.swift index e47d55b..7c07ea5 100644 --- a/ios/implementation/OrientationDirectorImpl.swift +++ b/ios/implementation/OrientationDirectorImpl.swift @@ -14,10 +14,12 @@ import UIKit private let bundleManager: BundleManager = BundleManager() private let utils: Utils = Utils() private let sensorListener: SensorListener = SensorListener() + private lazy var sceneGeometryListener: SceneGeometryListener = SceneGeometryListener(utils: utils) private let eventManager: EventManager = EventManager() private var initialSupportedInterfaceOrientations: UIInterfaceOrientationMask = UIInterfaceOrientationMask.all private var lastInterfaceOrientation = Orientation.UNKNOWN private var lastDeviceOrientation = Orientation.UNKNOWN + private var lastUIDeviceOrientation = UIDeviceOrientation.unknown private var isLocked = false /// # Only on iOS < 16 @@ -40,6 +42,13 @@ import UIKit isLocked = initIsLocked() supportedInterfaceOrientations = initialSupportedInterfaceOrientations + + sceneGeometryListener.setOnGeometryDidChange(callback: self.onSceneGeometryChanged) + sceneGeometryListener.attach() + // The scene might not be connected yet when this module is initialized + DispatchQueue.main.async { [weak self] in + self?.sceneGeometryListener.attach() + } } /////////////////////////////////////////////////////////////////////////////////////// @@ -93,6 +102,11 @@ import UIKit self.requestInterfaceUpdateTo(mask: UIInterfaceOrientationMask.all) updateIsLockedTo(value: false) + + if #available(iOS 16.0, *) { + return + } + self.adaptInterfaceTo(deviceOrientation: lastDeviceOrientation) } @@ -134,7 +148,8 @@ import UIKit } private func initDeviceOrientation() -> Orientation { - return utils.convertToOrientationFrom(deviceOrientation: UIDevice.current.orientation) + lastUIDeviceOrientation = UIDevice.current.orientation + return getDeviceOrientationRelativeToDisplay() } private func initIsLocked() -> Bool { @@ -173,16 +188,39 @@ import UIKit } private func onOrientationChanged(uiDeviceOrientation: UIDeviceOrientation) { - let deviceOrientation = utils.convertToOrientationFrom(deviceOrientation: uiDeviceOrientation) + lastUIDeviceOrientation = uiDeviceOrientation + let deviceOrientation = getDeviceOrientationRelativeToDisplay() + updateLastDeviceOrientationTo(value: deviceOrientation) - if (!self.isLocking) { - self.eventManager.sendDeviceOrientationDidChange(value: deviceOrientation.rawValue) + /// On iOS >= 16 the interface orientation is driven by the scene + /// geometry listener, see onSceneGeometryChanged. + if #available(iOS 16.0, *) { + return } - lastDeviceOrientation = deviceOrientation adaptInterfaceTo(deviceOrientation: deviceOrientation) } + /// # Only on iOS >= 16 + /// The scene geometry is the source of truth for the interface orientation: + /// it reflects what the system actually displays, even when the supported + /// interface orientations are ignored (e.g. iPhone Duo inner display) and + /// when the scene moves to another screen (fold / unfold). + private func onSceneGeometryChanged() { + // The scene might have moved to a display with a different + // alignment to the chassis (e.g. iPhone Duo fold / unfold) + updateLastDeviceOrientationTo(value: getDeviceOrientationRelativeToDisplay()) + + let interfaceOrientation = utils.getInterfaceOrientation() + if interfaceOrientation == .unknown { + return + } + + let newInterfaceOrientation = utils.convertToOrientationFrom(uiInterfaceOrientation: interfaceOrientation) + updateLastInterfaceOrientationTo(value: newInterfaceOrientation) + } + + /// # Only on iOS < 16 private func adaptInterfaceTo(deviceOrientation: Orientation) { let supportsLandscape = self.supportedInterfaceOrientations.contains(.landscape) if isLocked && !supportsLandscape { @@ -202,11 +240,32 @@ import UIKit isLocked = value } + private func updateLastDeviceOrientationTo(value: Orientation) { + if value == lastDeviceOrientation { + return + } + + if !self.isLocking { + self.eventManager.sendDeviceOrientationDidChange(value: value.rawValue) + } + + lastDeviceOrientation = value + } + private func updateLastInterfaceOrientationTo(value: Orientation) { - self.eventManager.sendInterfaceOrientationDidChange(value: value.rawValue) + if value == lastInterfaceOrientation { + return + } + + self.eventManager.sendInterfaceOrientationDidChange(value: value.rawValue) lastInterfaceOrientation = value } + private func getDeviceOrientationRelativeToDisplay() -> Orientation { + let deviceOrientation = utils.convertToDisplayRelativeFrom(deviceOrientation: lastUIDeviceOrientation) + return utils.convertToOrientationFrom(deviceOrientation: deviceOrientation) + } + private func getOrientationFromInterface() -> Orientation { let interfaceOrientation = utils.getInterfaceOrientation() return utils.convertToOrientationFrom(uiInterfaceOrientation: interfaceOrientation) diff --git a/ios/implementation/SceneGeometryListener.swift b/ios/implementation/SceneGeometryListener.swift new file mode 100644 index 0000000..42f7f7a --- /dev/null +++ b/ios/implementation/SceneGeometryListener.swift @@ -0,0 +1,88 @@ +// +// SceneGeometryListener.swift +// react-native-orientation-director +// +// Created by gladiuscode on 25/09/2026. +// + +import Foundation +import UIKit + +/// # Only on iOS >= 16 +/// Observes the effective geometry of the current window scene. +/// This is the only reliable source of interface orientation changes: +/// - on foldable devices (iPhone Duo) folding / unfolding moves the scene +/// to another screen without triggering any device orientation change; +/// - the geometry is updated after UIDevice.orientationDidChangeNotification, +/// so reading it from the sensor callback would return a stale value. +/// https://developer.apple.com/documentation/uikit/uiwindowscene/effectivegeometry +public class SceneGeometryListener { + private let utils: Utils + private var onGeometryDidChangeCallback: (() -> Void)? + private var observation: NSKeyValueObservation? + private weak var observedScene: UIWindowScene? + + init(utils: Utils) { + self.utils = utils + + NotificationCenter.default.addObserver( + self, + selector: #selector(sceneDidActivate), + name: UIScene.didActivateNotification, + object: nil + ) + } + + deinit { + NotificationCenter.default.removeObserver(self) + observation?.invalidate() + } + + func setOnGeometryDidChange(callback: @escaping () -> Void) { + self.onGeometryDidChangeCallback = callback + } + + /// Starts observing the current window scene, if not already observed. + /// The callback is invoked right after attaching so that the caller + /// can sync its state with the current geometry. + func attach() { + attach(to: utils.getCurrentWindow()?.windowScene) + } + + @objc private func sceneDidActivate(_ notification: Notification) { + attach(to: notification.object as? UIWindowScene) + } + + private func attach(to scene: UIWindowScene?) { + guard #available(iOS 16.0, *) else { + return + } + + guard let scene = scene, scene.session.role == .windowApplication else { + return + } + + if observedScene === scene { + return + } + + observedScene = scene + observation?.invalidate() + observation = scene.observe(\.effectiveGeometry, options: [.new]) { [weak self] _, _ in + self?.notifyGeometryDidChange() + } + + notifyGeometryDidChange() + } + + private func notifyGeometryDidChange() { + guard Thread.isMainThread else { + DispatchQueue.main.async { [weak self] in + self?.notifyGeometryDidChange() + } + return + } + + onGeometryDidChangeCallback?() + } +} diff --git a/ios/implementation/Utils.swift b/ios/implementation/Utils.swift index 65b3419..64bbdb1 100644 --- a/ios/implementation/Utils.swift +++ b/ios/implementation/Utils.swift @@ -42,6 +42,50 @@ class Utils { } } + /** + UIDevice.orientation is relative to the device chassis. On foldable devices + (iPhone Duo) the inner display is mounted rotated by 90° relative to the chassis, + so while the scene is on that display we rotate the device orientation + to make it relative to the display the user is looking at. + */ + public func convertToDisplayRelativeFrom(deviceOrientation: UIDeviceOrientation) -> UIDeviceOrientation { + if !isSceneOnRotatedDisplay() { + return deviceOrientation + } + + switch deviceOrientation { + case .portrait: + return .landscapeRight + case .landscapeRight: + return .portraitUpsideDown + case .portraitUpsideDown: + return .landscapeLeft + case .landscapeLeft: + return .portrait + default: + return deviceOrientation + } + } + + /** + On iPhone the main screen is always aligned with the chassis, even on iPhone Duo + where it's the outer display. When unfolded, the scene moves to the inner display, + which is a different screen. + Note: UIScreen.main is deprecated since iOS 26, but it's the only way to identify + the device's built-in screen from a scene placed on another one. + */ + private func isSceneOnRotatedDisplay() -> Bool { + guard UIDevice.current.userInterfaceIdiom == .phone else { + return false + } + + guard let screen = self.getCurrentWindow()?.windowScene?.screen else { + return false + } + + return screen !== UIScreen.main + } + public func convertToOrientationFrom(jsValue: NSNumber) -> Orientation { switch jsValue { case 2: @@ -98,6 +142,10 @@ class Utils { return UIInterfaceOrientation.unknown } + if #available(iOS 16.0, *) { + return windowScene.effectiveGeometry.interfaceOrientation + } + return windowScene.interfaceOrientation } From 39542ce9b6452ba61141f9eb74951b9b5ce408da Mon Sep 17 00:00:00 2001 From: gladiuscode Date: Fri, 25 Sep 2026 12:17:55 +0200 Subject: [PATCH 2/9] fix(ios): report the orientation applied by the system after lockTo/reset On iOS 16+ lockTo and resetSupportedInterfaceOrientations no longer update the interface orientation optimistically: the system might ignore the request (e.g. iPhone Duo inner display, where supported orientations are only a preference), so the scene geometry listener reports the actual value. Co-Authored-By: Claude Opus 5.5 --- ios/implementation/OrientationDirectorImpl.swift | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/ios/implementation/OrientationDirectorImpl.swift b/ios/implementation/OrientationDirectorImpl.swift index 7c07ea5..905365f 100644 --- a/ios/implementation/OrientationDirectorImpl.swift +++ b/ios/implementation/OrientationDirectorImpl.swift @@ -79,6 +79,14 @@ import UIKit updateIsLockedTo(value: true) + /// On iOS >= 16 the scene geometry listener reports the orientation + /// actually applied by the system, which might differ from the requested + /// one or ignore it entirely (e.g. iPhone Duo inner display). + if #available(iOS 16.0, *) { + self.isLocking = false + return + } + let orientationCanBeUpdatedDirectly = jsOrientation != Orientation.LANDSCAPE if orientationCanBeUpdatedDirectly { updateLastInterfaceOrientationTo(value: jsOrientation) @@ -115,6 +123,12 @@ import UIKit self.requestInterfaceUpdateTo(mask: self.supportedInterfaceOrientations) self.updateIsLockedTo(value: self.initIsLocked()) + /// On iOS >= 16 the scene geometry listener reports the orientation + /// actually applied by the system. + if #available(iOS 16.0, *) { + return + } + let lastMask = utils.convertToMaskFrom(jsOrientation: lastInterfaceOrientation) let isLastMaskSupported = self.supportedInterfaceOrientations.contains(lastMask) if isLastMaskSupported { From bf3c82e18f5acab41254ce2bb698353b877bfa46 Mon Sep 17 00:00:00 2001 From: gladiuscode Date: Fri, 25 Sep 2026 12:19:43 +0200 Subject: [PATCH 3/9] docs: document foldable devices behavior and iOS 27 scene orientations Co-Authored-By: Claude Opus 5.5 --- README.md | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/README.md b/README.md index d0b66b4..caf0a94 100644 --- a/README.md +++ b/README.md @@ -130,6 +130,21 @@ func application(_ application: UIApplication, supportedInterfaceOrientationsFor ``` +Starting from iOS 27, `application(_:supportedInterfaceOrientationsFor:)` is deprecated in favor of +`UIWindowSceneDelegate.supportedInterfaceOrientations(for:)`. The AppDelegate method above still works, but if your +app adopts the UIScene lifecycle and you build with the iOS 27 SDK, you can implement the new method in your +SceneDelegate instead: + +```swift + +import OrientationDirector + +func supportedInterfaceOrientations(for windowScene: UIWindowScene) -> UIInterfaceOrientationMask { + return SharedOrientationDirectorImpl.shared.supportedInterfaceOrientations +} + +``` + If you need help, you can check the example project. ## Usage @@ -184,6 +199,29 @@ documentation: 2. [iOS - UIDeviceOrientation](https://developer.apple.com/documentation/uikit/uideviceorientation) 3. [Android - getRotation]() +### iOS + +Starting from iOS 16, the interface orientation is read from the window scene geometry, which reflects what the +system actually displays. As a consequence, after calling `lockTo` or `resetSupportedInterfaceOrientations` the new +interface orientation is delivered through the listeners (and hooks) as soon as the system applies it, instead of +being assumed right away. + +#### Foldable devices (iPhone Duo) + +On foldable devices the app moves between the outer and the inner display when the device is folded or unfolded: + +1. The interface orientation is updated on fold / unfold, even though the device itself is not rotated; +2. The device orientation is relative to the display in use. The inner display is mounted rotated by 90° relative + to the device chassis, so the library converts the native `UIDevice.orientation` to match the display the user is + looking at (e.g. an unfolded device held with the inner display taller than wide reports `portrait`); +3. The inner display is a resizable environment: iOS treats the supported interface orientations as a preference and + ignores them there. This means that `lockTo` has no effect on the inner display, while it keeps working on the + outer one. `isLocked` still reports whether a lock has been requested, and the interface orientation always + reports the orientation actually displayed. + +Apple recommends relying on size classes rather than on the interface orientation for layout decisions in resizable +environments. More info in [Modernize your UIKit app (WWDC26)](https://developer.apple.com/videos/play/wwdc2026/278/). + ### Android Since on Android we need to deal with sensors and their usage, it is worth noting that the device orientation computation works From 6bdefa3f6761504558059e4b585d8fd5eaaa17cb Mon Sep 17 00:00:00 2001 From: gladiuscode Date: Sat, 26 Sep 2026 23:17:08 +0200 Subject: [PATCH 4/9] chore: update README.md --- README.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/README.md b/README.md index caf0a94..c8ecf74 100644 --- a/README.md +++ b/README.md @@ -219,9 +219,6 @@ On foldable devices the app moves between the outer and the inner display when t outer one. `isLocked` still reports whether a lock has been requested, and the interface orientation always reports the orientation actually displayed. -Apple recommends relying on size classes rather than on the interface orientation for layout decisions in resizable -environments. More info in [Modernize your UIKit app (WWDC26)](https://developer.apple.com/videos/play/wwdc2026/278/). - ### Android Since on Android we need to deal with sensors and their usage, it is worth noting that the device orientation computation works From 07ab6cc3ed7aee85a2b37a0f97ae3f2e04868a36 Mon Sep 17 00:00:00 2001 From: gladiuscode Date: Mon, 28 Sep 2026 09:59:32 +0200 Subject: [PATCH 5/9] fix(ios): report the device orientation as provided by iOS Revert the display-relative conversion of the device orientation: the library now exposes UIDevice.orientation as-is, matching what a native app reads. On iPhone Duo the device orientation is relative to the device body and does not change on fold / unfold. Co-Authored-By: Claude Opus 5.5 --- .../OrientationDirectorImpl.swift | 16 +------ ios/implementation/Utils.swift | 44 ------------------- 2 files changed, 2 insertions(+), 58 deletions(-) diff --git a/ios/implementation/OrientationDirectorImpl.swift b/ios/implementation/OrientationDirectorImpl.swift index 905365f..52ec795 100644 --- a/ios/implementation/OrientationDirectorImpl.swift +++ b/ios/implementation/OrientationDirectorImpl.swift @@ -19,7 +19,6 @@ import UIKit private var initialSupportedInterfaceOrientations: UIInterfaceOrientationMask = UIInterfaceOrientationMask.all private var lastInterfaceOrientation = Orientation.UNKNOWN private var lastDeviceOrientation = Orientation.UNKNOWN - private var lastUIDeviceOrientation = UIDeviceOrientation.unknown private var isLocked = false /// # Only on iOS < 16 @@ -162,8 +161,7 @@ import UIKit } private func initDeviceOrientation() -> Orientation { - lastUIDeviceOrientation = UIDevice.current.orientation - return getDeviceOrientationRelativeToDisplay() + return utils.convertToOrientationFrom(deviceOrientation: UIDevice.current.orientation) } private func initIsLocked() -> Bool { @@ -202,8 +200,7 @@ import UIKit } private func onOrientationChanged(uiDeviceOrientation: UIDeviceOrientation) { - lastUIDeviceOrientation = uiDeviceOrientation - let deviceOrientation = getDeviceOrientationRelativeToDisplay() + let deviceOrientation = utils.convertToOrientationFrom(deviceOrientation: uiDeviceOrientation) updateLastDeviceOrientationTo(value: deviceOrientation) /// On iOS >= 16 the interface orientation is driven by the scene @@ -221,10 +218,6 @@ import UIKit /// interface orientations are ignored (e.g. iPhone Duo inner display) and /// when the scene moves to another screen (fold / unfold). private func onSceneGeometryChanged() { - // The scene might have moved to a display with a different - // alignment to the chassis (e.g. iPhone Duo fold / unfold) - updateLastDeviceOrientationTo(value: getDeviceOrientationRelativeToDisplay()) - let interfaceOrientation = utils.getInterfaceOrientation() if interfaceOrientation == .unknown { return @@ -275,11 +268,6 @@ import UIKit lastInterfaceOrientation = value } - private func getDeviceOrientationRelativeToDisplay() -> Orientation { - let deviceOrientation = utils.convertToDisplayRelativeFrom(deviceOrientation: lastUIDeviceOrientation) - return utils.convertToOrientationFrom(deviceOrientation: deviceOrientation) - } - private func getOrientationFromInterface() -> Orientation { let interfaceOrientation = utils.getInterfaceOrientation() return utils.convertToOrientationFrom(uiInterfaceOrientation: interfaceOrientation) diff --git a/ios/implementation/Utils.swift b/ios/implementation/Utils.swift index 64bbdb1..b6d9ec2 100644 --- a/ios/implementation/Utils.swift +++ b/ios/implementation/Utils.swift @@ -42,50 +42,6 @@ class Utils { } } - /** - UIDevice.orientation is relative to the device chassis. On foldable devices - (iPhone Duo) the inner display is mounted rotated by 90° relative to the chassis, - so while the scene is on that display we rotate the device orientation - to make it relative to the display the user is looking at. - */ - public func convertToDisplayRelativeFrom(deviceOrientation: UIDeviceOrientation) -> UIDeviceOrientation { - if !isSceneOnRotatedDisplay() { - return deviceOrientation - } - - switch deviceOrientation { - case .portrait: - return .landscapeRight - case .landscapeRight: - return .portraitUpsideDown - case .portraitUpsideDown: - return .landscapeLeft - case .landscapeLeft: - return .portrait - default: - return deviceOrientation - } - } - - /** - On iPhone the main screen is always aligned with the chassis, even on iPhone Duo - where it's the outer display. When unfolded, the scene moves to the inner display, - which is a different screen. - Note: UIScreen.main is deprecated since iOS 26, but it's the only way to identify - the device's built-in screen from a scene placed on another one. - */ - private func isSceneOnRotatedDisplay() -> Bool { - guard UIDevice.current.userInterfaceIdiom == .phone else { - return false - } - - guard let screen = self.getCurrentWindow()?.windowScene?.screen else { - return false - } - - return screen !== UIScreen.main - } - public func convertToOrientationFrom(jsValue: NSNumber) -> Orientation { switch jsValue { case 2: From d8166624e6729ccb58ef9559359e976c7260dd26 Mon Sep 17 00:00:00 2001 From: gladiuscode Date: Tue, 29 Sep 2026 23:10:24 +0200 Subject: [PATCH 6/9] docs: document raw device orientation on foldables Co-Authored-By: Claude Sonnet 5.5 --- README.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index c8ecf74..358d33c 100644 --- a/README.md +++ b/README.md @@ -211,9 +211,10 @@ being assumed right away. On foldable devices the app moves between the outer and the inner display when the device is folded or unfolded: 1. The interface orientation is updated on fold / unfold, even though the device itself is not rotated; -2. The device orientation is relative to the display in use. The inner display is mounted rotated by 90° relative - to the device chassis, so the library converts the native `UIDevice.orientation` to match the display the user is - looking at (e.g. an unfolded device held with the inner display taller than wide reports `portrait`); +2. The device orientation is the one reported by iOS (`UIDevice.orientation`), which is relative to the device body + and does not change on fold / unfold. The iPhone Duo inner display is mounted rotated by 90° relative to the + body, so an unfolded device can report a `portrait` device orientation together with a landscape interface + orientation, exactly like a native app would read them; 3. The inner display is a resizable environment: iOS treats the supported interface orientations as a preference and ignores them there. This means that `lockTo` has no effect on the inner display, while it keeps working on the outer one. `isLocked` still reports whether a lock has been requested, and the interface orientation always From 8bea7cf78019c4c00517ed7716532b9095d05439 Mon Sep 17 00:00:00 2001 From: gladiuscode Date: Wed, 30 Sep 2026 22:31:39 +0200 Subject: [PATCH 7/9] fix(ios): read geometry from the observed application scene Co-Authored-By: Claude Sonnet 5.5 --- ios/implementation/OrientationDirectorImpl.swift | 4 ++-- ios/implementation/SceneGeometryListener.swift | 16 ++++++++-------- ios/implementation/Utils.swift | 5 +++-- 3 files changed, 13 insertions(+), 12 deletions(-) diff --git a/ios/implementation/OrientationDirectorImpl.swift b/ios/implementation/OrientationDirectorImpl.swift index 52ec795..238bcee 100644 --- a/ios/implementation/OrientationDirectorImpl.swift +++ b/ios/implementation/OrientationDirectorImpl.swift @@ -217,8 +217,8 @@ import UIKit /// it reflects what the system actually displays, even when the supported /// interface orientations are ignored (e.g. iPhone Duo inner display) and /// when the scene moves to another screen (fold / unfold). - private func onSceneGeometryChanged() { - let interfaceOrientation = utils.getInterfaceOrientation() + private func onSceneGeometryChanged(scene: UIWindowScene) { + let interfaceOrientation = utils.getInterfaceOrientation(scene: scene) if interfaceOrientation == .unknown { return } diff --git a/ios/implementation/SceneGeometryListener.swift b/ios/implementation/SceneGeometryListener.swift index 42f7f7a..df28bba 100644 --- a/ios/implementation/SceneGeometryListener.swift +++ b/ios/implementation/SceneGeometryListener.swift @@ -18,7 +18,7 @@ import UIKit /// https://developer.apple.com/documentation/uikit/uiwindowscene/effectivegeometry public class SceneGeometryListener { private let utils: Utils - private var onGeometryDidChangeCallback: (() -> Void)? + private var onGeometryDidChangeCallback: ((UIWindowScene) -> Void)? private var observation: NSKeyValueObservation? private weak var observedScene: UIWindowScene? @@ -38,7 +38,7 @@ public class SceneGeometryListener { observation?.invalidate() } - func setOnGeometryDidChange(callback: @escaping () -> Void) { + func setOnGeometryDidChange(callback: @escaping (UIWindowScene) -> Void) { self.onGeometryDidChangeCallback = callback } @@ -68,21 +68,21 @@ public class SceneGeometryListener { observedScene = scene observation?.invalidate() - observation = scene.observe(\.effectiveGeometry, options: [.new]) { [weak self] _, _ in - self?.notifyGeometryDidChange() + observation = scene.observe(\.effectiveGeometry, options: [.new]) { [weak self] scene, _ in + self?.notifyGeometryDidChange(scene: scene) } - notifyGeometryDidChange() + notifyGeometryDidChange(scene: scene) } - private func notifyGeometryDidChange() { + private func notifyGeometryDidChange(scene: UIWindowScene) { guard Thread.isMainThread else { DispatchQueue.main.async { [weak self] in - self?.notifyGeometryDidChange() + self?.notifyGeometryDidChange(scene: scene) } return } - onGeometryDidChangeCallback?() + onGeometryDidChangeCallback?(scene) } } diff --git a/ios/implementation/Utils.swift b/ios/implementation/Utils.swift index b6d9ec2..db063d3 100644 --- a/ios/implementation/Utils.swift +++ b/ios/implementation/Utils.swift @@ -93,8 +93,8 @@ class Utils { } } - public func getInterfaceOrientation() -> UIInterfaceOrientation { - guard let windowScene = self.getCurrentWindow()?.windowScene else { + public func getInterfaceOrientation(scene: UIWindowScene? = nil) -> UIInterfaceOrientation { + guard let windowScene = scene ?? self.getCurrentWindow()?.windowScene else { return UIInterfaceOrientation.unknown } @@ -114,6 +114,7 @@ class Utils { .shared .connectedScenes .compactMap { $0 as? UIWindowScene } + .filter { $0.session.role == .windowApplication } .flatMap { $0.windows } .last { $0.isKeyWindow } } From 83b59df095d6d44fd981461423d5096a814f26d0 Mon Sep 17 00:00:00 2001 From: gladiuscode Date: Mon, 28 Sep 2026 10:26:19 +0200 Subject: [PATCH 8/9] feat: add onDisplayChanged event (listenForDisplayChanges) Notify when the app moves to a different physical display, e.g. when a foldable device is folded or unfolded, with the new display size in points (iOS) / dp (Android). Rotations and window resizes don't trigger it. - iOS: emitted when the window scene moves to another screen (iOS 16+) - Android: emitted when the physical size of the display changes, through DisplayManager.DisplayListener - Example: show the last display change in the Explore screen Co-Authored-By: Claude Opus 5.5 --- .../OrientationDirectorModule.kt | 8 ++ .../implementation/DisplayChangesListener.kt | 106 ++++++++++++++++++ .../implementation/EventManager.kt | 8 ++ .../implementation/EventManagerDelegate.kt | 1 + .../OrientationDirectorModuleImpl.kt | 8 ++ example/src/screens/Explore.tsx | 14 +++ ios/OrientationDirector.mm | 8 ++ ios/implementation/EventManager.swift | 10 ++ .../OrientationDirectorImpl.swift | 25 +++++ src/EventEmitter.ts | 7 ++ src/NativeOrientationDirector.ts | 4 + src/RNOrientationDirector.ts | 14 +++ src/types/DisplayChangedEvent.interface.ts | 12 ++ 13 files changed, 225 insertions(+) create mode 100644 android/src/main/java/com/orientationdirector/implementation/DisplayChangesListener.kt create mode 100644 src/types/DisplayChangedEvent.interface.ts diff --git a/android/src/main/java/com/orientationdirector/OrientationDirectorModule.kt b/android/src/main/java/com/orientationdirector/OrientationDirectorModule.kt index 25f201b..9cbca02 100644 --- a/android/src/main/java/com/orientationdirector/OrientationDirectorModule.kt +++ b/android/src/main/java/com/orientationdirector/OrientationDirectorModule.kt @@ -73,6 +73,14 @@ class OrientationDirectorModule(reactContext: ReactApplicationContext) : } } + override fun sendOnDisplayChanged(params: WritableMap) { + try { + emitOnDisplayChanged(params) + } catch(_: Exception) { + // No listener instance yet + } + } + companion object { const val NAME = OrientationDirectorModuleImpl.NAME } diff --git a/android/src/main/java/com/orientationdirector/implementation/DisplayChangesListener.kt b/android/src/main/java/com/orientationdirector/implementation/DisplayChangesListener.kt new file mode 100644 index 0000000..89cb943 --- /dev/null +++ b/android/src/main/java/com/orientationdirector/implementation/DisplayChangesListener.kt @@ -0,0 +1,106 @@ +package com.orientationdirector.implementation + +import android.content.Context +import android.graphics.Point +import android.hardware.display.DisplayManager +import android.os.Build +import android.os.Handler +import android.os.Looper +import android.view.WindowManager +import com.facebook.react.bridge.ReactApplicationContext + +/** + * Notifies when the app moves to a display with a different physical size, + * e.g. when a foldable device is folded or unfolded. + * + * DisplayManager notifies any display change, rotations included, so the + * physical size is compared regardless of its orientation. + */ +class DisplayChangesListener internal constructor(private val context: ReactApplicationContext) : + DisplayManager.DisplayListener { + + private val displayManager = context.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager + private val handler = Handler(Looper.getMainLooper()) + private var isRegistered = false + private var lastPhysicalSize: Pair? = null + + private var onDisplayChangedCallback: ((widthDp: Double, heightDp: Double) -> Unit)? = null + + fun setOnDisplayChangedCallback(callback: (widthDp: Double, heightDp: Double) -> Unit) { + onDisplayChangedCallback = callback + } + + fun register() { + if (isRegistered) { + return + } + + displayManager.registerDisplayListener(this, handler) + isRegistered = true + + // The display might have changed while the listener was unregistered + checkDisplay() + } + + fun unregister() { + if (!isRegistered) { + return + } + + displayManager.unregisterDisplayListener(this) + isRegistered = false + } + + override fun onDisplayAdded(displayId: Int) = Unit + + override fun onDisplayRemoved(displayId: Int) = Unit + + override fun onDisplayChanged(displayId: Int) { + checkDisplay() + } + + private fun checkDisplay() { + val size = computeDisplaySize() ?: return + val physicalSize = Pair(minOf(size.x, size.y), maxOf(size.x, size.y)) + + val previousPhysicalSize = lastPhysicalSize + lastPhysicalSize = physicalSize + + // First sync, nothing changed yet + if (previousPhysicalSize == null || previousPhysicalSize == physicalSize) { + return + } + + val density = computeDensity() + onDisplayChangedCallback?.invoke(size.x / density, size.y / density) + } + + /** + * Returns the size of the display the activity is on, in pixels, + * in the orientation it is currently displayed. + */ + private fun computeDisplaySize(): Point? { + val activity = context.currentActivity ?: return null + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + val bounds = activity.windowManager.maximumWindowMetrics.bounds + return Point(bounds.width(), bounds.height()) + } + + val windowManager = activity.getSystemService(Context.WINDOW_SERVICE) as WindowManager + val size = Point() + @Suppress("DEPRECATION") + windowManager.defaultDisplay.getRealSize(size) + return size + } + + private fun computeDensity(): Double { + val activity = context.currentActivity + + if (activity != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + return activity.windowManager.maximumWindowMetrics.density.toDouble() + } + + return (activity?.resources ?: context.resources).displayMetrics.density.toDouble() + } +} diff --git a/android/src/main/java/com/orientationdirector/implementation/EventManager.kt b/android/src/main/java/com/orientationdirector/implementation/EventManager.kt index d126d14..3918c76 100644 --- a/android/src/main/java/com/orientationdirector/implementation/EventManager.kt +++ b/android/src/main/java/com/orientationdirector/implementation/EventManager.kt @@ -27,6 +27,14 @@ class EventManager(private val delegate: EventManagerDelegate) { delegate.sendOnLockChanged(params) } + fun sendDisplayDidChange(width: Double, height: Double) { + val params = Arguments.createMap().apply { + putDouble("width", width) + putDouble("height", height) + } + delegate.sendOnDisplayChanged(params) + } + companion object { const val NAME = "OrientationEventManager" } diff --git a/android/src/main/java/com/orientationdirector/implementation/EventManagerDelegate.kt b/android/src/main/java/com/orientationdirector/implementation/EventManagerDelegate.kt index 1f11c17..484bbbd 100644 --- a/android/src/main/java/com/orientationdirector/implementation/EventManagerDelegate.kt +++ b/android/src/main/java/com/orientationdirector/implementation/EventManagerDelegate.kt @@ -6,4 +6,5 @@ fun sendOnDeviceOrientationChanged(params: WritableMap) fun sendOnInterfaceOrientationChanged(params: WritableMap) fun sendOnLockChanged(params: WritableMap) + fun sendOnDisplayChanged(params: WritableMap) } diff --git a/android/src/main/java/com/orientationdirector/implementation/OrientationDirectorModuleImpl.kt b/android/src/main/java/com/orientationdirector/implementation/OrientationDirectorModuleImpl.kt index 27cd29b..c39a88c 100644 --- a/android/src/main/java/com/orientationdirector/implementation/OrientationDirectorModuleImpl.kt +++ b/android/src/main/java/com/orientationdirector/implementation/OrientationDirectorModuleImpl.kt @@ -16,6 +16,7 @@ class OrientationDirectorModuleImpl internal constructor(private val context: Re ) private var mLifecycleListener = LifecycleListener() private var mBroadcastReceiver = ConfigurationChangedBroadcastReceiver(context) + private var mDisplayChangesListener = DisplayChangesListener(context) private var initialSupportedInterfaceOrientations = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED private var lastInterfaceOrientation = Orientation.UNKNOWN @@ -36,6 +37,10 @@ class OrientationDirectorModuleImpl internal constructor(private val context: Re checkInterfaceOrientation(false) } + mDisplayChangesListener.setOnDisplayChangedCallback { width, height -> + mEventManager.sendDisplayDidChange(width, height) + } + // NOTE(1.init): This is needed to compute the initial device orientation mOrientationSensorsEventListener.enable() @@ -46,6 +51,7 @@ class OrientationDirectorModuleImpl internal constructor(private val context: Re } mAutoRotationObserver.enable() mBroadcastReceiver.register() + mDisplayChangesListener.register() } mLifecycleListener.setOnHostPauseCallback { if (!didComputeInitialDeviceOrientation || areOrientationSensorsEnabled) { @@ -53,6 +59,7 @@ class OrientationDirectorModuleImpl internal constructor(private val context: Re } mAutoRotationObserver.disable() mBroadcastReceiver.unregister() + mDisplayChangesListener.unregister() } mLifecycleListener.setOnHostDestroyCallback { if (!didComputeInitialDeviceOrientation || areOrientationSensorsEnabled) { @@ -60,6 +67,7 @@ class OrientationDirectorModuleImpl internal constructor(private val context: Re } mAutoRotationObserver.disable() mBroadcastReceiver.unregister() + mDisplayChangesListener.unregister() } initialSupportedInterfaceOrientations = diff --git a/example/src/screens/Explore.tsx b/example/src/screens/Explore.tsx index ba90ab3..d1cfc4a 100644 --- a/example/src/screens/Explore.tsx +++ b/example/src/screens/Explore.tsx @@ -1,3 +1,4 @@ +import { useEffect, useState } from 'react'; import { Button, Text, View } from 'react-native'; import { exploreStyle } from './styles'; import RNOrientationDirector, { @@ -13,6 +14,16 @@ function Explore() { const interfaceOrientation = useInterfaceOrientation(); const deviceOrientation = useDeviceOrientation(); const isInterfaceOrientationLocked = useIsInterfaceOrientationLocked(); + const [lastDisplayChange, setLastDisplayChange] = useState('None'); + + useEffect(() => { + const subscription = RNOrientationDirector.listenForDisplayChanges( + ({ width, height }) => { + setLastDisplayChange(`${Math.round(width)}x${Math.round(height)}`); + } + ); + return () => subscription.remove(); + }, []); const handleGoToInnerExploreOnPress = () => { navigation.navigate('InnerExplore' as never); @@ -44,6 +55,9 @@ function Explore() { Is Interface Orientation Locked: {isInterfaceOrientationLocked ? 'Yes' : 'No'} + + Last Display Change:{lastDisplayChange} + ); diff --git a/ios/OrientationDirector.mm b/ios/OrientationDirector.mm index 5d02375..116752b 100644 --- a/ios/OrientationDirector.mm +++ b/ios/OrientationDirector.mm @@ -69,6 +69,14 @@ -(void)emitOnLockChangedWithParams:(NSDictionary*)params { } } +-(void)emitDisplayChangedWithParams:(NSDictionary*)params { + try { + [self emitOnDisplayChanged:params]; + } catch (std::exception &e) { + // Ignore if no listeners + } +} + /// /////////////////////////////////////////////////////////////////////////////////////// diff --git a/ios/implementation/EventManager.swift b/ios/implementation/EventManager.swift index 486712a..6e75b97 100644 --- a/ios/implementation/EventManager.swift +++ b/ios/implementation/EventManager.swift @@ -36,10 +36,20 @@ public class EventManager: NSObject { let params = Dictionary(dictionaryLiteral: ("locked", value)) delegate.emitOnLockChanged(params: params as NSDictionary) } + + func sendDisplayDidChange(size: CGSize) { + guard let delegate = delegate else { + return + } + + let params: [String: Any] = ["width": size.width, "height": size.height] + delegate.emitDisplayChanged(params: params as NSDictionary) + } } @objc public protocol OrientationEventEmitterDelegate { func emitOnLockChanged(params: NSDictionary) func emitDeviceOrientationChanged(params: NSDictionary) func emitInterfaceOrientationChanged(params: NSDictionary) + func emitDisplayChanged(params: NSDictionary) } diff --git a/ios/implementation/OrientationDirectorImpl.swift b/ios/implementation/OrientationDirectorImpl.swift index 238bcee..5c07064 100644 --- a/ios/implementation/OrientationDirectorImpl.swift +++ b/ios/implementation/OrientationDirectorImpl.swift @@ -21,6 +21,11 @@ import UIKit private var lastDeviceOrientation = Orientation.UNKNOWN private var isLocked = false + /// # Only on iOS >= 16 + /// The screen the scene was on at the last geometry change, + /// used to detect when the app moves to another display (e.g. fold / unfold). + private weak var lastScreen: UIScreen? + /// # Only on iOS < 16 /// This variable is needed to prevent a loop where /// we lock the interface to a specific orientation @@ -218,6 +223,8 @@ import UIKit /// interface orientations are ignored (e.g. iPhone Duo inner display) and /// when the scene moves to another screen (fold / unfold). private func onSceneGeometryChanged(scene: UIWindowScene) { + checkDisplay(of: scene) + let interfaceOrientation = utils.getInterfaceOrientation(scene: scene) if interfaceOrientation == .unknown { return @@ -227,6 +234,24 @@ import UIKit updateLastInterfaceOrientationTo(value: newInterfaceOrientation) } + private func checkDisplay(of scene: UIWindowScene) { + let screen = scene.screen + + let previousScreen = lastScreen + lastScreen = screen + + // First sync, nothing changed yet + guard let previousScreen = previousScreen else { + return + } + + if previousScreen === screen { + return + } + + eventManager.sendDisplayDidChange(size: screen.bounds.size) + } + /// # Only on iOS < 16 private func adaptInterfaceTo(deviceOrientation: Orientation) { let supportsLandscape = self.supportedInterfaceOrientations.contains(.landscape) diff --git a/src/EventEmitter.ts b/src/EventEmitter.ts index a827218..a19824d 100644 --- a/src/EventEmitter.ts +++ b/src/EventEmitter.ts @@ -2,6 +2,7 @@ import { type EventSubscription, Platform } from 'react-native'; import NativeOrientationDirector from './NativeOrientationDirector'; import type { OrientationEvent } from './types/OrientationEvent.interface'; import type { LockedEvent } from './types/LockedEvent.interface'; +import type { DisplayChangedEvent } from './types/DisplayChangedEvent.interface'; class EventEmitter { private static androidListenerCount = 0; @@ -34,6 +35,12 @@ class EventEmitter { return NativeOrientationDirector.onLockChanged(callback); } + static addDisplayDidChangeListener( + callback: (event: DisplayChangedEvent) => void + ) { + return NativeOrientationDirector.onDisplayChanged(callback); + } + private static createDeviceOrientationListenerProxy( listener: EventSubscription ) { diff --git a/src/NativeOrientationDirector.ts b/src/NativeOrientationDirector.ts index eb87b8a..14e72b7 100644 --- a/src/NativeOrientationDirector.ts +++ b/src/NativeOrientationDirector.ts @@ -28,6 +28,10 @@ export interface Spec extends TurboModule { orientation: number; }>; readonly onLockChanged: CodegenTypes.EventEmitter<{ locked: boolean }>; + readonly onDisplayChanged: CodegenTypes.EventEmitter<{ + width: number; + height: number; + }>; } export default TurboModuleRegistry.getEnforcing('OrientationDirector'); diff --git a/src/RNOrientationDirector.ts b/src/RNOrientationDirector.ts index 26a8148..ae9eee0 100644 --- a/src/RNOrientationDirector.ts +++ b/src/RNOrientationDirector.ts @@ -7,6 +7,7 @@ import { OrientationType } from './types/OrientationType.enum'; import type { OrientationEvent } from './types/OrientationEvent.interface'; import type { LockableOrientation } from './types/LockableOrientation.type'; import type { LockedEvent } from './types/LockedEvent.interface'; +import type { DisplayChangedEvent } from './types/DisplayChangedEvent.interface'; import type { HumanReadableAutoRotationsResource } from './types/HumanReadableAutoRotationsResource.type'; import EventEmitter from './EventEmitter'; @@ -123,6 +124,19 @@ class RNOrientationDirector { return EventEmitter.addLockDidChangeListener(callback); } + /** + * Triggers the provided callback each time the app moves to a different + * physical display, e.g. when a foldable device is folded or unfolded. + * It is not triggered by rotations or by window resizes (split view, multi-window). + * + * @param callback invoked with the new display size + */ + static listenForDisplayChanges( + callback: (event: DisplayChangedEvent) => void + ) { + return EventEmitter.addDisplayDidChangeListener(callback); + } + static convertOrientationToHumanReadableString(orientation: Orientation) { return RNOrientationDirector._humanReadableOrientationsResource[ orientation diff --git a/src/types/DisplayChangedEvent.interface.ts b/src/types/DisplayChangedEvent.interface.ts new file mode 100644 index 0000000..c99b06d --- /dev/null +++ b/src/types/DisplayChangedEvent.interface.ts @@ -0,0 +1,12 @@ +export interface DisplayChangedEvent { + /** + * Width of the new display, in points (iOS) / dp (Android), + * in the orientation it is currently displayed. + */ + width: number; + /** + * Height of the new display, in points (iOS) / dp (Android), + * in the orientation it is currently displayed. + */ + height: number; +} From 3683270e2015a4f6c2e8909dca2c1d0ebec7a3bf Mon Sep 17 00:00:00 2001 From: gladiuscode Date: Tue, 29 Sep 2026 23:13:14 +0200 Subject: [PATCH 9/9] docs: document listenForDisplayChanges Co-Authored-By: Claude Sonnet 5.5 --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 358d33c..f25f1d1 100644 --- a/README.md +++ b/README.md @@ -162,6 +162,7 @@ This library exports a class called: [RNOrientationDirector](https://github.com/ | listenForDeviceOrientationChanges | Triggers a provided callback each time the device orientation changes | | listenForInterfaceOrientationChanges | Triggers a provided callback each time the interface orientation changes | | listenForLockChanges | Triggers a provided callback each time the interface orientation status changes | +| listenForDisplayChanges | Triggers a provided callback each time the app moves to a different display | | convertOrientationToHumanReadableString | Returns a human readable string based on the given orientation | | convertAutoRotationToHumanReadableString | Returns a human readable string based on the given auto rotation | | setHumanReadableOrientations | Sets the mapping needed to convert orientation values to human readable strings | @@ -220,6 +221,9 @@ On foldable devices the app moves between the outer and the inner display when t outer one. `isLocked` still reports whether a lock has been requested, and the interface orientation always reports the orientation actually displayed. +To be notified when the app moves to another display (e.g. fold / unfold), use `listenForDisplayChanges`: the +orientation values alone might not change in that case. + ### Android Since on Android we need to deal with sensors and their usage, it is worth noting that the device orientation computation works