diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/bridge/ExtraWindowEventListener.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/bridge/ExtraWindowEventListener.kt new file mode 100644 index 000000000000..e428a81ea90b --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/bridge/ExtraWindowEventListener.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.react.bridge + +import android.view.Window + +/** + * Listener for receiving extra window creation and destruction events. + * + * This allows modules to react to new windows being added or removed, such as Dialog windows + * registered by Modal components. Modules like StatusBarModule can implement this interface to + * apply their configuration to all active windows. + * + * Third-party libraries can both implement this listener and emit window events through + * [ReactContext.onExtraWindowCreate] and [ReactContext.onExtraWindowDestroy]. + */ +public interface ExtraWindowEventListener { + + /** Called when a new [Window] is created (e.g. a Dialog window for a Modal). */ + public fun onExtraWindowCreate(window: Window) + + /** Called when a [Window] is destroyed (e.g. on Dialog window dismiss). */ + public fun onExtraWindowDestroy(window: Window) +} diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/bridge/ReactContext.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/bridge/ReactContext.java index dc9acd738dd7..9d70e5081dc3 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/bridge/ReactContext.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/bridge/ReactContext.java @@ -15,6 +15,7 @@ import android.content.Intent; import android.os.Bundle; import android.view.LayoutInflater; +import android.view.Window; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import com.facebook.common.logging.FLog; @@ -48,6 +49,8 @@ public interface RCTDeviceEventEmitter extends JavaScriptModule { new CopyOnWriteArraySet<>(); private final CopyOnWriteArraySet mActivityEventListeners = new CopyOnWriteArraySet<>(); + private final CopyOnWriteArraySet mExtraWindowEventListeners = + new CopyOnWriteArraySet<>(); private final CopyOnWriteArraySet mWindowFocusEventListeners = new CopyOnWriteArraySet<>(); private final ScrollEndedListeners mScrollEndedListeners = new ScrollEndedListeners(); @@ -246,6 +249,14 @@ public void removeActivityEventListener(ActivityEventListener listener) { mActivityEventListeners.remove(listener); } + public void addExtraWindowEventListener(ExtraWindowEventListener listener) { + mExtraWindowEventListeners.add(listener); + } + + public void removeExtraWindowEventListener(ExtraWindowEventListener listener) { + mExtraWindowEventListeners.remove(listener); + } + public void addWindowFocusChangeListener(WindowFocusChangeListener listener) { mWindowFocusEventListeners.add(listener); } @@ -356,6 +367,30 @@ public void onActivityResult( } } + @ThreadConfined(UI) + public void onExtraWindowCreate(Window window) { + UiThreadUtil.assertOnUiThread(); + for (ExtraWindowEventListener listener : mExtraWindowEventListeners) { + try { + listener.onExtraWindowCreate(window); + } catch (RuntimeException e) { + handleException(e); + } + } + } + + @ThreadConfined(UI) + public void onExtraWindowDestroy(Window window) { + UiThreadUtil.assertOnUiThread(); + for (ExtraWindowEventListener listener : mExtraWindowEventListeners) { + try { + listener.onExtraWindowDestroy(window); + } catch (RuntimeException e) { + handleException(e); + } + } + } + @ThreadConfined(UI) public void onWindowFocusChange(boolean hasFocus) { UiThreadUtil.assertOnUiThread(); diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/statusbar/StatusBarModule.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/statusbar/StatusBarModule.kt index 1e773ba83d61..22b23ee65055 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/statusbar/StatusBarModule.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/statusbar/StatusBarModule.kt @@ -9,12 +9,14 @@ package com.facebook.react.modules.statusbar import android.animation.ArgbEvaluator import android.animation.ValueAnimator -import android.os.Build -import android.view.View -import android.view.WindowInsetsController +import android.view.Window import android.view.WindowManager +import androidx.core.view.ViewCompat +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat import com.facebook.common.logging.FLog import com.facebook.fbreact.specs.NativeStatusBarManagerAndroidSpec +import com.facebook.react.bridge.ExtraWindowEventListener import com.facebook.react.bridge.GuardedRunnable import com.facebook.react.bridge.NativeModule import com.facebook.react.bridge.ReactApplicationContext @@ -24,13 +26,43 @@ import com.facebook.react.module.annotations.ReactModule import com.facebook.react.uimanager.DisplayMetricsHolder.getStatusBarHeightPx import com.facebook.react.uimanager.PixelUtil import com.facebook.react.views.view.isEdgeToEdgeFeatureFlagOn +import com.facebook.react.views.view.setStatusBarStyle import com.facebook.react.views.view.setStatusBarTranslucency import com.facebook.react.views.view.setStatusBarVisibility /** [NativeModule] that allows changing the appearance of the status bar. */ @ReactModule(name = NativeStatusBarManagerAndroidSpec.NAME) internal class StatusBarModule(reactContext: ReactApplicationContext?) : - NativeStatusBarManagerAndroidSpec(reactContext) { + NativeStatusBarManagerAndroidSpec(reactContext), ExtraWindowEventListener { + + private val extraWindows = mutableSetOf() + + init { + reactApplicationContext.addExtraWindowEventListener(this) + } + + override fun invalidate() { + super.invalidate() + reactApplicationContext.removeExtraWindowEventListener(this) + } + + override fun onExtraWindowCreate(window: Window) { + extraWindows.add(window) + + UiThreadUtil.runOnUiThread { + val controller = WindowCompat.getInsetsController(window, window.decorView) + val insets = ViewCompat.getRootWindowInsets(window.decorView) + val style = if (controller.isAppearanceLightStatusBars) "dark-content" else "light-content" + val visible = insets?.isVisible(WindowInsetsCompat.Type.statusBars()) ?: true + + window.setStatusBarStyle(style) + window.setStatusBarVisibility(!visible) + } + } + + override fun onExtraWindowDestroy(window: Window) { + extraWindows.remove(window) + } @Suppress("DEPRECATION") override fun getTypedExportedConstants(): Map { @@ -118,10 +150,12 @@ internal class StatusBarModule(reactContext: ReactApplicationContext?) : ) return } - UiThreadUtil.runOnUiThread { activity.window?.setStatusBarVisibility(hidden) } + UiThreadUtil.runOnUiThread { + activity.window?.setStatusBarVisibility(hidden) + extraWindows.forEach { it.setStatusBarVisibility(hidden) } + } } - @Suppress("DEPRECATION") override fun setStyle(style: String?) { val activity = reactApplicationContext.getCurrentActivity() if (activity == null) { @@ -131,36 +165,10 @@ internal class StatusBarModule(reactContext: ReactApplicationContext?) : ) return } - UiThreadUtil.runOnUiThread( - Runnable { - val window = activity.window ?: return@Runnable - if (Build.VERSION.SDK_INT > Build.VERSION_CODES.R) { - val insetsController = window.insetsController ?: return@Runnable - if ("dark-content" == style) { - // dark-content means dark icons on a light status bar - insetsController.setSystemBarsAppearance( - WindowInsetsController.APPEARANCE_LIGHT_STATUS_BARS, - WindowInsetsController.APPEARANCE_LIGHT_STATUS_BARS, - ) - } else { - insetsController.setSystemBarsAppearance( - 0, - WindowInsetsController.APPEARANCE_LIGHT_STATUS_BARS, - ) - } - } else { - val decorView = window.decorView - var systemUiVisibilityFlags = decorView.systemUiVisibility - systemUiVisibilityFlags = - if ("dark-content" == style) { - systemUiVisibilityFlags or View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR - } else { - systemUiVisibilityFlags and View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR.inv() - } - decorView.systemUiVisibility = systemUiVisibilityFlags - } - } - ) + UiThreadUtil.runOnUiThread { + activity.window?.setStatusBarStyle(style) + extraWindows.forEach { it.setStatusBarStyle(style) } + } } companion object { diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/ThemedReactContext.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/ThemedReactContext.kt index 58fc5c8527b5..86a90a402b50 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/ThemedReactContext.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/ThemedReactContext.kt @@ -11,8 +11,10 @@ package com.facebook.react.uimanager import android.app.Activity import android.content.Context +import android.view.Window import com.facebook.react.bridge.Callback import com.facebook.react.bridge.CatalystInstance +import com.facebook.react.bridge.ExtraWindowEventListener import com.facebook.react.bridge.JavaScriptContextHolder import com.facebook.react.bridge.JavaScriptModule import com.facebook.react.bridge.LifecycleEventListener @@ -67,6 +69,22 @@ public class ThemedReactContext( reactApplicationContext.removeLifecycleEventListener(listener) } + override fun addExtraWindowEventListener(listener: ExtraWindowEventListener) { + reactApplicationContext.addExtraWindowEventListener(listener) + } + + override fun removeExtraWindowEventListener(listener: ExtraWindowEventListener) { + reactApplicationContext.removeExtraWindowEventListener(listener) + } + + override fun onExtraWindowCreate(window: Window) { + reactApplicationContext.onExtraWindowCreate(window) + } + + override fun onExtraWindowDestroy(window: Window) { + reactApplicationContext.onExtraWindowDestroy(window) + } + override fun hasCurrentActivity(): Boolean = reactApplicationContext.hasCurrentActivity() override fun getCurrentActivity(): Activity? = reactApplicationContext.getCurrentActivity() diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/modal/ReactModalHostView.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/modal/ReactModalHostView.kt index d634b601db6b..512e5f147dfe 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/modal/ReactModalHostView.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/modal/ReactModalHostView.kt @@ -196,6 +196,9 @@ public class ReactModalHostView(context: ThemedReactContext) : UiThreadUtil.assertOnUiThread() dialog?.let { nonNullDialog -> + nonNullDialog.window?.let { window -> + (context as ThemedReactContext).onExtraWindowDestroy(window) + } if (nonNullDialog.isShowing) { val dialogContext = ContextUtils.findContextOfType(nonNullDialog.context, Activity::class.java) @@ -341,6 +344,7 @@ public class ReactModalHostView(context: ThemedReactContext) : newDialog.show() updateSystemAppearance() window.clearFlags(WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE) + (context as ThemedReactContext).onExtraWindowCreate(window) } } diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/view/WindowUtil.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/view/WindowUtil.kt index 0cff3bc458d8..461388546ab9 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/view/WindowUtil.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/view/WindowUtil.kt @@ -9,7 +9,9 @@ package com.facebook.react.views.view import android.graphics.Color import android.os.Build +import android.view.View import android.view.Window +import android.view.WindowInsetsController import android.view.WindowManager import androidx.core.view.ViewCompat import androidx.core.view.WindowCompat @@ -65,6 +67,33 @@ internal fun Window.setStatusBarVisibility(isHidden: Boolean) { } } +@Suppress("DEPRECATION") +internal fun Window.setStatusBarStyle(style: String?) { + if (Build.VERSION.SDK_INT > Build.VERSION_CODES.R) { + if ("dark-content" == style) { + // dark-content means dark icons on a light status bar + insetsController?.setSystemBarsAppearance( + WindowInsetsController.APPEARANCE_LIGHT_STATUS_BARS, + WindowInsetsController.APPEARANCE_LIGHT_STATUS_BARS, + ) + } else { + insetsController?.setSystemBarsAppearance( + 0, + WindowInsetsController.APPEARANCE_LIGHT_STATUS_BARS, + ) + } + } else { + var systemUiVisibilityFlags = decorView.systemUiVisibility + systemUiVisibilityFlags = + if ("dark-content" == style) { + systemUiVisibilityFlags or View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR + } else { + systemUiVisibilityFlags and View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR.inv() + } + decorView.systemUiVisibility = systemUiVisibilityFlags + } +} + @Suppress("DEPRECATION") private fun Window.statusBarHide() { if (isEdgeToEdgeFeatureFlagOn) {