Skip to content

Commit 9c93d94

Browse files
authored
fix(android): interface orientation listener not working with manual rotation (#59)
1 parent e945e9a commit 9c93d94

File tree

10 files changed

+316
-18
lines changed

10 files changed

+316
-18
lines changed

README.md

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -64,12 +64,44 @@ Then, you need to add the plugin to your app.json file:
6464

6565
This way, Expo will handle the native setup for you during `prebuild`.
6666

67+
> Note: only SDK 50 and above are supported, the plugin is configured to handle only the kotlin template.
68+
6769
## Setup
6870

71+
### Android
72+
73+
This library uses a custom broadcast receiver to handle the manual orientation changes: when the user disables the
74+
autorotation feature and the system prompts the user to rotate the device, the library will listen to the broadcast
75+
sent by the MainActivity and update the interface orientation accordingly.
76+
77+
To allow the library to listen to the broadcast, you need to override the `onConfigurationChanged` method in your
78+
MainActivity file, as shown below:
79+
80+
```kotlin
81+
override fun onConfigurationChanged(newConfig: Configuration) {
82+
super.onConfigurationChanged(newConfig)
83+
84+
val orientationDirectorCustomAction =
85+
"${packageName}.${ConfigurationChangedBroadcastReceiver.CUSTOM_INTENT_ACTION}"
86+
87+
val intent =
88+
Intent(orientationDirectorCustomAction).apply {
89+
putExtra("newConfig", newConfig)
90+
setPackage(packageName)
91+
}
92+
93+
this.sendBroadcast(intent)
94+
}
95+
```
96+
97+
Nothing else is required for Android.
98+
99+
### iOS
100+
69101
To properly handle interface orientation changes in iOS, you need to update your AppDelegate file. Since React Native
70102
0.77, the AppDelegate has been migrated to Swift, so see the instructions below for both Swift and Objective-C.
71103

72-
### Objective-C
104+
#### Objective-C
73105

74106
In your AppDelegate.h file, import "OrientationDirector.h" and implement supportedInterfaceOrientationsForWindow method as follows:
75107

@@ -82,7 +114,7 @@ In your AppDelegate.h file, import "OrientationDirector.h" and implement support
82114
}
83115
```
84116

85-
### Swift
117+
#### Swift
86118

87119
You need to create a [bridging header](https://developer.apple.com/documentation/swift/importing-objective-c-into-swift#Import-Code-Within-an-App-Target)
88120
to import the library, as shown below:
@@ -101,8 +133,6 @@ override func application(_ application: UIApplication, supportedInterfaceOrient
101133

102134
If you need help, you can check the example project.
103135

104-
There is no need to do anything in Android, it works out of the box.
105-
106136
## Usage
107137

108138
This library exports a class called: [RNOrientationDirector](https://github.com/gladiuscode/react-native-orientation-director/blob/main/src/RNOrientationDirector.ts) that exposes the following methods:
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
package com.orientationdirector.implementation
2+
3+
import android.content.BroadcastReceiver
4+
import android.content.Context
5+
import android.content.Intent
6+
import android.content.IntentFilter
7+
import android.os.Build
8+
import com.facebook.react.bridge.ReactApplicationContext
9+
10+
/**
11+
* This custom broadcast receiver is needed to properly update the interface orientation when
12+
* the user has disabled the automatic rotation.
13+
*
14+
* It listens for an explicit intent that the MainActivity can send in the onConfigurationChanged
15+
* method and calls a custom callback that is set in the main implementation init
16+
*/
17+
class ConfigurationChangedBroadcastReceiver internal constructor(private val context: ReactApplicationContext) :
18+
BroadcastReceiver() {
19+
20+
private var onReceiveCallback: ((intent: Intent?) -> Unit)? = null
21+
22+
override fun onReceive(context: Context?, intent: Intent?) {
23+
this.onReceiveCallback?.invoke(intent)
24+
}
25+
26+
fun setOnReceiveCallback(callback: (intent: Intent?) -> Unit) {
27+
onReceiveCallback = callback
28+
}
29+
30+
/**
31+
* This method registers the receiver by checking the api we are currently running with.
32+
* With the latest changes in Android 14, we need to explicitly set the `Context.RECEIVER_NOT_EXPORTED`
33+
* flag.
34+
*/
35+
fun register() {
36+
val filter = IntentFilter("${context.packageName}.$CUSTOM_INTENT_ACTION")
37+
38+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
39+
context.registerReceiver(this, filter, Context.RECEIVER_NOT_EXPORTED)
40+
} else {
41+
context.registerReceiver(this, filter)
42+
}
43+
}
44+
45+
fun unregister() {
46+
context.unregisterReceiver(this)
47+
}
48+
49+
companion object {
50+
const val CUSTOM_INTENT_ACTION = "CONFIGURATION_CHANGED"
51+
}
52+
}

android/src/main/java/com/orientationdirector/implementation/OrientationDirectorModuleImpl.kt

Lines changed: 23 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ class OrientationDirectorModuleImpl internal constructor(private val context: Re
1515
)
1616
)
1717
private var mLifecycleListener = LifecycleListener()
18+
private var mBroadcastReceiver = ConfigurationChangedBroadcastReceiver(context)
1819

1920
private var initialSupportedInterfaceOrientations = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
2021
private var lastInterfaceOrientation = Orientation.UNKNOWN
@@ -31,24 +32,31 @@ class OrientationDirectorModuleImpl internal constructor(private val context: Re
3132

3233
mAutoRotationObserver.enable()
3334

35+
mBroadcastReceiver.setOnReceiveCallback {
36+
adaptInterfaceTo(lastDeviceOrientation, false)
37+
}
38+
3439
context.addLifecycleEventListener(mLifecycleListener)
3540
mLifecycleListener.setOnHostResumeCallback {
3641
if (!didComputeInitialDeviceOrientation || areOrientationSensorsEnabled) {
3742
mOrientationSensorsEventListener.enable()
3843
}
3944
mAutoRotationObserver.enable()
45+
mBroadcastReceiver.register()
4046
}
4147
mLifecycleListener.setOnHostPauseCallback {
4248
if (initialized && areOrientationSensorsEnabled) {
4349
mOrientationSensorsEventListener.disable()
4450
mAutoRotationObserver.disable()
4551
}
52+
mBroadcastReceiver.unregister()
4653
}
4754
mLifecycleListener.setOnHostDestroyCallback {
4855
if (areOrientationSensorsEnabled) {
4956
mOrientationSensorsEventListener.disable()
5057
mAutoRotationObserver.disable()
5158
}
59+
mBroadcastReceiver.unregister()
5260
}
5361

5462
initialSupportedInterfaceOrientations =
@@ -89,15 +97,16 @@ class OrientationDirectorModuleImpl internal constructor(private val context: Re
8997
return
9098
}
9199

92-
val lastInterfaceOrientationIsAlreadyInLandscape = lastInterfaceOrientation == Orientation.LANDSCAPE_RIGHT
93-
|| lastInterfaceOrientation == Orientation.LANDSCAPE_LEFT
94-
if (lastInterfaceOrientationIsAlreadyInLandscape) {
95-
updateLastInterfaceOrientationTo(lastInterfaceOrientation)
96-
return;
97-
}
100+
val lastInterfaceOrientationIsAlreadyInLandscape =
101+
lastInterfaceOrientation == Orientation.LANDSCAPE_RIGHT
102+
|| lastInterfaceOrientation == Orientation.LANDSCAPE_LEFT
103+
if (lastInterfaceOrientationIsAlreadyInLandscape) {
104+
updateLastInterfaceOrientationTo(lastInterfaceOrientation)
105+
return;
106+
}
98107

99108
val systemDefaultLandscapeOrientation = Orientation.LANDSCAPE_RIGHT
100-
updateLastInterfaceOrientationTo(systemDefaultLandscapeOrientation)
109+
updateLastInterfaceOrientationTo(systemDefaultLandscapeOrientation)
101110
}
102111

103112
fun unlock() {
@@ -160,12 +169,13 @@ class OrientationDirectorModuleImpl internal constructor(private val context: Re
160169
}
161170
}
162171

163-
private fun adaptInterfaceTo(deviceOrientation: Orientation) {
164-
if (!mAutoRotationObserver.getLastAutoRotationStatus()) {
172+
private fun adaptInterfaceTo(deviceOrientation: Orientation, checkLastAutoRotationStatus: Boolean = true) {
173+
if (checkLastAutoRotationStatus && !mAutoRotationObserver.getLastAutoRotationStatus()) {
165174
return
166175
}
167176

168-
val supportsLandscape = mUtils.getRequestedOrientation() == ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE;
177+
val supportsLandscape =
178+
mUtils.getRequestedOrientation() == ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE;
169179
if (isLocked && !supportsLandscape) {
170180
return
171181
}
@@ -191,8 +201,9 @@ class OrientationDirectorModuleImpl internal constructor(private val context: Re
191201
* Instead, we check that its value is either LANDSCAPE_RIGHT or LANDSCAPE_LEFT, otherwise we
192202
* exit
193203
*/
194-
val newInterfaceOrientationIsNotLandscape = newInterfaceOrientation != Orientation.LANDSCAPE_RIGHT
195-
&& newInterfaceOrientation != Orientation.LANDSCAPE_LEFT;
204+
val newInterfaceOrientationIsNotLandscape =
205+
newInterfaceOrientation != Orientation.LANDSCAPE_RIGHT
206+
&& newInterfaceOrientation != Orientation.LANDSCAPE_LEFT;
196207
if (supportsLandscape && newInterfaceOrientationIsNotLandscape) {
197208
return
198209
}

example/android/app/src/main/AndroidManifest.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
android:roundIcon="@mipmap/ic_launcher_round"
1010
android:allowBackup="false"
1111
android:theme="@style/AppTheme"
12-
android:supportsRtl="true">>
12+
android:supportsRtl="true">
1313
<activity
1414
android:name=".MainActivity"
1515
android:label="@string/app_name"

example/android/app/src/main/java/com/orientationdirectorexample/MainActivity.kt

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
package com.orientationdirectorexample
22

3+
import android.content.Intent
4+
import android.content.res.Configuration
35
import android.os.Bundle
46
import com.facebook.react.ReactActivity
57
import com.facebook.react.ReactActivityDelegate
68
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled
79
import com.facebook.react.defaults.DefaultReactActivityDelegate
10+
import com.orientationdirector.implementation.ConfigurationChangedBroadcastReceiver
811

912
class MainActivity : ReactActivity() {
1013

@@ -19,9 +22,24 @@ class MainActivity : ReactActivity() {
1922
* which allows you to enable New Architecture with a single boolean flags [fabricEnabled]
2023
*/
2124
override fun createReactActivityDelegate(): ReactActivityDelegate =
22-
DefaultReactActivityDelegate(this, mainComponentName, fabricEnabled)
25+
DefaultReactActivityDelegate(this, mainComponentName, fabricEnabled)
2326

2427
override fun onCreate(savedInstanceState: Bundle?) {
2528
super.onCreate(null)
2629
}
30+
31+
override fun onConfigurationChanged(newConfig: Configuration) {
32+
super.onConfigurationChanged(newConfig)
33+
34+
val orientationDirectorCustomAction =
35+
"${packageName}.${ConfigurationChangedBroadcastReceiver.CUSTOM_INTENT_ACTION}"
36+
37+
val intent =
38+
Intent(orientationDirectorCustomAction).apply {
39+
putExtra("newConfig", newConfig)
40+
setPackage(packageName)
41+
}
42+
43+
this.sendBroadcast(intent)
44+
}
2745
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
// Jest Snapshot v1, https://goo.gl/fbAQLP
2+
3+
exports[`withRNOrientationMainActivity updates the MainActivity.kt with both import and method implementation 1`] = `
4+
"package com.orientationdirectorexample
5+
6+
import android.content.Intent
7+
import android.content.res.Configuration
8+
import android.os.Bundle
9+
import com.facebook.react.ReactActivity
10+
import com.facebook.react.ReactActivityDelegate
11+
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled
12+
import com.facebook.react.defaults.DefaultReactActivityDelegate
13+
14+
// React Native Orientation Director @generated begin @react-native-orientation-director/library-import - expo prebuild (DO NOT MODIFY) sync-dd77fee7fe624fed474053ea60c3105920a01a6a
15+
import com.orientationdirector.implementation.ConfigurationChangedBroadcastReceiver
16+
17+
// React Native Orientation Director @generated end @react-native-orientation-director/library-import
18+
class MainActivity : ReactActivity() {
19+
20+
/**
21+
* Returns the name of the main component registered from JavaScript. This is used to schedule
22+
* rendering of the component.
23+
*/
24+
override fun getMainComponentName(): String = "OrientationDirectorExample"
25+
26+
/**
27+
* Returns the instance of the [ReactActivityDelegate]. We use [DefaultReactActivityDelegate]
28+
* which allows you to enable New Architecture with a single boolean flags [fabricEnabled]
29+
*/
30+
override fun createReactActivityDelegate(): ReactActivityDelegate =
31+
DefaultReactActivityDelegate(this, mainComponentName, fabricEnabled)
32+
33+
override fun onCreate(savedInstanceState: Bundle?) {
34+
super.onCreate(null)
35+
}
36+
// React Native Orientation Director @generated begin @react-native-orientation-director/supportedInterfaceOrientationsFor-implementation - expo prebuild (DO NOT MODIFY) sync-7a5cdf10057b2ddf1bcf4593bf408862cbed5473
37+
38+
override fun onConfigurationChanged(newConfig: Configuration) {
39+
super.onConfigurationChanged(newConfig)
40+
41+
val orientationDirectorCustomAction =
42+
packageName + "." + ConfigurationChangedBroadcastReceiver.CUSTOM_INTENT_ACTION
43+
44+
val intent =
45+
Intent(orientationDirectorCustomAction).apply {
46+
putExtra("newConfig", newConfig)
47+
setPackage(packageName)
48+
}
49+
50+
this.sendBroadcast(intent)
51+
}
52+
53+
// React Native Orientation Director @generated end @react-native-orientation-director/supportedInterfaceOrientationsFor-implementation
54+
55+
}
56+
"
57+
`;
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
package com.orientationdirectorexample
2+
3+
import android.content.Intent
4+
import android.content.res.Configuration
5+
import android.os.Bundle
6+
import com.facebook.react.ReactActivity
7+
import com.facebook.react.ReactActivityDelegate
8+
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled
9+
import com.facebook.react.defaults.DefaultReactActivityDelegate
10+
11+
class MainActivity : ReactActivity() {
12+
13+
/**
14+
* Returns the name of the main component registered from JavaScript. This is used to schedule
15+
* rendering of the component.
16+
*/
17+
override fun getMainComponentName(): String = "OrientationDirectorExample"
18+
19+
/**
20+
* Returns the instance of the [ReactActivityDelegate]. We use [DefaultReactActivityDelegate]
21+
* which allows you to enable New Architecture with a single boolean flags [fabricEnabled]
22+
*/
23+
override fun createReactActivityDelegate(): ReactActivityDelegate =
24+
DefaultReactActivityDelegate(this, mainComponentName, fabricEnabled)
25+
26+
override fun onCreate(savedInstanceState: Bundle?) {
27+
super.onCreate(null)
28+
}
29+
30+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import * as fs from 'node:fs';
2+
import * as path from 'node:path';
3+
4+
import { ktFileUpdater } from '../src/withRNOrientationMainActivity';
5+
6+
describe('withRNOrientationMainActivity', function () {
7+
beforeEach(function () {
8+
jest.resetAllMocks();
9+
});
10+
11+
it('updates the MainActivity.kt with both import and method implementation', async function () {
12+
const mainActivityPath = path.join(__dirname, './fixtures/MainActivity.kt');
13+
const mainActivity = await fs.promises.readFile(mainActivityPath, 'utf-8');
14+
15+
const result = ktFileUpdater(mainActivity);
16+
expect(result).toMatchSnapshot();
17+
});
18+
});

plugin/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
import { withAppBridgingHeaderMod } from './custom-mod/withBridgingHeader';
88
import { withRNOrientationAppDelegate } from './withRNOrientationAppDelegate';
99
import { withRNOrientationBridgingHeader } from './withRNOrientationBridgingHeader';
10+
import { withRNOrientationMainActivity } from './withRNOrientationMainActivity';
1011

1112
/**
1213
* So, expo config plugin are awesome and the documentation is well written, but I still needed to look around to see
@@ -22,6 +23,7 @@ const withRNOrientationDirector: ConfigPlugin = (config) => {
2223
return withPlugins(config, [
2324
withRNOrientationAppDelegate,
2425
withRNOrientationBridgingHeader,
26+
withRNOrientationMainActivity,
2527
withAppBridgingHeaderMod,
2628
]);
2729
};

0 commit comments

Comments
 (0)