diff --git a/FabricExample/e2e/single-feature-tests/tabs/test-tabs-override-scroll-view-content-inset-ios.e2e.ts b/FabricExample/e2e/single-feature-tests/tabs/test-tabs-override-scroll-view-content-inset-ios.e2e.ts new file mode 100644 index 0000000000..3604687417 --- /dev/null +++ b/FabricExample/e2e/single-feature-tests/tabs/test-tabs-override-scroll-view-content-inset-ios.e2e.ts @@ -0,0 +1,192 @@ +import { expect as jestExpect } from '@jest/globals'; +import { device, expect, element, by } from 'detox'; +import { IosElementAttributes } from 'detox/detox'; +import { + describeIfiOS, + forceTapByLabeliOS, + selectSingleFeatureTestsScreen, +} from '../../e2e-utils'; + +async function getScrollViewSafeAreaInsetsTop(testID: string): Promise<{ + top: number; +}> { + const attrs = (await element( + by.id(testID), + ).getAttributes()) as IosElementAttributes; + return { top: attrs.safeAreaInsets.top }; +} + +function isAboveSaveAreaInset( + itemFrame: { y: number; height: number }, + scrollViewSAVInsetTop: number, +): boolean { + return itemFrame.y + itemFrame.height <= scrollViewSAVInsetTop; +} + +async function getTabBarFrame(): Promise<{ + x: number; + y: number; + width: number; + height: number; +}> { + const attrs = await element(by.type('UITabBar')).getAttributes(); + return (attrs as IosElementAttributes).frame; +} + +async function getElementFrame(testID: string): Promise<{ + x: number; + y: number; + width: number; + height: number; +}> { + const attrs = await element(by.id(testID)).getAttributes(); + + if ('elements' in attrs) { + throw new Error( + `Multiple elements (${attrs.elements.length}) found for testID: "${testID}".`, + ); + } + return (attrs as IosElementAttributes).frame; +} + +function isAboveTabBar( + itemFrame: { y: number; height: number }, + tabBarFrame: { y: number }, +): boolean { + return itemFrame.y + itemFrame.height <= tabBarFrame.y; +} + +async function assertLastItemAboveTabBar(tabPrefix: string, expected: boolean) { + const lastItemFrame = await getElementFrame(`${tabPrefix}-item-30`); + const tabFrame = await getTabBarFrame(); + jestExpect(isAboveTabBar(lastItemFrame, tabFrame)).toBe(expected); +} + +async function assertHeaderBehindStatusBar( + tabPrefix: string, + expected: boolean, +) { + const informationFrame = await getElementFrame(`${tabPrefix}-header`); + const scrollViewSAVInsetTop = await getScrollViewSafeAreaInsetsTop( + `${tabPrefix}-scrollview`, + ); + jestExpect( + isAboveSaveAreaInset(informationFrame, scrollViewSAVInsetTop.top), + ).toBe(expected); +} + +async function scrollToMaxBottom(scrollViewId: string) { + await element(by.id(scrollViewId)).scrollTo('bottom', NaN, 0.5); +} +async function scrollToMaxTop(scrollViewId: string) { + await element(by.id(scrollViewId)).scrollTo('top', NaN, 0.5); +} + +describeIfiOS('Override Scroll View Content Inset (iOS)', () => { + beforeAll(async () => { + await device.reloadReactNative(); + await selectSingleFeatureTestsScreen( + 'Tabs', + 'test-tabs-override-scroll-view-content-inset-ios', + ); + }); + + describe('False tab (overrideScrollViewContentInsetAdjustmentBehavior: false)', () => { + beforeAll(async () => { + await forceTapByLabeliOS('override-inset-tab-false'); + }); + + it('should display the false tab scrollview with the tab bar visible', async () => { + await expect( + element(by.id('override-inset-false-scrollview')), + ).toBeVisible(); + await expect(element(by.type('UITabBar'))).toBeVisible(); + }); + + it('should render the last item overlapping or behind the tab bar (not fully above it)', async () => { + await scrollToMaxBottom('override-inset-false-scrollview'); + await assertLastItemAboveTabBar('override-inset-false', false); + }); + + it('should render the information text clipped behind the status bar', async () => { + await scrollToMaxTop('override-inset-false-scrollview'); + await assertHeaderBehindStatusBar('override-inset-false', true); + }); + }); + + describe('True tab (overrideScrollViewContentInsetAdjustmentBehavior: true)', () => { + beforeAll(async () => { + await forceTapByLabeliOS('override-inset-tab-true'); + }); + + it('should display the true tab scrollview with the tab bar visible', async () => { + await expect( + element(by.id('override-inset-true-scrollview')), + ).toBeVisible(); + await expect(element(by.type('UITabBar'))).toBeVisible(); + }); + + it('should render the last item fully above the tab bar (proper inset applied)', async () => { + await scrollToMaxBottom('override-inset-true-scrollview'); + await assertLastItemAboveTabBar('override-inset-true', true); + }); + + it('should show the information text visible (not hidden behind inset)', async () => { + await scrollToMaxTop('override-inset-true-scrollview'); + await assertHeaderBehindStatusBar('override-inset-true', false); + }); + }); + describe('Default tab (prop omitted)', () => { + beforeAll(async () => { + await forceTapByLabeliOS('override-inset-tab-default'); + }); + + it('should display the default tab scrollview with the tab bar visible', async () => { + await expect( + element(by.id('override-inset-default-scrollview')), + ).toBeVisible(); + await expect(element(by.type('UITabBar'))).toBeVisible(); + }); + + it('should render the last item fully above the tab bar (proper inset applied)', async () => { + await scrollToMaxBottom('override-inset-default-scrollview'); + await assertLastItemAboveTabBar('override-inset-default', true); + }); + + it('should show the header label visible (not hidden behind inset)', async () => { + await scrollToMaxTop('override-inset-default-scrollview'); + await assertHeaderBehindStatusBar('override-inset-default', false); + }); + }); + + describe('Cross-tab comparison', () => { + beforeAll(async () => { + await forceTapByLabeliOS('override-inset-tab-default'); + }); + + it('should show the information text visible between True and Default tabs', async () => { + await element(by.label('override-inset-tab-true')).tap(); + await expect(element(by.id('override-inset-true-item-1'))).toBeVisible(); + await assertHeaderBehindStatusBar('override-inset-true', false); + + await element(by.label('override-inset-tab-default')).tap(); + await expect( + element(by.id('override-inset-default-item-1')), + ).toBeVisible(); + await assertHeaderBehindStatusBar('override-inset-default', false); + + await element(by.label('override-inset-tab-true')).tap(); + await assertHeaderBehindStatusBar('override-inset-true', false); + }); + + it('should render the information text correctly between False and True tabs', async () => { + await element(by.label('override-inset-tab-false')).tap(); + await expect(element(by.id('override-inset-false-item-1'))).toBeVisible(); + await assertHeaderBehindStatusBar('override-inset-false', true); + + await element(by.label('override-inset-tab-true')).tap(); + await expect(element(by.id('override-inset-true-item-1'))).toBeVisible(); + await assertHeaderBehindStatusBar('override-inset-true', false); + }); + }); +}); diff --git a/apps/src/tests/single-feature-tests/tabs/index.ts b/apps/src/tests/single-feature-tests/tabs/index.ts index 8e9cc62cbe..93aacd6b6b 100644 --- a/apps/src/tests/single-feature-tests/tabs/index.ts +++ b/apps/src/tests/single-feature-tests/tabs/index.ts @@ -1,7 +1,7 @@ import type { ScenarioGroup } from '@apps/tests/shared/helpers'; import BottomAccessoryScenario from './bottom-accessory-layout'; -import OverrideScrollViewContentInsetScenario from './override-scroll-view-content-inset'; +import TestTabsOverrideScrollViewContentInset from './test-tabs-override-scroll-view-content-inset-ios'; import TestTabsTabBarHidden from './test-tabs-tab-bar-hidden'; import TabsScreenOrientationScenario from './tabs-screen-orientation'; import TabBarAppearanceDefinedBySelectedTabScenario from './test-tabs-appearance-defined-by-selected-tab'; @@ -18,7 +18,7 @@ import TestTabsSpecialEffectsScrollToTop from './test-tabs-special-effects-scrol const scenarios = { BottomAccessoryScenario, - OverrideScrollViewContentInsetScenario, + TestTabsOverrideScrollViewContentInset, TabBarAppearanceDefinedBySelectedTabScenario, TestTabsTabBarHidden, TabsScreenOrientationScenario, diff --git a/apps/src/tests/single-feature-tests/tabs/override-scroll-view-content-inset.tsx b/apps/src/tests/single-feature-tests/tabs/test-tabs-override-scroll-view-content-inset-ios/index.tsx similarity index 75% rename from apps/src/tests/single-feature-tests/tabs/override-scroll-view-content-inset.tsx rename to apps/src/tests/single-feature-tests/tabs/test-tabs-override-scroll-view-content-inset-ios/index.tsx index a1218a3161..57648072d0 100644 --- a/apps/src/tests/single-feature-tests/tabs/override-scroll-view-content-inset.tsx +++ b/apps/src/tests/single-feature-tests/tabs/test-tabs-override-scroll-view-content-inset-ios/index.tsx @@ -10,7 +10,7 @@ import { createScenario } from '@apps/tests/shared/helpers'; const scenarioDescription: ScenarioDescription = { name: 'Override ScrollView Content Inset', - key: 'override-scroll-view-content-inset', + key: 'test-tabs-override-scroll-view-content-inset-ios', details: 'Tests overrideScrollViewContentInsetAdjustmentBehavior with different static values per tab. ' + 'False: content scrolls behind bars. True/Default: content is inset from bars.', @@ -19,16 +19,22 @@ const scenarioDescription: ScenarioDescription = { const ITEM_COUNT = 30; -function ScrollContent({ label }: { label: string }) { +function ScrollContent({ + label, + testID, +}: { + label: string; + testID: string; +}) { return ( - + - + overrideScrollViewContentInsetAdjustmentBehavior: {label} {Array.from({ length: ITEM_COUNT }, (_, i) => ( - + Item {i + 1} ))} @@ -37,15 +43,30 @@ function ScrollContent({ label }: { label: string }) { } function FalseTab() { - return ; + return ( + + ); } function TrueTab() { - return ; + return ( + + ); } function DefaultTab() { - return ; + return ( + + ); } export function App() { @@ -59,6 +80,7 @@ export function App() { Component: FalseTab, options: { title: 'False', + tabBarItemAccessibilityLabel: 'override-inset-tab-false', ios: { overrideScrollViewContentInsetAdjustmentBehavior: false, icon: { type: 'sfSymbol', name: 'xmark.circle' }, @@ -70,6 +92,7 @@ export function App() { Component: TrueTab, options: { title: 'True', + tabBarItemAccessibilityLabel: 'override-inset-tab-true', ios: { overrideScrollViewContentInsetAdjustmentBehavior: true, icon: { type: 'sfSymbol', name: 'checkmark.circle' }, @@ -81,6 +104,7 @@ export function App() { Component: DefaultTab, options: { title: 'Default', + tabBarItemAccessibilityLabel: 'override-inset-tab-default', ios: { icon: { type: 'sfSymbol', name: 'circle.dashed' } }, }, }, diff --git a/apps/src/tests/single-feature-tests/tabs/test-tabs-override-scroll-view-content-inset-ios/scenario.md b/apps/src/tests/single-feature-tests/tabs/test-tabs-override-scroll-view-content-inset-ios/scenario.md new file mode 100644 index 0000000000..c22e01c0a4 --- /dev/null +++ b/apps/src/tests/single-feature-tests/tabs/test-tabs-override-scroll-view-content-inset-ios/scenario.md @@ -0,0 +1,131 @@ +# Test Scenario: overrideScrollViewContentInsetAdjustmentBehavior + +## Details + +**Description:** Validates the +`overrideScrollViewContentInsetAdjustmentBehavior` prop on `TabsScreen` +(iOS only). By default, React Native sets ScrollView's +`contentInsetAdjustmentBehavior` to `never`, which prevents the scroll +view from respecting navigation bar and tab bar insets. When this prop +is `true` (the default), the behavior is overridden back to UIKit's +`automatic`, so content is inset from the bars. When set to `false`, +the content scrolls behind the bars without insets. +The test verifies that the three tabs — **False**, **True**, and +**Default** — each exhibit the expected inset behavior and that the +**Default** tab (prop omitted) matches the **True** tab. + +**OS test creation version:** iOS: 18.6 and 26.2 + +## E2E test + +Yes: Covers all manual scenario steps. + +## Prerequisites + +- iOS device or simulator (iPhone) + +## Note + +- This prop is iOS-only and Fabric-only; skip on Android. +- "Content scrolls behind bars" means list items are visible underneath + the navigation bar and/or tab bar when scrolled to the top or bottom + of the list. +- "Content inset from bars" means the first and last list items are + fully visible and never hidden behind a bar, even when the scroll + view is at its extreme positions. +- The navigation bar at the top and the tab bar at the bottom are both + relevant reference points for inset verification. + +## Steps + +### Baseline + +1. Launch the app and navigate to the + **Override Scroll View Content Inset** screen. + +- [ ] Expected: Three tabs are displayed in the tab bar: **False**, + **True**, and **Default**. The **False** tab is selected and shows + a scrollable list of 30 items. + +--- + +### `false` — content scrolls behind bars + +2. Confirm the **False** tab is active and scroll the list to the bottom. + +- [ ] Expected: The last item in the list is partially or fully + obscured behind the tab bar, confirming that no bottom inset is + applied. + +3. Scroll the list to the top. + +- [ ] Expected: The text label + `overrideScrollViewContentInsetAdjustmentBehavior: false` at + the top of the scroll content is partially or fully obscured + behind the navigation bar, because + `overrideScrollViewContentInsetAdjustmentBehavior` is `false` + and the scroll view uses + `contentInsetAdjustmentBehavior: never`. + +--- + +### `true` — content inset from bars + +4. Tap the **True** tab. + +- [ ] Expected: The **True** tab becomes active and shows a + scrollable list of 30 items. + +5. Scroll the list to the bottom. + +- [ ] Expected: The last item is fully visible and is not obscured by + the tab bar. The scroll view respects the bottom inset. + +6. Scroll the list to the top. + +- [ ] Expected: The text label + `overrideScrollViewContentInsetAdjustmentBehavior: true` + at the top of the scroll content is fully visible below the + navigation bar and not obscured behind it. The scroll view + respects the top inset + (`contentInsetAdjustmentBehavior: automatic`). + +--- + +### Default (prop omitted) — same as `true` + +7. Tap the **Default** tab. + +- [ ] Expected: The **Default** tab becomes active and shows a + scrollable list of 30 items. + +8. Scroll the list to the bottom. + +- [ ] Expected: The last item is fully visible and not obscured by + the tab bar — identical behavior to the **True** tab. + +9. Scroll the list to the top. + +- [ ] Expected: The text label + `overrideScrollViewContentInsetAdjustmentBehavior: + (not set, defaults to true)` at the top of the scroll content + is fully visible below the navigation bar and not obscured + behind it — identical behavior to the **True** tab. + +--- + +### Cross-tab comparison + +10. Switch between the **True** tab and the **Default** tab several + times while keeping each list scrolled to the top. + +- [ ] Expected: Both tabs show the first item fully visible below + the navigation bar with identical inset. No layout jump or + visual difference between the two tabs. + +11. Switch to the **False** tab and scroll to the top, then + immediately switch to the **True** tab. + +- [ ] Expected: The **True** tab correctly shows the first item + inset from the navigation bar. No crash or blank screen occurs + during the switch.