Skip to content
Open
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
36 changes: 36 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -184,6 +199,27 @@ documentation:
2. [iOS - UIDeviceOrientation](https://developer.apple.com/documentation/uikit/uideviceorientation)
3. [Android - getRotation](<https://developer.android.com/reference/android/view/Display#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
Expand Down
69 changes: 65 additions & 4 deletions ios/implementation/OrientationDirectorImpl.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()
}
}

///////////////////////////////////////////////////////////////////////////////////////
Expand Down Expand Up @@ -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)
Expand All @@ -93,6 +109,11 @@ import UIKit
self.requestInterfaceUpdateTo(mask: UIInterfaceOrientationMask.all)

updateIsLockedTo(value: false)

if #available(iOS 16.0, *) {
return
}

self.adaptInterfaceTo(deviceOrientation: lastDeviceOrientation)
}

Expand All @@ -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 {
Expand Down Expand Up @@ -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 {
Expand All @@ -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
}

Expand Down
88 changes: 88 additions & 0 deletions ios/implementation/SceneGeometryListener.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
9 changes: 7 additions & 2 deletions ios/implementation/Utils.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand All @@ -110,6 +114,7 @@ class Utils {
.shared
.connectedScenes
.compactMap { $0 as? UIWindowScene }
.filter { $0.session.role == .windowApplication }
.flatMap { $0.windows }
.last { $0.isKeyWindow }
}
Expand Down