From 0accabd805808e6c8360a1578378e5b42571a283 Mon Sep 17 00:00:00 2001 From: Mark de Vocht Date: Tue, 26 May 2026 10:30:03 +0300 Subject: [PATCH 1/2] Edge 2 Edge drawBehind --- .../NavigationActivity.java | 15 +++++- .../options/NavigationBarOptions.java | 20 +++++++- .../utils/SystemUiUtils.kt | 46 ++++++++++++++++++- .../bottomtabs/BottomTabsController.java | 24 +++++++--- .../bottomtabs/BottomTabsPresenter.kt | 1 + .../component/ComponentViewController.java | 7 +-- .../viewcontroller/Presenter.java | 46 +++++++++++++------ .../presentation/PresenterTest.java | 14 ++++++ src/interfaces/Options.ts | 5 ++ website/docs/api/options-navigationBar.mdx | 10 +++- 10 files changed, 159 insertions(+), 29 deletions(-) diff --git a/android/src/main/java/com/reactnativenavigation/NavigationActivity.java b/android/src/main/java/com/reactnativenavigation/NavigationActivity.java index 4d6bdf44199..8a6d4f4b251 100644 --- a/android/src/main/java/com/reactnativenavigation/NavigationActivity.java +++ b/android/src/main/java/com/reactnativenavigation/NavigationActivity.java @@ -5,6 +5,7 @@ import android.content.res.Configuration; import android.os.Build; import android.os.Bundle; +import android.graphics.Color; import android.view.KeyEvent; import android.view.View; import android.view.ViewGroup; @@ -25,9 +26,11 @@ import androidx.activity.EdgeToEdge; import androidx.activity.OnBackPressedCallback; +import androidx.activity.SystemBarStyle; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.app.AppCompatActivity; +import androidx.core.view.WindowCompat; public class NavigationActivity extends AppCompatActivity implements DefaultHardwareBackBtnHandler, PermissionAwareActivity, JsDevReloadHandler.ReloadListener { @Nullable @@ -189,8 +192,18 @@ protected void enableEdgeToEdge() { * calling {@code EdgeToEdge.enable()} directly. */ protected void activateEdgeToEdge() { - EdgeToEdge.enable(this); + EdgeToEdge.enable( + this, + SystemBarStyle.auto(Color.TRANSPARENT, Color.TRANSPARENT), + SystemBarStyle.auto(Color.TRANSPARENT, Color.TRANSPARENT) + ); SystemUiUtils.activateEdgeToEdge(); + WindowCompat.setDecorFitsSystemWindows(getWindow(), false); + SystemUiUtils.setNavigationBarContrastEnforced(getWindow(), false); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + getWindow().setStatusBarColor(Color.TRANSPARENT); + getWindow().setNavigationBarColor(Color.TRANSPARENT); + } } protected void addDefaultSplashLayout() { diff --git a/android/src/main/java/com/reactnativenavigation/options/NavigationBarOptions.java b/android/src/main/java/com/reactnativenavigation/options/NavigationBarOptions.java index 2359b42dbcf..fdbccbe4fa3 100644 --- a/android/src/main/java/com/reactnativenavigation/options/NavigationBarOptions.java +++ b/android/src/main/java/com/reactnativenavigation/options/NavigationBarOptions.java @@ -17,20 +17,38 @@ public static NavigationBarOptions parse(Context context, JSONObject json) { result.backgroundColor = ThemeColour.parse(context, json.optJSONObject("backgroundColor")); result.isVisible = BoolParser.parse(json, "visible"); + result.drawBehind = BoolParser.parse(json, "drawBehind"); return result; } public ThemeColour backgroundColor = new NullThemeColour(); public Bool isVisible = new NullBool(); + public Bool drawBehind = new NullBool(); public void mergeWith(NavigationBarOptions other) { if (other.isVisible.hasValue()) isVisible = other.isVisible; if (other.backgroundColor.hasValue()) backgroundColor = other.backgroundColor; + if (other.drawBehind.hasValue()) drawBehind = other.drawBehind; } public void mergeWithDefault(NavigationBarOptions defaultOptions) { if (!isVisible.hasValue()) isVisible = defaultOptions.isVisible; if (!backgroundColor.hasValue()) backgroundColor = defaultOptions.backgroundColor; + if (!drawBehind.hasValue()) drawBehind = defaultOptions.drawBehind; } -} \ No newline at end of file + + public boolean shouldDrawBehind() { + if (drawBehind.isFalse()) return false; + if (drawBehind.isTrue()) return true; + return backgroundColor.hasTransparency(); + } + + public boolean isDrawBehindAndVisible() { + return shouldDrawBehind() && isVisible.isTrueOrUndefined(); + } + + public boolean hasAnyValue() { + return isVisible.hasValue() || drawBehind.hasValue() || backgroundColor.hasValue(); + } +} diff --git a/android/src/main/java/com/reactnativenavigation/utils/SystemUiUtils.kt b/android/src/main/java/com/reactnativenavigation/utils/SystemUiUtils.kt index 7df9a31d3e4..0069ab1a4ac 100644 --- a/android/src/main/java/com/reactnativenavigation/utils/SystemUiUtils.kt +++ b/android/src/main/java/com/reactnativenavigation/utils/SystemUiUtils.kt @@ -4,6 +4,7 @@ import android.app.Activity import android.graphics.Color import android.graphics.Rect import android.graphics.drawable.ColorDrawable +import android.os.Build import android.view.Gravity import android.view.View import android.view.ViewGroup @@ -15,6 +16,7 @@ import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsControllerCompat import kotlin.math.abs import kotlin.math.ceil +import kotlin.math.max object SystemUiUtils { private const val STATUS_BAR_HEIGHT_M = 24 @@ -176,6 +178,29 @@ object SystemUiUtils { isEdgeToEdgeActive = true } + @JvmStatic + fun setNavigationBarContrastEnforced(window: Window?, enforced: Boolean) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + window?.isNavigationBarContrastEnforced = enforced + } + } + + @JvmStatic + fun getContentBottomSystemBarInset(insets: WindowInsetsCompat, drawBehindNavigationBar: Boolean): Int { + val imeBottom = insets.getInsets(WindowInsetsCompat.Type.ime()).bottom + if (!isEdgeToEdgeActive) return imeBottom + if (drawBehindNavigationBar) return imeBottom + val navBarBottom = insets.getInsets(WindowInsetsCompat.Type.navigationBars()).bottom + return max(imeBottom, navBarBottom) + } + + @JvmStatic + fun getBottomTabsSystemBarPadding(insets: WindowInsetsCompat, drawBehindNavigationBar: Boolean): Int { + if (insets.getInsets(WindowInsetsCompat.Type.ime()).bottom > 0) return 0 + if (isEdgeToEdgeActive && drawBehindNavigationBar) return 0 + return insets.getInsets(WindowInsetsCompat.Type.systemBars()).bottom + } + /** * Clears references to system bar background views. * Call from Activity.onDestroy to avoid leaking views across activity recreation. @@ -316,11 +341,23 @@ object SystemUiUtils { * falls back to the deprecated window API on older configurations. */ @JvmStatic - fun setNavigationBarBackgroundColor(window: Window?, color: Int, lightColor: Boolean) { - lastExplicitNavBarColor = color + fun setNavigationBarBackgroundColor(window: Window?, color: Int, lightColor: Boolean, hideOverlay: Boolean) { window?.let { WindowInsetsControllerCompat(window, window.decorView).isAppearanceLightNavigationBars = lightColor } + if (hideOverlay) { + lastExplicitNavBarColor = null + navBarBackgroundView?.apply { + visibility = View.GONE + setBackgroundColor(Color.TRANSPARENT) + } + @Suppress("DEPRECATION") + window?.navigationBarColor = Color.TRANSPARENT + return + } + + lastExplicitNavBarColor = color + navBarBackgroundView?.visibility = View.VISIBLE if (isEdgeToEdgeActive) { navBarBackgroundView?.setBackgroundColor(color) } else { @@ -329,5 +366,10 @@ object SystemUiUtils { } } + @JvmStatic + fun setNavigationBarBackgroundColor(window: Window?, color: Int, lightColor: Boolean) { + setNavigationBarBackgroundColor(window, color, lightColor, Color.alpha(color) == 0) + } + // 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 49324fa2b6d..d4b934fe95a 100644 --- a/android/src/main/java/com/reactnativenavigation/viewcontrollers/bottomtabs/BottomTabsController.java +++ b/android/src/main/java/com/reactnativenavigation/viewcontrollers/bottomtabs/BottomTabsController.java @@ -13,7 +13,7 @@ import androidx.annotation.NonNull; import androidx.annotation.RestrictTo; import androidx.coordinatorlayout.widget.CoordinatorLayout; -import androidx.core.graphics.Insets; +import androidx.core.view.ViewCompat; import androidx.core.view.WindowInsetsCompat; import com.aurelhubert.ahbottomnavigation.AHBottomNavigation; @@ -25,6 +25,7 @@ import com.reactnativenavigation.react.CommandListenerAdapter; import com.reactnativenavigation.react.events.EventEmitter; import com.reactnativenavigation.utils.ImageLoader; +import com.reactnativenavigation.utils.SystemUiUtils; import com.reactnativenavigation.viewcontrollers.bottomtabs.attacher.BottomTabsAttacher; import com.reactnativenavigation.viewcontrollers.child.ChildControllersRegistry; import com.reactnativenavigation.viewcontrollers.parent.ParentController; @@ -156,6 +157,7 @@ public void mergeOptions(Options options) { public void applyChildOptions(Options options, ViewController child) { super.applyChildOptions(options, child); presenter.applyChildOptions(resolveCurrentOptions(), child); + onNavigationBarOptionsChanged(options); performOnParentController(parent -> parent.applyChildOptions( this.options.copy() .clearBottomTabsOptions() @@ -170,6 +172,7 @@ public void mergeChildOptions(Options options, ViewController child) { super.mergeChildOptions(options, child); presenter.mergeChildOptions(options, child); tabPresenter.mergeChildOptions(options, child); + onNavigationBarOptionsChanged(options); performOnParentController(parent -> parent.mergeChildOptions(options.copy().clearBottomTabsOptions(), child)); } @@ -316,14 +319,23 @@ public Animator getPopAnimation(Options appearingOptions, Options disappearingOp @Override protected WindowInsetsCompat onApplyWindowInsets(View view, WindowInsetsCompat insets) { - Insets sysInsets = insets.getInsets(WindowInsetsCompat.Type.systemBars()); - Insets imeInsets = insets.getInsets(WindowInsetsCompat.Type.ime()); - - int bottomInset = (imeInsets.bottom > 0) ? 0 : sysInsets.bottom; - view.setPaddingRelative(0, 0, 0, bottomInset); + boolean drawBehindNavBar = resolveCurrentOptions().navigationBar.isDrawBehindAndVisible(); + view.setPaddingRelative(0, 0, 0, SystemUiUtils.getBottomTabsSystemBarPadding(insets, drawBehindNavBar)); return insets; } + private void onNavigationBarOptionsChanged(Options options) { + if (!options.navigationBar.hasAnyValue()) return; + refreshNavigationBarInsets(); + } + + private void refreshNavigationBarInsets() { + if (getView() != null) { + applyBottomInset(); + ViewCompat.requestApplyInsets(getView()); + } + } + @RestrictTo(RestrictTo.Scope.TESTS) public BottomTabs getBottomTabs() { return bottomTabs; 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 2d40aa256ac..88f3ffc557b 100644 --- a/android/src/main/java/com/reactnativenavigation/viewcontrollers/bottomtabs/BottomTabsPresenter.kt +++ b/android/src/main/java/com/reactnativenavigation/viewcontrollers/bottomtabs/BottomTabsPresenter.kt @@ -393,6 +393,7 @@ class BottomTabsPresenter( private fun syncNavigationBarColor(options: Options, tabsColor: Int) { val resolved = options.copy().withDefaultOptions(defaultOptions) if (resolved.navigationBar.backgroundColor.hasValue()) return + if (resolved.navigationBar.shouldDrawBehind()) return val window = (bottomTabsContainer.context as? Activity)?.window ?: return SystemUiUtils.setNavigationBarBackgroundColor(window, tabsColor, isColorLight(tabsColor)) } 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 ca1a0dda64d..8bd298ed631 100644 --- a/android/src/main/java/com/reactnativenavigation/viewcontrollers/component/ComponentViewController.java +++ b/android/src/main/java/com/reactnativenavigation/viewcontrollers/component/ComponentViewController.java @@ -173,11 +173,8 @@ protected WindowInsetsCompat onApplyWindowInsets(View view, WindowInsetsCompat i insets.getInsets(WindowInsetsCompat.Type.navigationBars()).top - systemBarsInsets.top; - 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; + boolean drawBehindNavBar = resolveCurrentOptions(presenter.defaultOptions).navigationBar.isDrawBehindAndVisible(); + int systemWindowInsetBottom = SystemUiUtils.getContentBottomSystemBarInset(insets, drawBehindNavBar); WindowInsetsCompat finalInsets = new WindowInsetsCompat.Builder() .setInsets(WindowInsetsCompat.Type.systemBars() | WindowInsetsCompat.Type.ime(), 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 a8adf777eb4..3925efe53dd 100644 --- a/android/src/main/java/com/reactnativenavigation/viewcontrollers/viewcontroller/Presenter.java +++ b/android/src/main/java/com/reactnativenavigation/viewcontrollers/viewcontroller/Presenter.java @@ -11,6 +11,8 @@ import android.view.ViewGroup; import android.view.ViewGroup.MarginLayoutParams; +import androidx.core.view.ViewCompat; + import com.reactnativenavigation.options.NavigationBarOptions; import com.reactnativenavigation.options.Options; import com.reactnativenavigation.options.OrientationOptions; @@ -114,16 +116,16 @@ private void mergeStatusBarOptions(View view, StatusBarOptions statusBarOptions) private void applyNavigationBarOptions(NavigationBarOptions options) { applyNavigationBarVisibility(options); - setNavigationBarBackgroundColor(options); + applyNavigationBarBackground(options); + refreshNavigationBarInsets(); } private void mergeNavigationBarOptions(NavigationBarOptions options) { - mergeNavigationBarVisibility(options); - setNavigationBarBackgroundColor(options); - } - - private void mergeNavigationBarVisibility(NavigationBarOptions options) { - if (options.isVisible.hasValue()) applyNavigationBarOptions(options); + if (options.isVisible.hasValue()) { + applyNavigationBarVisibility(options); + } + applyNavigationBarBackground(options); + refreshNavigationBarInsets(); } private void applyNavigationBarVisibility(NavigationBarOptions options) { @@ -136,20 +138,38 @@ private void applyNavigationBarVisibility(NavigationBarOptions options) { } } - private void setNavigationBarBackgroundColor(NavigationBarOptions navigationBar) { + private void applyNavigationBarBackground(NavigationBarOptions navigationBar) { if (activity == null) return; int defaultColor = SystemUiUtils.getDefaultNavBarColor(); - if (navigationBar.backgroundColor.canApplyValue()) { - int color = navigationBar.backgroundColor.get(defaultColor); - SystemUiUtils.setNavigationBarBackgroundColor(activity.getWindow(), color, isColorLight(color)); + int color; + boolean hideOverlay; + if (navigationBar.isDrawBehindAndVisible()) { + if (navigationBar.backgroundColor.canApplyValue()) { + color = navigationBar.backgroundColor.get(defaultColor); + hideOverlay = Color.alpha(color) == 0; + } else { + color = Color.TRANSPARENT; + hideOverlay = true; + } } else { - SystemUiUtils.setNavigationBarBackgroundColor(activity.getWindow(), defaultColor, isColorLight(defaultColor)); + hideOverlay = false; + color = navigationBar.backgroundColor.canApplyValue() + ? navigationBar.backgroundColor.get(defaultColor) + : defaultColor; } + SystemUiUtils.setNavigationBarBackgroundColor( + activity.getWindow(), color, isColorLight(color), hideOverlay); + } + + private void refreshNavigationBarInsets() { + if (activity == null) return; + ViewCompat.requestApplyInsets(activity.getWindow().getDecorView()); } public void onConfigurationChanged(ViewController controller, Options options) { Options withDefault = options.withDefaultOptions(defaultOptions); - setNavigationBarBackgroundColor(withDefault.navigationBar); + applyNavigationBarBackground(withDefault.navigationBar); + refreshNavigationBarInsets(); StatusBarPresenter.instance.onConfigurationChanged(withDefault.statusBar); applyBackgroundColor(controller, withDefault); } diff --git a/android/src/test/java/com/reactnativenavigation/presentation/PresenterTest.java b/android/src/test/java/com/reactnativenavigation/presentation/PresenterTest.java index 6c4eb456935..57153874d76 100644 --- a/android/src/test/java/com/reactnativenavigation/presentation/PresenterTest.java +++ b/android/src/test/java/com/reactnativenavigation/presentation/PresenterTest.java @@ -96,4 +96,18 @@ public void shouldMergeInsetsOnTopMostParent(){ verify(parentView).setPadding(2,1,4,3); } + @Test + public void applyNavigationBarDrawBehind_usesTransparentOverlay() { + mockSystemUiUtils(0, 0, (mockedStatic) -> { + ViewGroup spy = spy(new FrameLayout(activity)); + Mockito.when(controller.getView()).thenReturn(spy); + Mockito.when(controller.resolveCurrentOptions()).thenReturn(Options.EMPTY); + Options options = new Options(); + options.navigationBar.drawBehind = new Bool(true); + uut.applyOptions(controller, options); + mockedStatic.verify(() -> SystemUiUtils.setNavigationBarBackgroundColor( + any(), eq(android.graphics.Color.TRANSPARENT), eq(false), eq(true)), times(1)); + }); + } + } diff --git a/src/interfaces/Options.ts b/src/interfaces/Options.ts index bebca9b8ab5..3b8ccc274ec 100644 --- a/src/interfaces/Options.ts +++ b/src/interfaces/Options.ts @@ -1574,6 +1574,11 @@ export interface AnimationOptions { export interface NavigationBarOptions { backgroundColor?: Color; visible?: boolean; + /** + * Draw screen content behind the system navigation bar while keeping it visible. + * On Android 15+ edge-to-edge, use with `backgroundColor: 'transparent'`. + */ + drawBehind?: boolean; } /** diff --git a/website/docs/api/options-navigationBar.mdx b/website/docs/api/options-navigationBar.mdx index 89418b2dfcb..5424af670d3 100644 --- a/website/docs/api/options-navigationBar.mdx +++ b/website/docs/api/options-navigationBar.mdx @@ -39,4 +39,12 @@ Set the navigation bar color. When a light background color is used, the color o | Type | Required | Platform | Default | | --------------------- | -------- | -------- | ------- | -| Color | No | Android | 'black' | \ No newline at end of file +| Color | No | Android | 'black' | + +### `drawBehind` + +Draw screen content behind the system navigation bar while keeping it visible (gesture pill / 3-button bar remain on screen). Use with edge-to-edge enabled in your activity. With `backgroundColor: 'transparent'`, content shows through the nav bar area; with an opaque color, RNN paints a scrim overlay without reserving bottom layout inset. + +| Type | Required | Platform | +| ------- | -------- | -------- | +| boolean | No | Android | \ No newline at end of file From 1ac5421b4acd17f191e866c21bc333e51d08888c Mon Sep 17 00:00:00 2001 From: Mark de Vocht Date: Tue, 26 May 2026 10:37:31 +0300 Subject: [PATCH 2/2] docs update --- website/docs/docs/style-edge-to-edge.mdx | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/website/docs/docs/style-edge-to-edge.mdx b/website/docs/docs/style-edge-to-edge.mdx index 63f4a48e403..169c620ac1a 100644 --- a/website/docs/docs/style-edge-to-edge.mdx +++ b/website/docs/docs/style-edge-to-edge.mdx @@ -63,4 +63,28 @@ options: { } ``` +Use `navigationBar.drawBehind` with edge-to-edge to draw content behind the system navigation bar while keeping it visible (gesture pill or 3-button bar stay on screen). Layout insets skip the navigation bar height; RNN paints an optional scrim via `backgroundColor`: + +```js +// Transparent nav bar — content shows through, pill visible +options: { + navigationBar: { + visible: true, + drawBehind: true, + backgroundColor: 'transparent' + } +} + +// Opaque scrim over content behind the nav bar +options: { + navigationBar: { + visible: true, + drawBehind: true, + backgroundColor: '#20303C' + } +} +``` + +Without `drawBehind`, an opaque `backgroundColor` reserves bottom inset so content sits above the navigation bar (traditional behavior). See [Navigation Bar options](../api/options-navigationBar) for `visible` and other fields. + 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.