diff --git a/README.md b/README.md index 5d528c5..a722fe4 100644 --- a/README.md +++ b/README.md @@ -64,12 +64,44 @@ Then, you need to add the plugin to your app.json file: This way, Expo will handle the native setup for you during `prebuild`. +> Note: only SDK 50 and above are supported, the plugin is configured to handle only the kotlin template. + ## Setup +### Android + +This library uses a custom broadcast receiver to handle the manual orientation changes: when the user disables the +autorotation feature and the system prompts the user to rotate the device, the library will listen to the broadcast +sent by the MainActivity and update the interface orientation accordingly. + +To allow the library to listen to the broadcast, you need to override the `onConfigurationChanged` method in your +MainActivity file, as shown below: + +```kotlin +override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + + val orientationDirectorCustomAction = + "${packageName}.${ConfigurationChangedBroadcastReceiver.CUSTOM_INTENT_ACTION}" + + val intent = + Intent(orientationDirectorCustomAction).apply { + putExtra("newConfig", newConfig) + setPackage(packageName) + } + + this.sendBroadcast(intent) +} +``` + +Nothing else is required for Android. + +### iOS + To properly handle interface orientation changes in iOS, you need to update your AppDelegate file. Since React Native 0.77, the AppDelegate has been migrated to Swift, so see the instructions below for both Swift and Objective-C. -### Objective-C +#### Objective-C In your AppDelegate.h file, import "OrientationDirector.h" and implement supportedInterfaceOrientationsForWindow method as follows: @@ -82,7 +114,7 @@ In your AppDelegate.h file, import "OrientationDirector.h" and implement support } ``` -### Swift +#### Swift You need to create a [bridging header](https://developer.apple.com/documentation/swift/importing-objective-c-into-swift#Import-Code-Within-an-App-Target) to import the library, as shown below: @@ -101,8 +133,6 @@ override func application(_ application: UIApplication, supportedInterfaceOrient If you need help, you can check the example project. -There is no need to do anything in Android, it works out of the box. - ## Usage 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: diff --git a/android/src/main/java/com/orientationdirector/implementation/ConfigurationChangedBroadcastReceiver.kt b/android/src/main/java/com/orientationdirector/implementation/ConfigurationChangedBroadcastReceiver.kt new file mode 100644 index 0000000..1b6061e --- /dev/null +++ b/android/src/main/java/com/orientationdirector/implementation/ConfigurationChangedBroadcastReceiver.kt @@ -0,0 +1,52 @@ +package com.orientationdirector.implementation + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.os.Build +import com.facebook.react.bridge.ReactApplicationContext + +/** + * This custom broadcast receiver is needed to properly update the interface orientation when + * the user has disabled the automatic rotation. + * + * It listens for an explicit intent that the MainActivity can send in the onConfigurationChanged + * method and calls a custom callback that is set in the main implementation init + */ +class ConfigurationChangedBroadcastReceiver internal constructor(private val context: ReactApplicationContext) : + BroadcastReceiver() { + + private var onReceiveCallback: ((intent: Intent?) -> Unit)? = null + + override fun onReceive(context: Context?, intent: Intent?) { + this.onReceiveCallback?.invoke(intent) + } + + fun setOnReceiveCallback(callback: (intent: Intent?) -> Unit) { + onReceiveCallback = callback + } + + /** + * This method registers the receiver by checking the api we are currently running with. + * With the latest changes in Android 14, we need to explicitly set the `Context.RECEIVER_NOT_EXPORTED` + * flag. + */ + fun register() { + val filter = IntentFilter("${context.packageName}.$CUSTOM_INTENT_ACTION") + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + context.registerReceiver(this, filter, Context.RECEIVER_NOT_EXPORTED) + } else { + context.registerReceiver(this, filter) + } + } + + fun unregister() { + context.unregisterReceiver(this) + } + + companion object { + const val CUSTOM_INTENT_ACTION = "CONFIGURATION_CHANGED" + } +} diff --git a/android/src/main/java/com/orientationdirector/implementation/OrientationDirectorModuleImpl.kt b/android/src/main/java/com/orientationdirector/implementation/OrientationDirectorModuleImpl.kt index d121fe7..1e4dc3b 100644 --- a/android/src/main/java/com/orientationdirector/implementation/OrientationDirectorModuleImpl.kt +++ b/android/src/main/java/com/orientationdirector/implementation/OrientationDirectorModuleImpl.kt @@ -15,6 +15,7 @@ class OrientationDirectorModuleImpl internal constructor(private val context: Re ) ) private var mLifecycleListener = LifecycleListener() + private var mBroadcastReceiver = ConfigurationChangedBroadcastReceiver(context) private var initialSupportedInterfaceOrientations = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED private var lastInterfaceOrientation = Orientation.UNKNOWN @@ -31,24 +32,31 @@ class OrientationDirectorModuleImpl internal constructor(private val context: Re mAutoRotationObserver.enable() + mBroadcastReceiver.setOnReceiveCallback { + adaptInterfaceTo(lastDeviceOrientation, false) + } + context.addLifecycleEventListener(mLifecycleListener) mLifecycleListener.setOnHostResumeCallback { if (!didComputeInitialDeviceOrientation || areOrientationSensorsEnabled) { mOrientationSensorsEventListener.enable() } mAutoRotationObserver.enable() + mBroadcastReceiver.register() } mLifecycleListener.setOnHostPauseCallback { if (initialized && areOrientationSensorsEnabled) { mOrientationSensorsEventListener.disable() mAutoRotationObserver.disable() } + mBroadcastReceiver.unregister() } mLifecycleListener.setOnHostDestroyCallback { if (areOrientationSensorsEnabled) { mOrientationSensorsEventListener.disable() mAutoRotationObserver.disable() } + mBroadcastReceiver.unregister() } initialSupportedInterfaceOrientations = @@ -89,15 +97,16 @@ class OrientationDirectorModuleImpl internal constructor(private val context: Re return } - val lastInterfaceOrientationIsAlreadyInLandscape = lastInterfaceOrientation == Orientation.LANDSCAPE_RIGHT - || lastInterfaceOrientation == Orientation.LANDSCAPE_LEFT - if (lastInterfaceOrientationIsAlreadyInLandscape) { - updateLastInterfaceOrientationTo(lastInterfaceOrientation) - return; - } + val lastInterfaceOrientationIsAlreadyInLandscape = + lastInterfaceOrientation == Orientation.LANDSCAPE_RIGHT + || lastInterfaceOrientation == Orientation.LANDSCAPE_LEFT + if (lastInterfaceOrientationIsAlreadyInLandscape) { + updateLastInterfaceOrientationTo(lastInterfaceOrientation) + return; + } val systemDefaultLandscapeOrientation = Orientation.LANDSCAPE_RIGHT - updateLastInterfaceOrientationTo(systemDefaultLandscapeOrientation) + updateLastInterfaceOrientationTo(systemDefaultLandscapeOrientation) } fun unlock() { @@ -160,12 +169,13 @@ class OrientationDirectorModuleImpl internal constructor(private val context: Re } } - private fun adaptInterfaceTo(deviceOrientation: Orientation) { - if (!mAutoRotationObserver.getLastAutoRotationStatus()) { + private fun adaptInterfaceTo(deviceOrientation: Orientation, checkLastAutoRotationStatus: Boolean = true) { + if (checkLastAutoRotationStatus && !mAutoRotationObserver.getLastAutoRotationStatus()) { return } - val supportsLandscape = mUtils.getRequestedOrientation() == ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE; + val supportsLandscape = + mUtils.getRequestedOrientation() == ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE; if (isLocked && !supportsLandscape) { return } @@ -191,8 +201,9 @@ class OrientationDirectorModuleImpl internal constructor(private val context: Re * Instead, we check that its value is either LANDSCAPE_RIGHT or LANDSCAPE_LEFT, otherwise we * exit */ - val newInterfaceOrientationIsNotLandscape = newInterfaceOrientation != Orientation.LANDSCAPE_RIGHT - && newInterfaceOrientation != Orientation.LANDSCAPE_LEFT; + val newInterfaceOrientationIsNotLandscape = + newInterfaceOrientation != Orientation.LANDSCAPE_RIGHT + && newInterfaceOrientation != Orientation.LANDSCAPE_LEFT; if (supportsLandscape && newInterfaceOrientationIsNotLandscape) { return } diff --git a/example/android/app/src/main/AndroidManifest.xml b/example/android/app/src/main/AndroidManifest.xml index 4fe5d08..e189252 100644 --- a/example/android/app/src/main/AndroidManifest.xml +++ b/example/android/app/src/main/AndroidManifest.xml @@ -9,7 +9,7 @@ android:roundIcon="@mipmap/ic_launcher_round" android:allowBackup="false" android:theme="@style/AppTheme" - android:supportsRtl="true">> + android:supportsRtl="true"> { return withPlugins(config, [ withRNOrientationAppDelegate, withRNOrientationBridgingHeader, + withRNOrientationMainActivity, withAppBridgingHeaderMod, ]); }; diff --git a/plugin/src/withRNOrientationMainActivity.ts b/plugin/src/withRNOrientationMainActivity.ts new file mode 100644 index 0000000..9c03cd6 --- /dev/null +++ b/plugin/src/withRNOrientationMainActivity.ts @@ -0,0 +1,80 @@ +import { + type ConfigPlugin, + type ExportedConfigWithProps, + withMainActivity, +} from '@expo/config-plugins'; +import { type ApplicationProjectFile } from '@expo/config-plugins/build/android/Paths'; +import { mergeContents } from '@expo/config-plugins/build/utils/generateCode'; + +export const withRNOrientationMainActivity: ConfigPlugin = (config) => { + return withMainActivity(config, readMainActivityFileAndUpdateContents); +}; + +async function readMainActivityFileAndUpdateContents( + config: ExportedConfigWithProps +): Promise> { + const { modResults: mainActivityFile } = config; + + const worker = getCompatibleFileUpdater(mainActivityFile.language); + mainActivityFile.contents = worker(mainActivityFile.contents); + + return config; +} + +function getCompatibleFileUpdater( + language: ApplicationProjectFile['language'] +): (originalContents: string) => string { + switch (language) { + case 'kt': + return ktFileUpdater; + default: + throw new Error( + `Cannot add React Native Orientation Director code to MainActivity of language "${language}"` + ); + } +} + +export function ktFileUpdater(originalContents: string): string { + const libraryImportCodeBlock = + 'import com.orientationdirector.implementation.ConfigurationChangedBroadcastReceiver\n'; + const rightBeforeClassDeclaration = /class MainActivity/g; + + const importMergeResults = mergeContents({ + tag: '@react-native-orientation-director/library-import', + src: originalContents, + newSrc: libraryImportCodeBlock, + anchor: rightBeforeClassDeclaration, + offset: 0, + comment: '// React Native Orientation Director', + }); + + const onConfigurationChangedCodeBlock = ` + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + + val orientationDirectorCustomAction = + packageName + "." + ConfigurationChangedBroadcastReceiver.CUSTOM_INTENT_ACTION + + val intent = + Intent(orientationDirectorCustomAction).apply { + putExtra("newConfig", newConfig) + setPackage(packageName) + } + + this.sendBroadcast(intent) + }\n`; + + const rightBeforeLastClosingBrace = /super\.onCreate/g; + const pasteInTheListJustAfterTheClosingBracket = 2; + + const implementationMergeResults = mergeContents({ + tag: '@react-native-orientation-director/supportedInterfaceOrientationsFor-implementation', + src: importMergeResults.contents, + newSrc: onConfigurationChangedCodeBlock, + anchor: rightBeforeLastClosingBrace, + offset: pasteInTheListJustAfterTheClosingBracket, + comment: '// React Native Orientation Director', + }); + + return implementationMergeResults.contents; +}