Skip to content
Merged
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
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -176,6 +177,7 @@ In addition, the library exposes the following hooks:
| [useInterfaceOrientation](https://github.com/gladiuscode/react-native-orientation-director/blob/main/src/hooks/useInterfaceOrientation.hook.ts) | Returns the current interface orientation and listens to changes |
| [useDeviceOrientation](https://github.com/gladiuscode/react-native-orientation-director/blob/main/src/hooks/useDeviceOrientation.hook.ts) | Returns the current device orientation and listens to changes |
| [useIsInterfaceOrientationLocked](https://github.com/gladiuscode/react-native-orientation-director/blob/main/src/hooks/useIsInterfaceOrientationLocked.hook.ts) | Returns the current interface orientation status and listens to changes |
| [useDisplay](https://github.com/gladiuscode/react-native-orientation-director/blob/main/src/hooks/useDisplay.hook.ts) | Returns the size of the current display and listens to display changes |

Head over to the [example project](example) to see how to use the library.

Expand Down Expand Up @@ -220,6 +222,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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
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 size in dp,
* e.g. when a foldable device is folded or unfolded.
*
* DisplayManager notifies any display change, rotations included, so the
* size is compared regardless of its orientation. It is compared in dp so that
* density changes with the same pixel size are notified as well.
*/
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 lastSizeDp: Pair<Double, Double>? = 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)
Comment thread
gladiuscode marked this conversation as resolved.
Resolved
isRegistered = true

// The display might have changed while the listener was unregistered
sync()
}

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) {
sync()
}

/**
* Compares the current display with the last known one.
* It is also needed when the activity moves between existing displays,
* as DisplayManager doesn't notify it since no display has changed.
*/
fun sync() {
val size = computeDisplaySize() ?: return
val density = computeDensity()
val widthDp = size.x / density
val heightDp = size.y / density
val sizeDp = Pair(minOf(widthDp, heightDp), maxOf(widthDp, heightDp))

val previousSizeDp = lastSizeDp
lastSizeDp = sizeDp

// First sync, nothing changed yet
if (previousSizeDp == null || previousSizeDp == sizeDp) {
return
}

onDisplayChangedCallback?.invoke(widthDp, heightDp)
}

/**
* 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())
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}

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()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,5 @@
fun sendOnDeviceOrientationChanged(params: WritableMap)
fun sendOnInterfaceOrientationChanged(params: WritableMap)
fun sendOnLockChanged(params: WritableMap)
fun sendOnDisplayChanged(params: WritableMap)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -34,6 +35,11 @@ class OrientationDirectorModuleImpl internal constructor(private val context: Re

mBroadcastReceiver.setOnReceiveCallback {
checkInterfaceOrientation(false)
mDisplayChangesListener.sync()
}

mDisplayChangesListener.setOnDisplayChangedCallback { width, height ->
mEventManager.sendDisplayDidChange(width, height)
}

// NOTE(1.init): This is needed to compute the initial device orientation
Expand All @@ -46,20 +52,23 @@ class OrientationDirectorModuleImpl internal constructor(private val context: Re
}
mAutoRotationObserver.enable()
mBroadcastReceiver.register()
mDisplayChangesListener.register()
}
mLifecycleListener.setOnHostPauseCallback {
if (!didComputeInitialDeviceOrientation || areOrientationSensorsEnabled) {
mOrientationSensorsEventListener.disable()
}
mAutoRotationObserver.disable()
mBroadcastReceiver.unregister()
mDisplayChangesListener.unregister()
}
mLifecycleListener.setOnHostDestroyCallback {
if (!didComputeInitialDeviceOrientation || areOrientationSensorsEnabled) {
mOrientationSensorsEventListener.disable()
}
mAutoRotationObserver.disable()
mBroadcastReceiver.unregister()
mDisplayChangesListener.unregister()
}

initialSupportedInterfaceOrientations =
Expand Down
6 changes: 6 additions & 0 deletions example/src/screens/Explore.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Button, Text, View } from 'react-native';
import { exploreStyle } from './styles';
import RNOrientationDirector, {
useDeviceOrientation,
useDisplay,
useInterfaceOrientation,
useIsInterfaceOrientationLocked,
} from 'react-native-orientation-director';
Expand All @@ -13,6 +14,7 @@ function Explore() {
const interfaceOrientation = useInterfaceOrientation();
const deviceOrientation = useDeviceOrientation();
const isInterfaceOrientationLocked = useIsInterfaceOrientationLocked();
const display = useDisplay();

const handleGoToInnerExploreOnPress = () => {
navigation.navigate('InnerExplore' as never);
Expand Down Expand Up @@ -44,6 +46,10 @@ function Explore() {
Is Interface Orientation Locked:
{isInterfaceOrientationLocked ? 'Yes' : 'No'}
</Text>
<Text style={[exploreStyle.text, exploreStyle.marginBottom]}>
Current Display:{Math.round(display.width)}x
{Math.round(display.height)}
</Text>
</View>
</View>
);
Expand Down
8 changes: 8 additions & 0 deletions ios/OrientationDirector.mm
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,14 @@ -(void)emitOnLockChangedWithParams:(NSDictionary*)params {
}
}

-(void)emitDisplayChangedWithParams:(NSDictionary*)params {
try {
[self emitOnDisplayChanged:params];
} catch (std::exception &e) {
// Ignore if no listeners
}
}

///
///////////////////////////////////////////////////////////////////////////////////////

Expand Down
10 changes: 10 additions & 0 deletions ios/implementation/EventManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
25 changes: 25 additions & 0 deletions ios/implementation/OrientationDirectorImpl.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down
7 changes: 7 additions & 0 deletions ios/implementation/SceneGeometryListener.swift
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,13 @@ public class SceneGeometryListener {
}

@objc private func sceneDidActivate(_ notification: Notification) {
/// Apps can have multiple scenes: once the React Native scene is observed,
/// activations of unrelated scenes must not replace it. A new scene is picked
/// only when the observed one is gone, as observedScene is weak.
if observedScene != nil {
return
}

attach(to: notification.object as? UIWindowScene)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}

Expand Down
7 changes: 7 additions & 0 deletions src/EventEmitter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
) {
Expand Down
4 changes: 4 additions & 0 deletions src/NativeOrientationDirector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Spec>('OrientationDirector');
Loading