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..f4d6102981cb 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 mWindowEventListeners = + 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 addWindowEventListener(WindowEventListener listener) { + mWindowEventListeners.add(listener); + } + + public void removeWindowEventListener(WindowEventListener listener) { + mWindowEventListeners.remove(listener); + } + public void addWindowFocusChangeListener(WindowFocusChangeListener listener) { mWindowFocusEventListeners.add(listener); } @@ -356,6 +367,30 @@ public void onActivityResult( } } + @ThreadConfined(UI) + public void onWindowCreated(Window window) { + UiThreadUtil.assertOnUiThread(); + for (WindowEventListener listener : mWindowEventListeners) { + try { + listener.onWindowCreated(window); + } catch (RuntimeException e) { + handleException(e); + } + } + } + + @ThreadConfined(UI) + public void onWindowDestroyed(Window window) { + UiThreadUtil.assertOnUiThread(); + for (WindowEventListener listener : mWindowEventListeners) { + try { + listener.onWindowDestroyed(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/bridge/WindowEventListener.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/bridge/WindowEventListener.kt new file mode 100644 index 000000000000..3b8b1e59b847 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/bridge/WindowEventListener.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 window creation and destruction events. + * + * This allows modules to react to new windows being added or removed, such as Dialog windows + * created 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.onWindowCreated] and [ReactContext.onWindowDestroyed]. + */ +public interface WindowEventListener { + + /** Called when a new [Window] is created (e.g. a Dialog window for a Modal). */ + public fun onWindowCreated(window: Window) + + /** Called when a [Window] is about to be destroyed (e.g. a Dialog window being dismissed). */ + public fun onWindowDestroyed(window: Window) +} 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..d864142ad8cf 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 @@ -7,30 +7,59 @@ 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.WindowManager +import android.view.Window +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.GuardedRunnable import com.facebook.react.bridge.NativeModule import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.bridge.UiThreadUtil +import com.facebook.react.bridge.WindowEventListener import com.facebook.react.common.ReactConstants 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.setStatusBarColor +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), WindowEventListener { + + private val extrasWindows = mutableSetOf() + + init { + reactApplicationContext.addWindowEventListener(this) + } + + override fun invalidate() { + super.invalidate() + reactApplicationContext.removeWindowEventListener(this) + } + + override fun onWindowCreated(window: Window) { + extrasWindows.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 onWindowDestroyed(window: Window) { + extrasWindows.remove(window) + } @Suppress("DEPRECATION") override fun getTypedExportedConstants(): Map { @@ -45,7 +74,6 @@ internal class StatusBarModule(reactContext: ReactApplicationContext?) : ) } - @Suppress("DEPRECATION") override fun setColor(colorDouble: Double, animated: Boolean) { val color = colorDouble.toInt() val activity = reactApplicationContext.getCurrentActivity() @@ -63,25 +91,7 @@ internal class StatusBarModule(reactContext: ReactApplicationContext?) : ) return } - UiThreadUtil.runOnUiThread( - object : GuardedRunnable(reactApplicationContext) { - override fun runGuarded() { - val window = activity.window ?: return - window.addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS) - if (animated) { - val curColor = window.statusBarColor - val colorAnimation = ValueAnimator.ofObject(ArgbEvaluator(), curColor, color) - colorAnimation.addUpdateListener { animator -> - activity.window?.statusBarColor = (animator.animatedValue as Int) - } - colorAnimation.setDuration(300).startDelay = 0 - colorAnimation.start() - } else { - window.statusBarColor = color - } - } - } - ) + UiThreadUtil.runOnUiThread { activity.window?.setStatusBarColor(color, animated) } } override fun setTranslucent(translucent: Boolean) { @@ -100,13 +110,7 @@ internal class StatusBarModule(reactContext: ReactApplicationContext?) : ) return } - UiThreadUtil.runOnUiThread( - object : GuardedRunnable(reactApplicationContext) { - override fun runGuarded() { - activity.window?.setStatusBarTranslucency(translucent) - } - } - ) + UiThreadUtil.runOnUiThread { activity.window?.setStatusBarTranslucency(translucent) } } override fun setHidden(hidden: Boolean) { @@ -118,10 +122,12 @@ internal class StatusBarModule(reactContext: ReactApplicationContext?) : ) return } - UiThreadUtil.runOnUiThread { activity.window?.setStatusBarVisibility(hidden) } + UiThreadUtil.runOnUiThread { + activity.window?.setStatusBarVisibility(hidden) + extrasWindows.forEach { it.setStatusBarVisibility(hidden) } + } } - @Suppress("DEPRECATION") override fun setStyle(style: String?) { val activity = reactApplicationContext.getCurrentActivity() if (activity == null) { @@ -131,36 +137,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) + extrasWindows.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..e581b0b3407f 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,6 +11,7 @@ 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.JavaScriptContextHolder @@ -20,6 +21,7 @@ import com.facebook.react.bridge.NativeModule import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.bridge.ReactContext import com.facebook.react.bridge.ScrollEndedListeners +import com.facebook.react.bridge.WindowEventListener import com.facebook.react.bridge.UIManager import com.facebook.react.common.annotations.internal.LegacyArchitecture import com.facebook.react.turbomodule.core.interfaces.CallInvokerHolder @@ -67,6 +69,22 @@ public class ThemedReactContext( reactApplicationContext.removeLifecycleEventListener(listener) } + override fun addWindowEventListener(listener: WindowEventListener) { + reactApplicationContext.addWindowEventListener(listener) + } + + override fun removeWindowEventListener(listener: WindowEventListener) { + reactApplicationContext.removeWindowEventListener(listener) + } + + override fun onWindowCreated(window: Window) { + reactApplicationContext.onWindowCreated(window) + } + + override fun onWindowDestroyed(window: Window) { + reactApplicationContext.onWindowDestroyed(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..f6cadc94336b 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).onWindowDestroyed(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).onWindowCreated(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..43429d9fd0ac 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 @@ -7,9 +7,13 @@ package com.facebook.react.views.view +import android.animation.ArgbEvaluator +import android.animation.ValueAnimator 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 @@ -37,6 +41,49 @@ public fun setEdgeToEdgeFeatureFlagOn() { isEdgeToEdgeFeatureFlagOn = true } +@Suppress("DEPRECATION") +internal fun Window.setStatusBarColor(color: Int, animated: Boolean) { + addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS) + if (animated) { + val curColor = statusBarColor + val colorAnimation = ValueAnimator.ofObject(ArgbEvaluator(), curColor, color) + colorAnimation.addUpdateListener { animator -> + statusBarColor = (animator.animatedValue as Int) + } + colorAnimation.setDuration(300).startDelay = 0 + colorAnimation.start() + } else { + statusBarColor = color + } +} + +@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") internal fun Window.setStatusBarTranslucency(isTranslucent: Boolean) { // If the status bar is translucent hook into the window insets calculations