Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 =
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -316,6 +336,8 @@ class TabsContainer internal constructor(
return insets
}

insetsAppliedBySystem = true

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

First call to this method comes from us in onAttachedToWindow -> can we call it "by system" then?


for (child in children) {
if (child === bottomNavigationView) {
val insetsForBottomNavigationView = getInsetsForBottomNavigationView(insets)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,13 @@ class TabsHostViewManager :
}
}

override fun setTabBarShouldApplyInsetsSynchronously(
view: TabsHost,
shouldApply: Boolean,
) {
view.tabBarShouldApplyInsetsSynchronously = shouldApply
}

companion object {
const val REACT_CLASS = "RNSTabsHostAndroid"
}
Expand Down
2 changes: 2 additions & 0 deletions apps/src/tests/single-feature-tests/tabs/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If there is no corresponding iOS screen with the same name, we can omit the platform from the scenario name (similar to how it is done for TestTabsTabBarMinimizeBehavior). The fact that a scenario is Android-only is already indicated by the icon on the scenario list.

Suggested change
import TestTabsSynchronousInsetsAndroid from './test-tabs-synchronous-insets-android';
import TestTabsSynchronousInsets 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';
Expand All @@ -35,6 +36,7 @@ const scenarios = {
TestTabsTabBarMinimizeBehavior,
TestTabsTabBarControllerMode,
TestTabsSpecialEffectsScrollToTop,
TestTabsSynchronousInsetsAndroid,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
TestTabsSynchronousInsetsAndroid,
TestTabsSynchronousInsets,

TestTabsTabBarExperimentalUserInterfaceStyle,
TestTabsLifecycleEvents,
TestTabsItemTitle,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import React, { createContext, useContext, useState, useEffect } from 'react';
import { Button, StyleSheet, Text, View } from 'react-native';

Comment thread
t0maboro marked this conversation as resolved.
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 (
<View style={styles.centerContainer}>
<Text style={styles.heading}>Test Configuration</Text>

<View style={styles.section}>
<SettingsSwitch
label="shouldApplyInsetsSynchronously"
value={syncInsets}
onValueChange={setSyncInsets}
/>
</View>

<Button
title="Push Tabs Screen"
color={Colors.primary}
onPress={() => push('TabsScreen')}
/>
</View>
);
}

export function DummyTabScreen() {
const { syncInsets } = useContext(TestConfigContext);
const { updateHostConfig } = useTabsHostConfig();

useEffect(() => {
updateHostConfig({
android: { tabBarShouldApplyInsetsSynchronously: syncInsets },
});
}, [syncInsets, updateHostConfig]);

return (
<View style={styles.centerContainer}>
<Text style={styles.heading}>Inside Tabs</Text>
<Text style={styles.info}>
Synchronous Insets: {syncInsets ? 'ENABLED' : 'DISABLED'}
</Text>
</View>
);
}

const ROUTE_CONFIGS: TabRouteConfig[] = [
{
name: 'MainTab',
Component: DummyTabScreen,
options: {
...DEFAULT_TAB_ROUTE_OPTIONS,
title: 'Main',
},
},
{
name: 'SecondaryTab',
Component: () => (
<View style={styles.centerContainer}>
<Text style={styles.text}>Another tab</Text>
</View>
),
options: {
...DEFAULT_TAB_ROUTE_OPTIONS,
title: 'Secondary',
},
},
];

export function TabsScreen() {
return <TabsContainerWithHostConfigContext routeConfigs={ROUTE_CONFIGS} />;
}

export function App() {
const [syncInsets, setSyncInsets] = useState(true);

return (
<TestConfigContext.Provider value={{ syncInsets, setSyncInsets }}>
<StackContainer
routeConfigs={[
{
name: 'SetupScreen',
Component: SetupScreen,
options: {},
},
{
name: 'TabsScreen',
Component: TabsScreen,
options: {},
},
]}
/>
</TestConfigContext.Provider>
);
}

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);
Original file line number Diff line number Diff line change
@@ -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',

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking at the scenario E2E test section it's already decided that this test won't be automated so e2eCoverage value update should be made:

Suggested change
e2eCoverage: 'tbd',
e2eCoverage: 'incomplete',

smokeTest: false,
};
Original file line number Diff line number Diff line change
@@ -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.

@LKuchno LKuchno May 27, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Following new naming (described in RFC), if we are not going to implement e2e test I would change this line to:

Suggested change
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.
Incomplete: Not automated. 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.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you add Button to the screen to go back to previous screen?
On new Android devices (i.e. Pixel 9) system back button is hidden by default.

Image


---

### 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.
5 changes: 4 additions & 1 deletion src/components/tabs/host/TabsHost.android.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,10 @@ function TabsHost(props: TabsHostProps) {
ref={componentNodeRef}
{...filteredBaseProps}
// Android-specific
tabBarRespectsIMEInsets={android?.tabBarRespectsIMEInsets}>
tabBarRespectsIMEInsets={android?.tabBarRespectsIMEInsets}
tabBarShouldApplyInsetsSynchronously={
android?.tabBarShouldApplyInsetsSynchronously
}>
{children}
</TabsHostAndroidNativeComponent>
);
Expand Down
20 changes: 20 additions & 0 deletions src/components/tabs/host/TabsHost.android.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,24 @@ export interface TabsHostPropsAndroid {
* @supported API 30 or higher
*/
tabBarRespectsIMEInsets?: boolean | undefined;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we've been using no new line between props. Recently in FormSheet we started using them but it would be nice to keep one convention. Not sure which one.

/**
* @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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm thinking about how we want to expose controlling the inset in tabs here.
Now we're adding a switch between reading from decor vs not reading from decor and relying on native dispatch.

But should we also expose an option to disable the inset completely? E.g. is somebody uses a footer of some kind that handles the bottom inset visually but doesn't consume it natively (e.g. a react native view)?

If so, do we need an option to control synchronous read? I guess it won't hurt even if we add a prop to control inset application later.

}
1 change: 1 addition & 0 deletions src/fabric/tabs/TabsHostAndroidNativeComponent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ export interface NativeProps extends ViewProps {

// Android-specific props
tabBarRespectsIMEInsets?: CT.WithDefault<boolean, false>;
tabBarShouldApplyInsetsSynchronously?: CT.WithDefault<boolean, true>;
}

export default codegenNativeComponent<NativeProps>('RNSTabsHostAndroid', {
Expand Down
Loading