diff --git a/android/build.gradle b/android/build.gradle index 51f8f029a7a..bec9808bfbf 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -130,6 +130,7 @@ dependencies { implementation "androidx.constraintlayout:constraintlayout:2.0.4" implementation 'androidx.appcompat:appcompat:1.7.0' + implementation 'androidx.activity:activity:1.9.0' implementation 'androidx.annotation:annotation:1.2.0' implementation 'com.google.android.material:material:1.2.0-alpha03' diff --git a/android/src/main/java/com/reactnativenavigation/NavigationActivity.java b/android/src/main/java/com/reactnativenavigation/NavigationActivity.java index f3f0d5a57ee..c8c4930cb42 100644 --- a/android/src/main/java/com/reactnativenavigation/NavigationActivity.java +++ b/android/src/main/java/com/reactnativenavigation/NavigationActivity.java @@ -7,6 +7,7 @@ import android.os.Bundle; import android.view.KeyEvent; import android.view.View; +import android.view.ViewGroup; import com.facebook.react.modules.core.DefaultHardwareBackBtnHandler; import com.facebook.react.modules.core.PermissionAwareActivity; @@ -17,10 +18,12 @@ import com.reactnativenavigation.react.JsDevReloadHandler; import com.reactnativenavigation.react.ReactGateway; import com.reactnativenavigation.react.CommandListenerAdapter; +import com.reactnativenavigation.utils.SystemUiUtils; import com.reactnativenavigation.viewcontrollers.child.ChildControllersRegistry; import com.reactnativenavigation.viewcontrollers.modal.ModalStack; import com.reactnativenavigation.viewcontrollers.navigator.Navigator; +import androidx.activity.EdgeToEdge; import androidx.activity.OnBackPressedCallback; import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -36,6 +39,7 @@ public class NavigationActivity extends AppCompatActivity implements DefaultHard @Override protected void onCreate(@Nullable Bundle savedInstanceState) { + enableEdgeToEdge(); super.onCreate(savedInstanceState); if (isFinishing()) { return; @@ -63,7 +67,9 @@ public void onConfigurationChanged(@NonNull Configuration newConfig) { @Override public void onPostCreate(@Nullable Bundle savedInstanceState) { super.onPostCreate(savedInstanceState); - navigator.setContentLayout(findViewById(android.R.id.content)); + ViewGroup contentLayout = findViewById(android.R.id.content); + navigator.setContentLayout(contentLayout); + SystemUiUtils.setupSystemBarBackgrounds(this, contentLayout); } @Override @@ -88,6 +94,7 @@ protected void onPause() { @Override protected void onDestroy() { super.onDestroy(); + SystemUiUtils.tearDown(); if (navigator != null) { navigator.destroy(); } @@ -146,6 +153,25 @@ public void onReload() { navigator.destroyViews(); } + /** + * Enables edge-to-edge display only if the app theme sets + * {@code windowOptOutEdgeToEdgeEnforcement} to {@code false} (API 35+). + * By default, edge-to-edge is not enabled. Override to customize. + * Called at the start of onCreate, before super.onCreate. + */ + protected void enableEdgeToEdge() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) { + android.content.res.TypedArray a = getTheme().obtainStyledAttributes( + new int[]{android.R.attr.windowOptOutEdgeToEdgeEnforcement}); + boolean optOut = a.getBoolean(0, true); + a.recycle(); + if (!optOut) { + EdgeToEdge.enable(this); + SystemUiUtils.activateEdgeToEdge(); + } + } + } + protected void addDefaultSplashLayout() { View view = new View(this); setContentView(view); diff --git a/android/src/main/java/com/reactnativenavigation/react/modal/ModalHostLayout.kt b/android/src/main/java/com/reactnativenavigation/react/modal/ModalHostLayout.kt index 88094c33b3a..50e77bd8a64 100644 --- a/android/src/main/java/com/reactnativenavigation/react/modal/ModalHostLayout.kt +++ b/android/src/main/java/com/reactnativenavigation/react/modal/ModalHostLayout.kt @@ -34,8 +34,8 @@ open class ModalHostLayout(reactContext: ThemedReactContext) : ViewGroup(reactCo } @TargetApi(23) - override fun dispatchProvideStructure(structure: ViewStructure) { - mHostView.dispatchProvideStructure(structure) + override fun dispatchProvideStructure(structure: ViewStructure?) { + structure?.let { mHostView.dispatchProvideStructure(it) } } override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {} diff --git a/android/src/main/java/com/reactnativenavigation/utils/ReactTypefaceUtils.java b/android/src/main/java/com/reactnativenavigation/utils/ReactTypefaceUtils.java index 962bf9ef53c..7a208bf85a2 100644 --- a/android/src/main/java/com/reactnativenavigation/utils/ReactTypefaceUtils.java +++ b/android/src/main/java/com/reactnativenavigation/utils/ReactTypefaceUtils.java @@ -18,9 +18,8 @@ import android.text.TextUtils; import androidx.annotation.Nullable; import com.facebook.react.bridge.ReadableArray; -import com.facebook.react.common.ReactConstants; import com.facebook.react.views.text.ReactFontManager; -import com.facebook.react.views.text.ReactTextShadowNode; +import com.facebook.react.common.ReactConstants; import java.util.ArrayList; import java.util.List; diff --git a/android/src/main/java/com/reactnativenavigation/utils/SystemUiUtils.kt b/android/src/main/java/com/reactnativenavigation/utils/SystemUiUtils.kt index 5ac5d2ed932..35c74cb8bdc 100644 --- a/android/src/main/java/com/reactnativenavigation/utils/SystemUiUtils.kt +++ b/android/src/main/java/com/reactnativenavigation/utils/SystemUiUtils.kt @@ -3,10 +3,14 @@ package com.reactnativenavigation.utils import android.app.Activity import android.graphics.Color import android.graphics.Rect +import android.graphics.drawable.ColorDrawable +import android.view.Gravity import android.view.View +import android.view.ViewGroup import android.view.Window +import android.widget.FrameLayout import androidx.annotation.ColorInt -import androidx.core.view.WindowCompat +import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsControllerCompat import kotlin.math.abs @@ -16,8 +20,17 @@ object SystemUiUtils { private const val STATUS_BAR_HEIGHT_M = 24 internal const val STATUS_BAR_HEIGHT_TRANSLUCENCY = 0.65f private var statusBarHeight = -1 - var navigationBarDefaultColor = -1 + + const val DEFAULT_NAV_BAR_COLOR = Color.BLACK + private const val THREE_BUTTON_NAV_BAR_OPACITY = 0.8f + + private var statusBarBackgroundView: View? = null + private var navBarBackgroundView: View? = null + @JvmStatic + var isEdgeToEdgeActive = false private set + private var isThreeButtonNav = false + private var lastExplicitNavBarColor: Int? = null @JvmStatic fun getStatusBarHeight(activity: Activity?): Int { @@ -45,98 +58,176 @@ object SystemUiUtils { statusBarHeight = height } - @JvmStatic fun getStatusBarHeightDp(activity: Activity?): Int { - return UiUtils.pxToDp(activity, getStatusBarHeight(activity).toFloat()) - .toInt() + return UiUtils.pxToDp(activity, getStatusBarHeight(activity).toFloat()).toInt() } + // region Setup + + /** + * Initializes view-based system bar backgrounds for edge-to-edge. + * Call from Activity.onPostCreate after the navigator content layout is set. + * + * Status bar: reuses the system's android:id/statusBarBackground DecorView child. + * Navigation bar: creates a view in [contentLayout] sized by WindowInsets, + * since the system's navigationBarBackground is not available with EdgeToEdge. + * + * Both fall back to deprecated window APIs when the views are unavailable. + */ @JvmStatic - fun hideNavigationBar(window: Window?, view: View) { - window?.let { - WindowCompat.setDecorFitsSystemWindows(window, false) - WindowInsetsControllerCompat(window, view).let { controller -> - controller.hide(WindowInsetsCompat.Type.navigationBars()) - controller.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + fun setupSystemBarBackgrounds(activity: Activity, contentLayout: ViewGroup) { + setupStatusBarBackground(activity) + setupNavigationBarBackground(contentLayout) + } + + private fun setupStatusBarBackground(activity: Activity) { + if (statusBarBackgroundView != null) return + val sbView = activity.window.decorView.findViewById(android.R.id.statusBarBackground) + if (sbView != null) { + sbView.setBackgroundColor(Color.BLACK) + statusBarBackgroundView = sbView + } + } + + private fun setupNavigationBarBackground(contentLayout: ViewGroup) { + if (navBarBackgroundView != null) return + val view = View(contentLayout.context).apply { + setBackgroundColor(Color.BLACK) + } + val params = FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, 0, Gravity.BOTTOM + ) + contentLayout.addView(view, params) + navBarBackgroundView = view + + ViewCompat.setOnApplyWindowInsetsListener(view) { v, insets -> + val navBarHeight = insets.getInsets(WindowInsetsCompat.Type.navigationBars()).bottom + val tappableHeight = insets.getInsets(WindowInsetsCompat.Type.tappableElement()).bottom + val wasThreeButton = isThreeButtonNav + isThreeButtonNav = tappableHeight > 0 + if (isThreeButtonNav != wasThreeButton) { + val color = lastExplicitNavBarColor ?: getDefaultNavBarColor() + v.setBackgroundColor(color) } + val lp = v.layoutParams + if (lp.height != navBarHeight) { + lp.height = navBarHeight + v.layoutParams = lp + } + insets } + view.requestApplyInsets() } + /** + * Returns the default navigation bar color, applying 80% opacity for 3-button navigation. + * Gesture navigation gets a fully opaque color since the bar is minimal. + */ @JvmStatic - fun showNavigationBar(window: Window?, view: View) { - window?.let { - WindowCompat.setDecorFitsSystemWindows(window, true) - WindowInsetsControllerCompat(window, view).show(WindowInsetsCompat.Type.navigationBars()) - } + fun getDefaultNavBarColor(): Int { + if (!isThreeButtonNav) return DEFAULT_NAV_BAR_COLOR + val alpha = (THREE_BUTTON_NAV_BAR_OPACITY * 255).toInt() + return Color.argb(alpha, Color.red(DEFAULT_NAV_BAR_COLOR), Color.green(DEFAULT_NAV_BAR_COLOR), Color.blue(DEFAULT_NAV_BAR_COLOR)) } + /** + * Marks edge-to-edge as active. Call after EdgeToEdge.enable() in the activity. + * This flag controls whether navigation bar insets are forwarded to SafeAreaView + * and whether the view-based nav bar background is used for color changes. + */ + @JvmStatic + fun activateEdgeToEdge() { + isEdgeToEdgeActive = true + } + + /** + * Clears references to system bar background views. + * Call from Activity.onDestroy to avoid leaking views across activity recreation. + */ + @JvmStatic + fun tearDown() { + statusBarBackgroundView = null + navBarBackgroundView = null + isEdgeToEdgeActive = false + isThreeButtonNav = false + lastExplicitNavBarColor = null + statusBarHeight = -1 + } + + // endregion + + // region Status Bar + @JvmStatic fun setStatusBarColorScheme(window: Window?, view: View, isDark: Boolean) { window?.let { WindowInsetsControllerCompat(window, view).isAppearanceLightStatusBars = isDark - // Workaround: on devices with api 30 status bar icons flickers or get hidden when removing view - //turns out it is a bug on such devices, fixed by using system flags until it is fixed. - var flags = view.systemUiVisibility - flags = if (isDark) { - flags or View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR - } else { - flags and View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR.inv() - } - - view.systemUiVisibility = flags } } @JvmStatic fun setStatusBarTranslucent(window: Window?) { - window?.let { - setStatusBarColor(window, window.statusBarColor, true) + getStatusBarColor(window)?.let { currentColor -> + setStatusBarColor(window, currentColor, true) } } @JvmStatic fun isTranslucent(window: Window?): Boolean { - return window?.let { - Color.alpha(it.statusBarColor) < 255 - } ?: false + val color = getStatusBarColor(window) ?: return false + return Color.alpha(color) < 255 } @JvmStatic fun clearStatusBarTranslucency(window: Window?) { - window?.let { - setStatusBarColor(it, it.statusBarColor, false) + getStatusBarColor(window)?.let { currentColor -> + setStatusBarColor(window, currentColor, false) } } @JvmStatic - fun setStatusBarColor( - window: Window?, - @ColorInt color: Int, - translucent: Boolean - ) { + fun setStatusBarColor(window: Window?, @ColorInt color: Int, translucent: Boolean) { val colorAlpha = Color.alpha(color) - val alpha = if (translucent && colorAlpha == 255) STATUS_BAR_HEIGHT_TRANSLUCENCY else colorAlpha/255.0f - val red: Int = Color.red(color) - val green: Int = Color.green(color) - val blue: Int = Color.blue(color) - val opaqueColor = Color.argb(ceil(alpha * 255).toInt(), red, green, blue) + val alpha = if (translucent && colorAlpha == 255) STATUS_BAR_HEIGHT_TRANSLUCENCY else colorAlpha / 255.0f + val opaqueColor = Color.argb( + ceil(alpha * 255).toInt(), + Color.red(color), + Color.green(color), + Color.blue(color) + ) setStatusBarColor(window, opaqueColor) } + /** + * Sets the status bar background color. + * Uses the view-based background when available (edge-to-edge), + * falls back to the deprecated window API on older configurations. + */ fun setStatusBarColor(window: Window?, color: Int) { - window?.statusBarColor = color + statusBarBackgroundView?.setBackgroundColor(color) ?: run { + @Suppress("DEPRECATION") + window?.statusBarColor = color + } } + /** + * Gets the current status bar background color. + * Reads from the view-based background when available, + * falls back to the deprecated window API on older configurations. + */ @JvmStatic fun getStatusBarColor(window: Window?): Int? { + statusBarBackgroundView?.let { view -> + (view.background as? ColorDrawable)?.let { return it.color } + } + @Suppress("DEPRECATION") return window?.statusBarColor } @JvmStatic fun hideStatusBar(window: Window?, view: View) { window?.let { - WindowCompat.setDecorFitsSystemWindows(window, false) WindowInsetsControllerCompat(window, view).let { controller -> controller.hide(WindowInsetsCompat.Type.statusBars()) controller.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE @@ -147,22 +238,49 @@ object SystemUiUtils { @JvmStatic fun showStatusBar(window: Window?, view: View) { window?.let { - WindowCompat.setDecorFitsSystemWindows(window, true) WindowInsetsControllerCompat(window, view).show(WindowInsetsCompat.Type.statusBars()) } } + // endregion + + // region Navigation Bar + @JvmStatic - fun setNavigationBarBackgroundColor(window: Window?, color: Int, lightColor: Boolean) { + fun hideNavigationBar(window: Window?, view: View) { window?.let { - if (navigationBarDefaultColor == -1) { - navigationBarDefaultColor = window.navigationBarColor - } - WindowInsetsControllerCompat(window, window.decorView).let { controller -> - controller.isAppearanceLightNavigationBars = lightColor + WindowInsetsControllerCompat(window, view).let { controller -> + controller.hide(WindowInsetsCompat.Type.navigationBars()) + controller.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE } - window.navigationBarColor = color } } + @JvmStatic + fun showNavigationBar(window: Window?, view: View) { + window?.let { + WindowInsetsControllerCompat(window, view).show(WindowInsetsCompat.Type.navigationBars()) + } + } + + /** + * Sets the navigation bar background color and icon appearance. + * Uses the view-based background when available (edge-to-edge), + * falls back to the deprecated window API on older configurations. + */ + @JvmStatic + fun setNavigationBarBackgroundColor(window: Window?, color: Int, lightColor: Boolean) { + lastExplicitNavBarColor = color + window?.let { + WindowInsetsControllerCompat(window, window.decorView).isAppearanceLightNavigationBars = lightColor + } + if (isEdgeToEdgeActive) { + navBarBackgroundView?.setBackgroundColor(color) + } else { + @Suppress("DEPRECATION") + window?.navigationBarColor = color + } + } + + // endregion } diff --git a/android/src/main/java/com/reactnativenavigation/viewcontrollers/bottomtabs/BottomTabsController.java b/android/src/main/java/com/reactnativenavigation/viewcontrollers/bottomtabs/BottomTabsController.java index b3deb2cd87f..49324fa2b6d 100644 --- a/android/src/main/java/com/reactnativenavigation/viewcontrollers/bottomtabs/BottomTabsController.java +++ b/android/src/main/java/com/reactnativenavigation/viewcontrollers/bottomtabs/BottomTabsController.java @@ -254,12 +254,13 @@ public boolean onMeasureChild(CoordinatorLayout parent, ViewGroup child, int par @Override public int getBottomInset(ViewController child) { - return presenter.getBottomInset(resolveChildOptions(child)) + perform(getParentController(), 0, p -> p.getBottomInset(this)); + return presenter.getChildrenBottomInset(resolveChildOptions(child)) + perform(getParentController(), 0, p -> p.getBottomInset(this)); } @Override public void applyBottomInset() { - presenter.applyBottomInset(getBottomInset()); + presenter.applyChildrenInset(getBottomInset()); + presenter.applySelfInset(getBottomInset()); super.applyBottomInset(); } diff --git a/android/src/main/java/com/reactnativenavigation/viewcontrollers/bottomtabs/BottomTabsPresenter.kt b/android/src/main/java/com/reactnativenavigation/viewcontrollers/bottomtabs/BottomTabsPresenter.kt index c2e435e8497..2d40aa256ac 100644 --- a/android/src/main/java/com/reactnativenavigation/viewcontrollers/bottomtabs/BottomTabsPresenter.kt +++ b/android/src/main/java/com/reactnativenavigation/viewcontrollers/bottomtabs/BottomTabsPresenter.kt @@ -1,6 +1,7 @@ package com.reactnativenavigation.viewcontrollers.bottomtabs import android.animation.Animator +import android.app.Activity import android.graphics.Color import android.view.ViewGroup import android.view.ViewGroup.LayoutParams.WRAP_CONTENT @@ -12,6 +13,8 @@ import com.reactnativenavigation.RNNToggles.TAB_BAR_TRANSLUCENCE import com.reactnativenavigation.options.Options import com.reactnativenavigation.options.params.BottomTabsLayoutStyle import com.reactnativenavigation.options.params.Fraction +import com.reactnativenavigation.utils.ColorUtils.isColorLight +import com.reactnativenavigation.utils.SystemUiUtils import com.reactnativenavigation.utils.UiUtils import com.reactnativenavigation.viewcontrollers.viewcontroller.ViewController import com.reactnativenavigation.views.bottomtabs.BottomTabs @@ -92,7 +95,9 @@ class BottomTabsPresenter( // Keep this before the translucent check below if (bottomTabsOptions.backgroundColor.hasValue()) { - bottomTabsContainer.setBackgroundColor(bottomTabsOptions.backgroundColor.get()) + val color = bottomTabsOptions.backgroundColor.get() + bottomTabsContainer.setBackgroundColor(color) + syncNavigationBarColor(options, color) } if (RNNFeatureToggles.isEnabled(TAB_BAR_TRANSLUCENCE)) { @@ -212,7 +217,9 @@ class BottomTabsPresenter( bottomTabsContainer.enableBackgroundBlur() } else { bottomTabsContainer.disableBackgroundBlur() - bottomTabsContainer.setBackgroundColor(bottomTabsOptions.backgroundColor.get(Color.WHITE)!!) + val color = bottomTabsOptions.backgroundColor.get(Color.WHITE)!! + bottomTabsContainer.setBackgroundColor(color) + syncNavigationBarColor(options, color) } bottomTabs.setLayoutDirection(options.layout.direction) @@ -282,13 +289,17 @@ class BottomTabsPresenter( bottomTabs.setBehaviorTranslationEnabled(bottomTabsOptions.hideOnScroll[false]) } - fun applyBottomInset(bottomInset: Int) { + fun applyChildrenInset(bottomInset: Int) { (bottomTabsContainer.layoutParams as ViewGroup.MarginLayoutParams).updateMargins(bottom = bottomInset) bottomTabsContainer.requestLayout() } - fun getBottomInset(resolvedOptions: Options): Int { - return if (resolvedOptions.withDefaultOptions(defaultOptions).bottomTabsOptions.isHiddenOrDrawBehind) 0 else bottomTabs.height + fun getChildrenBottomInset(resolvedOptions: Options): Int { + return if (resolvedOptions.withDefaultOptions(defaultOptions).bottomTabsOptions.isHiddenOrDrawBehind) 0 else (bottomTabsContainer.height) + } + + fun applySelfInset(bottomInset: Int) { + bottomTabsContainer.setBottomInset(bottomInset) } fun getPushAnimation(appearingOptions: Options): Animator? { @@ -351,7 +362,9 @@ class BottomTabsPresenter( bottomTabsContainer.disableBackgroundBlur() // TODO Change to bottomTabsContainer.setBackgroundColor()? - bottomTabs.setBackgroundColor(bottomTabsOptions.backgroundColor.get(Color.WHITE)!!) + val color = bottomTabsOptions.backgroundColor.get(Color.WHITE)!! + bottomTabs.setBackgroundColor(color) + syncNavigationBarColor(options, color) } if (bottomTabsOptions.shadowOptions.hasValue()) { @@ -376,4 +389,11 @@ class BottomTabsPresenter( val margin = UiUtils.dpToPx(bottomTabsContainer.context, marginDp.toFloat()).roundToInt() return margin } + + private fun syncNavigationBarColor(options: Options, tabsColor: Int) { + val resolved = options.copy().withDefaultOptions(defaultOptions) + if (resolved.navigationBar.backgroundColor.hasValue()) return + val window = (bottomTabsContainer.context as? Activity)?.window ?: return + SystemUiUtils.setNavigationBarBackgroundColor(window, tabsColor, isColorLight(tabsColor)) + } } \ No newline at end of file diff --git a/android/src/main/java/com/reactnativenavigation/viewcontrollers/component/ComponentViewController.java b/android/src/main/java/com/reactnativenavigation/viewcontrollers/component/ComponentViewController.java index e5730af4ef5..ca1a0dda64d 100644 --- a/android/src/main/java/com/reactnativenavigation/viewcontrollers/component/ComponentViewController.java +++ b/android/src/main/java/com/reactnativenavigation/viewcontrollers/component/ComponentViewController.java @@ -172,9 +172,12 @@ protected WindowInsetsCompat onApplyWindowInsets(View view, WindowInsetsCompat i int systemWindowInsetTop = insets.getInsets(WindowInsetsCompat.Type.statusBars()).top + insets.getInsets(WindowInsetsCompat.Type.navigationBars()).top - systemBarsInsets.top; - int systemWindowInsetBottom = insets.getInsets(WindowInsetsCompat.Type.ime()).bottom + - insets.getInsets(WindowInsetsCompat.Type.navigationBars()).bottom - - systemBarsInsets.bottom; + + int navBarBottom = insets.getInsets(WindowInsetsCompat.Type.navigationBars()).bottom; + int imeBottom = insets.getInsets(WindowInsetsCompat.Type.ime()).bottom; + int systemWindowInsetBottom = SystemUiUtils.isEdgeToEdgeActive() + ? Math.max(imeBottom, navBarBottom) + : imeBottom; WindowInsetsCompat finalInsets = new WindowInsetsCompat.Builder() .setInsets(WindowInsetsCompat.Type.systemBars() | WindowInsetsCompat.Type.ime(), diff --git a/android/src/main/java/com/reactnativenavigation/viewcontrollers/viewcontroller/LayoutDirectionApplier.kt b/android/src/main/java/com/reactnativenavigation/viewcontrollers/viewcontroller/LayoutDirectionApplier.kt index cdfc2e9e020..01843c30ccf 100644 --- a/android/src/main/java/com/reactnativenavigation/viewcontrollers/viewcontroller/LayoutDirectionApplier.kt +++ b/android/src/main/java/com/reactnativenavigation/viewcontrollers/viewcontroller/LayoutDirectionApplier.kt @@ -1,7 +1,6 @@ package com.reactnativenavigation.viewcontrollers.viewcontroller import android.annotation.SuppressLint -import com.facebook.react.ReactInstanceManager import com.facebook.react.modules.i18nmanager.I18nUtil import com.reactnativenavigation.options.Options @@ -16,4 +15,4 @@ class LayoutDirectionApplier { I18nUtil.instance.forceRTL(currentContext, options.layout.direction.isRtl) } } -} \ No newline at end of file +} diff --git a/android/src/main/java/com/reactnativenavigation/viewcontrollers/viewcontroller/Presenter.java b/android/src/main/java/com/reactnativenavigation/viewcontrollers/viewcontroller/Presenter.java index f1e16de088b..7c6b2b8d840 100644 --- a/android/src/main/java/com/reactnativenavigation/viewcontrollers/viewcontroller/Presenter.java +++ b/android/src/main/java/com/reactnativenavigation/viewcontrollers/viewcontroller/Presenter.java @@ -136,14 +136,12 @@ private void applyNavigationBarVisibility(NavigationBarOptions options) { } private void setNavigationBarBackgroundColor(NavigationBarOptions navigationBar) { - int navigationBarDefaultColor = SystemUiUtils.INSTANCE.getNavigationBarDefaultColor(); - navigationBarDefaultColor = navigationBarDefaultColor == -1 ? Color.BLACK : navigationBarDefaultColor; + int defaultColor = SystemUiUtils.getDefaultNavBarColor(); if (navigationBar.backgroundColor.canApplyValue()) { - int color = navigationBar.backgroundColor.get(navigationBarDefaultColor); + int color = navigationBar.backgroundColor.get(defaultColor); SystemUiUtils.setNavigationBarBackgroundColor(activity.getWindow(), color, isColorLight(color)); } else { - SystemUiUtils.setNavigationBarBackgroundColor(activity.getWindow(), navigationBarDefaultColor, isColorLight(navigationBarDefaultColor)); - + SystemUiUtils.setNavigationBarBackgroundColor(activity.getWindow(), defaultColor, isColorLight(defaultColor)); } } diff --git a/android/src/main/java/com/reactnativenavigation/viewcontrollers/viewcontroller/ViewController.java b/android/src/main/java/com/reactnativenavigation/viewcontrollers/viewcontroller/ViewController.java index 0035533a5c5..cd6bfe86306 100644 --- a/android/src/main/java/com/reactnativenavigation/viewcontrollers/viewcontroller/ViewController.java +++ b/android/src/main/java/com/reactnativenavigation/viewcontrollers/viewcontroller/ViewController.java @@ -430,17 +430,13 @@ public boolean onDependentViewChanged(CoordinatorLayout parent, ViewGroup child, return false; } - public void applyTopInset() { - - } + public void applyTopInset() {} public int getTopInset() { return 0; } - public void applyBottomInset() { - - } + public void applyBottomInset() {} public int getBottomInset() { return perform(parentController, 0, p -> p.getBottomInset(this)); diff --git a/android/src/main/java/com/reactnativenavigation/views/bottomtabs/BottomTabsContainer.kt b/android/src/main/java/com/reactnativenavigation/views/bottomtabs/BottomTabsContainer.kt index d992f567313..3a937ac6f0b 100644 --- a/android/src/main/java/com/reactnativenavigation/views/bottomtabs/BottomTabsContainer.kt +++ b/android/src/main/java/com/reactnativenavigation/views/bottomtabs/BottomTabsContainer.kt @@ -12,6 +12,7 @@ import android.widget.FrameLayout.LayoutParams.WRAP_CONTENT import android.widget.LinearLayout import androidx.annotation.RestrictTo import androidx.core.graphics.ColorUtils +import androidx.core.view.updatePadding import com.reactnativenavigation.options.params.Fraction import com.reactnativenavigation.utils.UiUtils.dpToPx import eightbitlab.com.blurview.BlurTarget @@ -176,4 +177,8 @@ class BottomTabsContainer(context: Context, val bottomTabs: BottomTabs) : Shadow fun setElevation(elevation: Fraction) { setElevation(dpToPx(context, elevation.get().toFloat())) } + + fun setBottomInset(bottomInset: Int) { + blurringView.updatePadding(bottom = bottomInset) + } } diff --git a/android/src/test/java/com/reactnativenavigation/viewcontrollers/stack/TopBarControllerTest.kt b/android/src/test/java/com/reactnativenavigation/viewcontrollers/stack/TopBarControllerTest.kt index 2d9b01f91c9..3c4f94f16df 100644 --- a/android/src/test/java/com/reactnativenavigation/viewcontrollers/stack/TopBarControllerTest.kt +++ b/android/src/test/java/com/reactnativenavigation/viewcontrollers/stack/TopBarControllerTest.kt @@ -51,7 +51,6 @@ class TopBarControllerTest : BaseTest() { override fun beforeEach() { super.beforeEach() - activity = newActivity() appearAnimator = spy(TopBarAppearanceAnimator()) colorAnimator = mock() diff --git a/e2e/assets/bottom_tabs.stylized-root.png b/e2e/assets/bottom_tabs.stylized-root.png new file mode 100644 index 00000000000..4000db82ae1 Binary files /dev/null and b/e2e/assets/bottom_tabs.stylized-root.png differ diff --git a/e2e/assets/bottom_tabs.stylized.png b/e2e/assets/bottom_tabs.stylized.png new file mode 100644 index 00000000000..e2235ded5a9 Binary files /dev/null and b/e2e/assets/bottom_tabs.stylized.png differ diff --git a/e2e/assets/side_menu.aboveContent.png b/e2e/assets/side_menu.aboveContent.png new file mode 100644 index 00000000000..f6a1542fa79 Binary files /dev/null and b/e2e/assets/side_menu.aboveContent.png differ diff --git a/e2e/assets/side_menu.pushContent.png b/e2e/assets/side_menu.pushContent.png new file mode 100644 index 00000000000..5a8b71de73b Binary files /dev/null and b/e2e/assets/side_menu.pushContent.png differ diff --git a/e2e/assets/side_menu.undefined.png b/e2e/assets/side_menu.undefined.png new file mode 100644 index 00000000000..c97b3093840 Binary files /dev/null and b/e2e/assets/side_menu.undefined.png differ diff --git a/lib/android/app/src/main/java/com/reactnativenavigation/viewcontrollers/statusbar/StatusBarPresenter.kt b/lib/android/app/src/main/java/com/reactnativenavigation/viewcontrollers/statusbar/StatusBarPresenter.kt new file mode 100644 index 00000000000..6b060ed4dc6 --- /dev/null +++ b/lib/android/app/src/main/java/com/reactnativenavigation/viewcontrollers/statusbar/StatusBarPresenter.kt @@ -0,0 +1,212 @@ +package com.reactnativenavigation.viewcontrollers.statusbar + +import android.animation.Animator +import android.app.Activity +import android.graphics.Color +import android.view.View +import android.view.Window +import com.reactnativenavigation.RNNFeatureToggles.isEnabled +import com.reactnativenavigation.RNNToggles +import com.reactnativenavigation.options.Options +import com.reactnativenavigation.options.StatusBarOptions +import com.reactnativenavigation.options.StatusBarOptions.TextColorScheme +import com.reactnativenavigation.options.params.Bool +import com.reactnativenavigation.utils.ColorUtils.isColorLight +import com.reactnativenavigation.utils.StubAnimationListener.Companion.onAnimatorEnd +import com.reactnativenavigation.utils.SystemUiUtils.clearStatusBarTranslucency +import com.reactnativenavigation.utils.SystemUiUtils.getStatusBarColor +import com.reactnativenavigation.utils.SystemUiUtils.hideStatusBar +import com.reactnativenavigation.utils.SystemUiUtils.isTranslucent +import com.reactnativenavigation.utils.SystemUiUtils.setStatusBarColor +import com.reactnativenavigation.utils.SystemUiUtils.setStatusBarColorScheme +import com.reactnativenavigation.utils.SystemUiUtils.setStatusBarTranslucent +import com.reactnativenavigation.utils.SystemUiUtils.showStatusBar +import com.reactnativenavigation.viewcontrollers.viewcontroller.StatusBarColorAnimator +import com.reactnativenavigation.viewcontrollers.viewcontroller.ViewController +import java.lang.ref.WeakReference + +class StatusBarPresenter private constructor( + activity: Activity, + private val sbColorAnimator: StatusBarColorAnimator = StatusBarColorAnimator(activity)) { + + private val window = WeakReference(activity.window) + private var hasPendingColorAnim = false + + fun applyOptions(viewController: ViewController<*>, options: StatusBarOptions) { + if (!hasPendingColorAnim) { + setStatusBarBackgroundColor(options) + setTranslucent(options) + } + setTextColorScheme(options) + setStatusBarVisible(viewController, options.visible) + } + + fun mergeOptions(view: View, statusBar: StatusBarOptions) { + mergeStatusBarBackgroundColor(statusBar) + mergeTextColorScheme(statusBar) + mergeTranslucent(statusBar) + mergeStatusBarVisible(view, statusBar.visible) + } + + fun onConfigurationChanged(options: StatusBarOptions) { + setStatusBarBackgroundColor(options) + setTextColorScheme(options) + } + + fun bindViewController(newOptions: StatusBarOptions) { + if (!isEnabled(RNNToggles.TOP_BAR_COLOR_ANIMATION__TABS)) { + return + } + + if (newOptions.backgroundColor.canApplyValue()) { + val currentColor = getCurrentStatusBarBackgroundColor() ?: return + val newColor = getStatusBarBackgroundColor(newOptions) + createStatusBarColorAnimation( + from = currentColor, + to = newColor, + translucent = newOptions.translucent.isTrue, + ).start() + } + } + + fun getStatusBarPushAnimation(appearingOptions: Options): Animator? = + if (isEnabled(RNNToggles.TOP_BAR_COLOR_ANIMATION__PUSH)) { + getStatusBarColorAnimation(appearingOptions.statusBar) + } else null + + fun getStatusBarPopAnimation(appearingOptions: Options, disappearingOptions: Options): Animator? = + if (isEnabled(RNNToggles.TOP_BAR_COLOR_ANIMATION__PUSH)) { + getStatusBarColorAnimation(appearingOptions.statusBar) + } else null + + private fun setStatusBarBackgroundColor(statusBar: StatusBarOptions) { + if (statusBar.backgroundColor.canApplyValue()) { + val statusBarBackgroundColor: Int = getStatusBarBackgroundColor(statusBar) + setStatusBarBackgroundColor(statusBarBackgroundColor, statusBar.translucent.isTrue) + } + } + + private fun setStatusBarBackgroundColor(color: Int, translucent: Boolean) { + setStatusBarColor(window.get(), color, translucent) + } + + private fun getStatusBarBackgroundColor(statusBar: StatusBarOptions): Int { + val defaultColor = + if (statusBar.visible.isTrueOrUndefined) Color.BLACK else Color.TRANSPARENT + return statusBar.backgroundColor.get(defaultColor)!! + } + + private fun setTextColorScheme(statusBar: StatusBarOptions) { + val view = window.get()?.decorView + //View.post is a Workaround, added to solve internal Samsung + //Android 9 issues. For more info see https://github.com/wix/react-native-navigation/pull/7231 + view?.post { + setStatusBarColorScheme( + window.get(), + view, + isDarkTextColorScheme(statusBar) + ) + } + } + + private fun setTranslucent(options: StatusBarOptions) { + val window = window.get() + if (options.translucent.isTrue) { + setStatusBarTranslucent(window) + } else if (isTranslucent(window)) { + clearStatusBarTranslucency(window) + } + } + + private fun setStatusBarVisible(viewController: ViewController<*>, visible: Bool) { + val window = window.get() ?: return + val view = if (viewController.view != null) viewController.view else window.decorView + if (visible.isFalse) { + hideStatusBar(window, view) + } else { + showStatusBar(window, view) + } + } + + private fun mergeStatusBarBackgroundColor(statusBar: StatusBarOptions) { + if (statusBar.backgroundColor.hasValue()) { + val statusBarBackgroundColor = getStatusBarBackgroundColor(statusBar) + setStatusBarColor( + window.get(), statusBarBackgroundColor, + statusBar.translucent.isTrue + ) + } + } + + private fun mergeTextColorScheme(statusBar: StatusBarOptions) { + if (!statusBar.textColorScheme.hasValue()) return + setTextColorScheme(statusBar) + } + + private fun mergeTranslucent(options: StatusBarOptions) { + val window: Window = window.get() ?: return + if (options.translucent.isTrue) { + setStatusBarTranslucent(window) + } else if (options.translucent.isFalse && isTranslucent(window)) { + clearStatusBarTranslucency(window) + } + } + + private fun mergeStatusBarVisible(view: View, visible: Bool) { + if (visible.hasValue()) { + if (visible.isTrue) { + showStatusBar(window.get(), view) + } else { + hideStatusBar(window.get(), view) + } + } + } + + private fun isDarkTextColorScheme(statusBar: StatusBarOptions): Boolean { + if (statusBar.textColorScheme == TextColorScheme.Dark) { + return true + } + + if (statusBar.textColorScheme == TextColorScheme.Light) { + return false + } + return isColorLight(getStatusBarBackgroundColor(statusBar)) + } + + private fun getStatusBarColorAnimation(statusBarOptions: StatusBarOptions): Animator? { + if (isEnabled(RNNToggles.TOP_BAR_COLOR_ANIMATION__TABS)) { + getCurrentStatusBarBackgroundColor()?.let { currentColor -> + val targetColor = statusBarOptions.backgroundColor + + if (targetColor.hasValue()) { + val translucent = statusBarOptions.translucent.isTrue + return createStatusBarColorAnimation( + from = currentColor, + to = targetColor.get(), + translucent = translucent, + ) + } + } + } + return null + } + + private fun createStatusBarColorAnimation(from: Int, to: Int, translucent: Boolean): Animator = + sbColorAnimator.getAnimator(from, to, translucent).apply { + addListener(onAnimatorEnd { + hasPendingColorAnim = false + }) + hasPendingColorAnim = true + } + + private fun getCurrentStatusBarBackgroundColor() = + getStatusBarColor(window.get()) + + companion object { + lateinit var instance: StatusBarPresenter + + fun init(activity: Activity) { + instance = StatusBarPresenter(activity) + } + } +} diff --git a/playground/android/gradle.properties b/playground/android/gradle.properties index 5b8b5878d26..97cc74f1fbb 100644 --- a/playground/android/gradle.properties +++ b/playground/android/gradle.properties @@ -19,6 +19,7 @@ org.gradle.jvmargs=-Xmx2048m # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects # org.gradle.parallel=true android.useAndroidX=true +android.enableJetifier=true android.jetifier.ignorelist=bcprov hermesEnabled=true newArchEnabled=true diff --git a/playground/android/settings.gradle b/playground/android/settings.gradle index 503cdf852e1..869220cd271 100644 --- a/playground/android/settings.gradle +++ b/playground/android/settings.gradle @@ -1,6 +1,8 @@ -pluginManagement { includeBuild("../node_modules/@react-native/gradle-plugin") } +pluginManagement { includeBuild("../../node_modules/@react-native/gradle-plugin") } plugins { id("com.facebook.react.settings") } extensions.configure(com.facebook.react.ReactSettingsExtension){ ex -> ex.autolinkLibrariesFromCommand() } +includeBuild('../../node_modules/@react-native/gradle-plugin') rootProject.name = 'Playground' include ':app' -includeBuild('../node_modules/@react-native/gradle-plugin') +include ':react-native-navigation' +project(':react-native-navigation').projectDir = new File(rootProject.projectDir, '../../android/') diff --git a/playground/e2e/assets/bottom_tabs.stylized-root.png b/playground/e2e/assets/bottom_tabs.stylized-root.png index 2f9c3116e64..e1c86428372 100644 Binary files a/playground/e2e/assets/bottom_tabs.stylized-root.png and b/playground/e2e/assets/bottom_tabs.stylized-root.png differ diff --git a/playground/e2e/assets/bottom_tabs.stylized.png b/playground/e2e/assets/bottom_tabs.stylized.png index 3d9fa4554c1..795c7566cf1 100644 Binary files a/playground/e2e/assets/bottom_tabs.stylized.png and b/playground/e2e/assets/bottom_tabs.stylized.png differ diff --git a/playground/src/components/PressableScale.tsx b/playground/src/components/PressableScale.tsx index 5247ee1debe..a13adc5d394 100644 --- a/playground/src/components/PressableScale.tsx +++ b/playground/src/components/PressableScale.tsx @@ -24,8 +24,9 @@ export interface PressableScaleProps weight?: 'light' | 'medium' | 'heavy'; } -const ReanimatedTouchableWithoutFeedback = - Reanimated.createAnimatedComponent(TouchableWithoutFeedback); +const ReanimatedTouchableWithoutFeedback = Reanimated.createAnimatedComponent( + TouchableWithoutFeedback +); /** * A Pressable that scales down when pressed. Uses the JS Pressability API. diff --git a/playground/src/screens/OrientationScreen.tsx b/playground/src/screens/OrientationScreen.tsx index 6a066d40004..70575377ca1 100644 --- a/playground/src/screens/OrientationScreen.tsx +++ b/playground/src/screens/OrientationScreen.tsx @@ -5,8 +5,11 @@ import Button from '../components/Button'; import Screens from './Screens'; import testIDs from '../testIDs'; -const { LANDSCAPE_PORTRAIT_ORIENTATION_BTN, LANDSCAPE_ORIENTATION_BTN, PORTRAIT_ORIENTATION_BTN } = - testIDs; +const { + LANDSCAPE_PORTRAIT_ORIENTATION_BTN, + LANDSCAPE_ORIENTATION_BTN, + PORTRAIT_ORIENTATION_BTN, +} = testIDs; export default class OrientationScreen extends React.Component { render() { diff --git a/playground/src/screens/OverlayAlert.tsx b/playground/src/screens/OverlayAlert.tsx index b1727d479f9..a3ffcce8fe8 100644 --- a/playground/src/screens/OverlayAlert.tsx +++ b/playground/src/screens/OverlayAlert.tsx @@ -5,8 +5,12 @@ import { component } from '../commons/Layouts'; import Screens from './Screens'; import testIDs from '../testIDs'; -const { OVERLAY_ALERT_HEADER, DISMISS_BTN, SET_INTERCEPT_TOUCH, DISMISS_ALL_OVERLAYS_BUTTON } = - testIDs; +const { + OVERLAY_ALERT_HEADER, + DISMISS_BTN, + SET_INTERCEPT_TOUCH, + DISMISS_ALL_OVERLAYS_BUTTON, +} = testIDs; interface Props extends NavigationProps { incrementDismissedOverlays: any; diff --git a/playground/src/screens/StackCommandsScreen.tsx b/playground/src/screens/StackCommandsScreen.tsx index 03b7061bc12..ab8d4401e9f 100644 --- a/playground/src/screens/StackCommandsScreen.tsx +++ b/playground/src/screens/StackCommandsScreen.tsx @@ -42,7 +42,9 @@ export default class StackCommandsScreen extends NavigationComponent new Promise((resolve) => setTimeout(() => resolve(pushId), 100))) + .then( + (pushId) => new Promise((resolve) => setTimeout(() => resolve(pushId), 100)) + ) .then((pushId) => { this.setState({ pushPromiseResult: `push promise resolved with: ${pushId}`, diff --git a/src/events/EventsRegistry.test.tsx b/src/events/EventsRegistry.test.tsx index 87aeef00a00..e2233fb8aff 100644 --- a/src/events/EventsRegistry.test.tsx +++ b/src/events/EventsRegistry.test.tsx @@ -19,9 +19,9 @@ describe('EventsRegistry', () => { it('exposes appLaunched event', () => { const subscription = {}; const cb = jest.fn(); - ( - mockNativeEventsReceiver.registerAppLaunchedListener as jest.MockedFunction - ).mockReturnValueOnce(subscription); + (mockNativeEventsReceiver.registerAppLaunchedListener as jest.MockedFunction< + any + >).mockReturnValueOnce(subscription); const result = uut.registerAppLaunchedListener(cb); diff --git a/website/docs/api/options-navigationBar.mdx b/website/docs/api/options-navigationBar.mdx index 2ed96fe4491..89418b2dfcb 100644 --- a/website/docs/api/options-navigationBar.mdx +++ b/website/docs/api/options-navigationBar.mdx @@ -7,6 +7,10 @@ import useBaseUrl from '@docusaurus/useBaseUrl'; The Navigation Bar is the area at the bottom of the screen containing Android's three navigation buttons: Back, Home and Recents. +:::info Edge-to-Edge +Starting with Android 15 (API 35), the system enforces edge-to-edge by default. React Native Navigation supports this via a theme opt-in. See the [Edge-to-Edge guide](../docs/style-edge-to-edge) for setup instructions. Navigation bar options below work in both edge-to-edge and non-edge-to-edge modes. +::: + An example of a dark navigation bar: diff --git a/website/docs/docs/style-edge-to-edge.mdx b/website/docs/docs/style-edge-to-edge.mdx new file mode 100644 index 00000000000..82e08a0a5c2 --- /dev/null +++ b/website/docs/docs/style-edge-to-edge.mdx @@ -0,0 +1,64 @@ +--- +id: style-edge-to-edge +title: Edge-to-Edge (Android) +sidebar_label: Edge-to-Edge +--- + +Starting with Android 15 (API 35), the system enforces edge-to-edge display by default — app content draws behind the transparent status bar and navigation bar. React Native Navigation provides built-in support for this behavior, disabled by default. + +## Enabling edge-to-edge + +To enable edge-to-edge, set `windowOptOutEdgeToEdgeEnforcement` to `false` in your app's theme: + +```xml + + + + +``` + +`NavigationActivity` reads this attribute on startup. When set to `false` on API 35+, it calls `EdgeToEdge.enable()` to draw content behind system bars. On older API levels this attribute has no effect. + +## How it works + +When edge-to-edge is active: + +- **Status bar and navigation bar** become transparent. React Native Navigation manages view-based background overlays for both, replacing the deprecated `window.statusBarColor` / `window.navigationBarColor` APIs. +- **`SafeAreaView`** receives correct navigation bar insets, so content is automatically inset from the system navigation bar. +- **Bottom tabs** account for navigation bar insets when positioning. +- **Navigation mode detection** — the library detects gesture navigation vs 3-button navigation at runtime and applies appropriate default styling for the navigation bar background. + +When edge-to-edge is not active (the default), behavior is unchanged from previous versions. + +## Custom behavior + +`NavigationActivity.enableEdgeToEdge()` is a `protected` method. Override it in your `MainActivity` to customize when and how edge-to-edge is enabled: + +```kotlin +class MainActivity : NavigationActivity() { + override fun enableEdgeToEdge() { + // Always enable edge-to-edge regardless of theme + EdgeToEdge.enable(this) + } +} +``` + +## Styling system bars + +Status bar and navigation bar colors can still be controlled per-screen via options: + +```js +options: { + statusBar: { + backgroundColor: 'white', + style: 'dark' + }, + navigationBar: { + backgroundColor: '#000000' + } +} +``` + +These options work in both edge-to-edge and non-edge-to-edge modes. In edge-to-edge mode, the colors are applied to the view-based overlays rather than the deprecated window APIs. diff --git a/website/sidebars.js b/website/sidebars.js index 2c86d57c3f2..4da7faaa1ea 100755 --- a/website/sidebars.js +++ b/website/sidebars.js @@ -22,6 +22,7 @@ module.exports = { 'docs/stack-buttons', 'docs/style-theme', 'docs/style-statusBar', + 'docs/style-edge-to-edge', 'docs/style-orientation', 'docs/style-animations', 'docs/style-fonts',