diff --git a/README.md b/README.md index d0b66b4..358d33c 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,27 @@ 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 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 + reports the orientation actually displayed. + ### Android Since on Android we need to deal with sensors and their usage, it is worth noting that the device orientation computation works diff --git a/ios/implementation/OrientationDirectorImpl.swift b/ios/implementation/OrientationDirectorImpl.swift index e47d55b..238bcee 100644 --- a/ios/implementation/OrientationDirectorImpl.swift +++ b/ios/implementation/OrientationDirectorImpl.swift @@ -14,6 +14,7 @@ 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 @@ -40,6 +41,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() + } } /////////////////////////////////////////////////////////////////////////////////////// @@ -70,6 +78,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) @@ -93,6 +109,11 @@ import UIKit self.requestInterfaceUpdateTo(mask: UIInterfaceOrientationMask.all) updateIsLockedTo(value: false) + + if #available(iOS 16.0, *) { + return + } + self.adaptInterfaceTo(deviceOrientation: lastDeviceOrientation) } @@ -101,6 +122,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 { @@ -174,15 +201,33 @@ import UIKit private func onOrientationChanged(uiDeviceOrientation: UIDeviceOrientation) { let deviceOrientation = utils.convertToOrientationFrom(deviceOrientation: uiDeviceOrientation) + 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(scene: UIWindowScene) { + let interfaceOrientation = utils.getInterfaceOrientation(scene: scene) + 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,8 +247,24 @@ 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 } diff --git a/ios/implementation/SceneGeometryListener.swift b/ios/implementation/SceneGeometryListener.swift new file mode 100644 index 0000000..df28bba --- /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: ((UIWindowScene) -> 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 (UIWindowScene) -> 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] scene, _ in + self?.notifyGeometryDidChange(scene: scene) + } + + notifyGeometryDidChange(scene: scene) + } + + private func notifyGeometryDidChange(scene: UIWindowScene) { + guard Thread.isMainThread else { + DispatchQueue.main.async { [weak self] in + self?.notifyGeometryDidChange(scene: scene) + } + return + } + + onGeometryDidChangeCallback?(scene) + } +} diff --git a/ios/implementation/Utils.swift b/ios/implementation/Utils.swift index 65b3419..db063d3 100644 --- a/ios/implementation/Utils.swift +++ b/ios/implementation/Utils.swift @@ -93,11 +93,15 @@ 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 } + if #available(iOS 16.0, *) { + return windowScene.effectiveGeometry.interfaceOrientation + } + return windowScene.interfaceOrientation } @@ -110,6 +114,7 @@ class Utils { .shared .connectedScenes .compactMap { $0 as? UIWindowScene } + .filter { $0.session.role == .windowApplication } .flatMap { $0.windows } .last { $0.isKeyWindow } }