diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/container/TabsContainer.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/container/TabsContainer.kt
index 3916fdc2ac..4d003a61cb 100644
--- a/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/container/TabsContainer.kt
+++ b/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/container/TabsContainer.kt
@@ -10,10 +10,12 @@ import android.view.WindowInsets
import android.widget.FrameLayout
import androidx.appcompat.view.ContextThemeWrapper
import androidx.core.graphics.Insets
+import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.children
import androidx.core.view.size
import androidx.fragment.app.FragmentManager
+import com.facebook.react.uimanager.ThemedReactContext
import com.google.android.material.R
import com.google.android.material.bottomnavigation.BottomNavigationView
import com.swmansion.rnscreens.gamma.common.colorscheme.ColorScheme
@@ -127,6 +129,9 @@ class TabsContainer internal constructor(
internal var colorScheme: ColorScheme by colorSchemeCoordinator::colorScheme
internal var tabBarRespectsIMEInsets: Boolean = false
+ internal var tabBarShouldApplyInsetsSynchronously: Boolean = true
+ private var insetsAppliedBySystem = false
+
private val contentView: FrameLayout =
FrameLayout(context).apply {
layoutParams =
@@ -274,6 +279,8 @@ class TabsContainer internal constructor(
RNSLog.d(TAG, "TabsContainer [$id] attached to window")
super.onAttachedToWindow()
+
+ maybeApplyDecorViewInsetsSynchronously()
setupFragmentManager()
// When TabsContainer is reattached to window, it might find new fragment manager (other
@@ -306,6 +313,19 @@ class TabsContainer internal constructor(
colorSchemeCoordinator.onConfigurationChanged(newConfig)
}
+ private fun maybeApplyDecorViewInsetsSynchronously() {
+ if (insetsAppliedBySystem || !tabBarShouldApplyInsetsSynchronously) return
+
+ val activity = (context as? ThemedReactContext)?.currentActivity ?: return
+ val decorView = activity.window.decorView
+ val insetsCompat = ViewCompat.getRootWindowInsets(decorView) ?: return
+
+ val windowInsets = insetsCompat.toWindowInsets()
+ if (windowInsets != null) {
+ dispatchApplyWindowInsets(windowInsets)
+ }
+ }
+
override fun dispatchApplyWindowInsets(insets: WindowInsets?): WindowInsets? {
// On Android versions prior to R, insets dispatch is broken.
// In order to mitigate this, we override dispatchApplyWindowInsets with
@@ -316,6 +336,8 @@ class TabsContainer internal constructor(
return insets
}
+ insetsAppliedBySystem = true
+
for (child in children) {
if (child === bottomNavigationView) {
val insetsForBottomNavigationView = getInsetsForBottomNavigationView(insets)
diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/host/TabsHost.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/host/TabsHost.kt
index 8da0489f1b..f165a4b3d2 100644
--- a/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/host/TabsHost.kt
+++ b/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/host/TabsHost.kt
@@ -58,6 +58,8 @@ class TabsHost(
internal var colorScheme: ColorScheme by container::colorScheme
var tabBarRespectsIMEInsets: Boolean by container::tabBarRespectsIMEInsets
+ internal var tabBarShouldApplyInsetsSynchronously: Boolean by container::tabBarShouldApplyInsetsSynchronously
+
init {
addView(container)
check(container.addNavigationStateObserver(this)) {
diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/host/TabsHostViewManager.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/host/TabsHostViewManager.kt
index c640543d2f..4defab87f1 100644
--- a/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/host/TabsHostViewManager.kt
+++ b/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/host/TabsHostViewManager.kt
@@ -134,6 +134,13 @@ class TabsHostViewManager :
}
}
+ override fun setTabBarShouldApplyInsetsSynchronously(
+ view: TabsHost,
+ shouldApply: Boolean,
+ ) {
+ view.tabBarShouldApplyInsetsSynchronously = shouldApply
+ }
+
companion object {
const val REACT_CLASS = "RNSTabsHostAndroid"
}
diff --git a/apps/src/tests/single-feature-tests/tabs/index.ts b/apps/src/tests/single-feature-tests/tabs/index.ts
index 39584ef9dc..031210d657 100644
--- a/apps/src/tests/single-feature-tests/tabs/index.ts
+++ b/apps/src/tests/single-feature-tests/tabs/index.ts
@@ -15,6 +15,7 @@ import TestTabsStaleStateUpdateRejection from './test-tabs-stale-update-rejectio
import TestTabsTabBarMinimizeBehavior from './test-tabs-tab-bar-minimize-behavior-ios';
import TestTabsTabBarControllerMode from './test-tabs-tab-bar-controller-mode-ios';
import TestTabsSpecialEffectsScrollToTop from './test-tabs-special-effects-scroll-to-top';
+import TestTabsSynchronousInsetsAndroid from './test-tabs-synchronous-insets-android';
import TestTabsTabBarExperimentalUserInterfaceStyle from './test-tabs-tab-bar-experimental-user-interface-style-ios';
import TestTabsLifecycleEvents from './test-tabs-lifecycle-events';
import TestTabsItemTitle from './test-tabs-item-title-ios';
@@ -35,6 +36,7 @@ const scenarios = {
TestTabsTabBarMinimizeBehavior,
TestTabsTabBarControllerMode,
TestTabsSpecialEffectsScrollToTop,
+ TestTabsSynchronousInsetsAndroid,
TestTabsTabBarExperimentalUserInterfaceStyle,
TestTabsLifecycleEvents,
TestTabsItemTitle,
diff --git a/apps/src/tests/single-feature-tests/tabs/test-tabs-synchronous-insets-android/index.tsx b/apps/src/tests/single-feature-tests/tabs/test-tabs-synchronous-insets-android/index.tsx
new file mode 100644
index 0000000000..e357f55d74
--- /dev/null
+++ b/apps/src/tests/single-feature-tests/tabs/test-tabs-synchronous-insets-android/index.tsx
@@ -0,0 +1,153 @@
+import React, { createContext, useContext, useState, useEffect } from 'react';
+import { Button, StyleSheet, Text, View } from 'react-native';
+
+import { scenarioDescription } from './scenario-description';
+import { createScenario } from '@apps/tests/shared/helpers';
+import { SettingsSwitch } from '@apps/shared';
+import {
+ TabsContainerWithHostConfigContext,
+ type TabRouteConfig,
+ useTabsHostConfig,
+ DEFAULT_TAB_ROUTE_OPTIONS,
+} from '@apps/shared/gamma/containers/tabs';
+import {
+ StackContainer,
+ useStackNavigationContext,
+} from '@apps/shared/gamma/containers/stack';
+import { Colors } from '@apps/shared/styling';
+
+const TestConfigContext = createContext({
+ syncInsets: true,
+ setSyncInsets: (_: boolean) => {},
+});
+
+export function SetupScreen() {
+ const { syncInsets, setSyncInsets } = useContext(TestConfigContext);
+ const { push } = useStackNavigationContext();
+
+ return (
+
+ Test Configuration
+
+
+
+
+
+
+ );
+}
+
+export function DummyTabScreen() {
+ const { syncInsets } = useContext(TestConfigContext);
+ const { updateHostConfig } = useTabsHostConfig();
+
+ useEffect(() => {
+ updateHostConfig({
+ android: { tabBarShouldApplyInsetsSynchronously: syncInsets },
+ });
+ }, [syncInsets, updateHostConfig]);
+
+ return (
+
+ Inside Tabs
+
+ Synchronous Insets: {syncInsets ? 'ENABLED' : 'DISABLED'}
+
+
+ );
+}
+
+const ROUTE_CONFIGS: TabRouteConfig[] = [
+ {
+ name: 'MainTab',
+ Component: DummyTabScreen,
+ options: {
+ ...DEFAULT_TAB_ROUTE_OPTIONS,
+ title: 'Main',
+ },
+ },
+ {
+ name: 'SecondaryTab',
+ Component: () => (
+
+ Another tab
+
+ ),
+ options: {
+ ...DEFAULT_TAB_ROUTE_OPTIONS,
+ title: 'Secondary',
+ },
+ },
+];
+
+export function TabsScreen() {
+ return ;
+}
+
+export function App() {
+ const [syncInsets, setSyncInsets] = useState(true);
+
+ return (
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ centerContainer: {
+ flex: 1,
+ justifyContent: 'center',
+ alignItems: 'center',
+ padding: 24,
+ backgroundColor: Colors.background,
+ },
+ heading: {
+ fontSize: 24,
+ fontWeight: 'bold',
+ marginBottom: 20,
+ color: Colors.text,
+ },
+ info: {
+ fontSize: 16,
+ color: Colors.NavyLight60,
+ marginTop: 10,
+ },
+ text: {
+ fontSize: 16,
+ color: Colors.text,
+ },
+ section: {
+ marginBottom: 30,
+ width: '100%',
+ backgroundColor: Colors.cardBackground,
+ borderColor: Colors.cardBorder,
+ borderWidth: 1,
+ borderRadius: 8,
+ padding: 16,
+ },
+});
+
+export default createScenario(App, scenarioDescription);
diff --git a/apps/src/tests/single-feature-tests/tabs/test-tabs-synchronous-insets-android/scenario-description.ts b/apps/src/tests/single-feature-tests/tabs/test-tabs-synchronous-insets-android/scenario-description.ts
new file mode 100644
index 0000000000..433b02b632
--- /dev/null
+++ b/apps/src/tests/single-feature-tests/tabs/test-tabs-synchronous-insets-android/scenario-description.ts
@@ -0,0 +1,10 @@
+import type { ScenarioDescription } from '@apps/tests/shared/helpers';
+
+export const scenarioDescription: ScenarioDescription = {
+ name: 'Synchronous insets application',
+ key: 'test-tabs-synchronous-insets-android',
+ details: 'Test synchronous application of window insets on Android',
+ platforms: ['android'],
+ e2eCoverage: 'tbd',
+ smokeTest: false,
+};
diff --git a/apps/src/tests/single-feature-tests/tabs/test-tabs-synchronous-insets-android/scenario.md b/apps/src/tests/single-feature-tests/tabs/test-tabs-synchronous-insets-android/scenario.md
new file mode 100644
index 0000000000..4ee8bc117b
--- /dev/null
+++ b/apps/src/tests/single-feature-tests/tabs/test-tabs-synchronous-insets-android/scenario.md
@@ -0,0 +1,42 @@
+# Test Scenario: Synchronous insets application
+
+## Details
+
+**Description:** Verifies the `tabBarShouldApplyInsetsSynchronously` prop on the Android. When set to `true`, window insets are retrieved from the `DecorView` and applied synchronously during the `onAttachedToWindow` lifecycle phase. This prevents visible layout jumps of the bottom tab bar that occur when insets are applied asynchronously by the system during or after a screen transition.
+
+**OS test creation version:** Android: API Level 36.
+
+## E2E test
+
+No. Visual layout jumps during screen transitions are difficult to capture reliably in automated UI tests like Detox without precise frame-by-frame visual regression tools. Manual visual verification is required.
+
+## Prerequisites
+
+- Android emulator or physical device.
+
+
+## Steps
+
+### Synchronous insets
+
+1. Launch the app and navigate to **Synchronous insets application**.
+
+- [ ] Expected: The **Setup** screen is visible. The `shouldApplyInsetsSynchronously` button is toggled `true`.
+
+2. Tap the **Push Tabs Screen** button.
+
+- [ ] Expected: A transition to the tabs screen begins. The bottom tab bar renders with the correct height and bottom padding respecting system navigation bars. There is no visible vertical layout jump or resize of the tab bar after the transition completes.
+
+3. Press the back button to return to the **Setup** screen.
+
+---
+
+### Asynchronous insets
+
+4. On the **Setup** screen, toggle the `shouldApplyInsetsSynchronously` button to `false`.
+
+- [ ] Expected: The switch state updates.
+
+5. Tap the **Push Tabs Screen** button.
+
+- [ ] Expected: A transition to the tabs screen begins. The bottom tab bar may initially render with incorrect height (lacking proper bottom padding). Shortly after or right at the end of the transition, the tab bar visibly "jumps" or resizes as the system asynchronously dispatches the insets.
diff --git a/src/components/tabs/host/TabsHost.android.tsx b/src/components/tabs/host/TabsHost.android.tsx
index 110998379e..a201de5c95 100644
--- a/src/components/tabs/host/TabsHost.android.tsx
+++ b/src/components/tabs/host/TabsHost.android.tsx
@@ -48,7 +48,10 @@ function TabsHost(props: TabsHostProps) {
ref={componentNodeRef}
{...filteredBaseProps}
// Android-specific
- tabBarRespectsIMEInsets={android?.tabBarRespectsIMEInsets}>
+ tabBarRespectsIMEInsets={android?.tabBarRespectsIMEInsets}
+ tabBarShouldApplyInsetsSynchronously={
+ android?.tabBarShouldApplyInsetsSynchronously
+ }>
{children}
);
diff --git a/src/components/tabs/host/TabsHost.android.types.ts b/src/components/tabs/host/TabsHost.android.types.ts
index 4f86e43cef..bdbf26f668 100644
--- a/src/components/tabs/host/TabsHost.android.types.ts
+++ b/src/components/tabs/host/TabsHost.android.types.ts
@@ -15,4 +15,24 @@ export interface TabsHostPropsAndroid {
* @supported API 30 or higher
*/
tabBarRespectsIMEInsets?: boolean | undefined;
+
+ /**
+ * @summary Determines whether window insets should be applied synchronously from the DecorView.
+ *
+ * By default, the Android system applies window insets with some delay
+ * (React Native may calculate the initial layout before the insets are dispatched).
+ * This can cause sudden height changes of the bottom tab bar during screen transitions.
+ *
+ * Setting this prop to `true` fetches and applies the insets manually during the view attachment
+ * phase, effectively preventing these visual glitches.
+ *
+ * Setting this to `false` disables this workaround and falls back to the default asynchronous
+ * behavior. This serves as an opt-out mechanism if the synchronous application causes
+ * conflicts with other inset-handling configurations or unexpected UI side-effects.
+ *
+ * @default true
+ *
+ * @platform android
+ */
+ tabBarShouldApplyInsetsSynchronously?: boolean;
}
diff --git a/src/fabric/tabs/TabsHostAndroidNativeComponent.ts b/src/fabric/tabs/TabsHostAndroidNativeComponent.ts
index 221ea81dd2..d78cf3df00 100644
--- a/src/fabric/tabs/TabsHostAndroidNativeComponent.ts
+++ b/src/fabric/tabs/TabsHostAndroidNativeComponent.ts
@@ -61,6 +61,7 @@ export interface NativeProps extends ViewProps {
// Android-specific props
tabBarRespectsIMEInsets?: CT.WithDefault;
+ tabBarShouldApplyInsetsSynchronously?: CT.WithDefault;
}
export default codegenNativeComponent('RNSTabsHostAndroid', {