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
120 changes: 120 additions & 0 deletions packages/react-native/Libraries/Lists/__tests__/FlatList-itest.js
Original file line number Diff line number Diff line change
Expand Up @@ -584,6 +584,126 @@ describe('<FlatList>', () => {
expect(viewableKeys).toContain('3');
});

it('reports the items around sticky headers that are on screen', () => {
// Two groups, each one 50px sticky header followed by five 80px rows:
// header A at 0, rows A1-A5, header B at 450, rows B1-B5.
const data = ['A', 'B'].flatMap(group => [
{key: `header ${group}`, height: 50},
...Array.from({length: 5}, (_, i) => ({
key: `${group}${i + 1}`,
height: 80,
})),
]);
const root = Fantom.createRoot({
viewportWidth: 400,
viewportHeight: 600,
});
const onViewableItemsChanged = jest.fn();
const flatListRef = createRef<FlatList<{key: string, height: number}>>();
const timers = Fantom.installTimerMock();
try {
Fantom.runTask(() => {
root.render(
<FlatList
ref={flatListRef}
data={data}
renderItem={({item}) => (
<View style={{height: item.height}} collapsable={false} />
)}
stickyHeaderIndices={[0, 6]}
initialNumToRender={data.length}
viewabilityConfig={{itemVisiblePercentThreshold: 100}}
onViewableItemsChanged={onViewableItemsChanged}
/>,
);
});

const scrollView = nullthrows(
nullthrows(flatListRef.current).getNativeScrollRef(),
);

// Header A is pinned over A4. Header B (at 450) and B1-B4 are fully on
// screen.
Fantom.scrollTo(scrollView, {x: 0, y: 254});
// Flush the render batches scheduled by cell layout events.
timers.runAllTimers();
Fantom.runWorkLoop();

const lastCall =
onViewableItemsChanged.mock.calls[
onViewableItemsChanged.mock.calls.length - 1
][0];
const viewableKeys = lastCall.viewableItems.map(
(item: {key: string, ...}) => item.key,
);
expect(viewableKeys).toEqual([
'header A',
'A5',
'header B',
'B1',
'B2',
'B3',
'B4',
]);
} finally {
timers.uninstall();
}
});

it('does not report items hidden behind a sticky ListHeaderComponent', () => {
const root = Fantom.createRoot({
viewportWidth: 400,
viewportHeight: 600,
});
const onViewableItemsChanged = jest.fn();
const flatListRef = createRef<FlatList<{key: string}>>();
const data = Array.from({length: 30}, (_, i) => ({key: String(i)}));
const timers = Fantom.installTimerMock();
try {
Fantom.runTask(() => {
root.render(
<FlatList
ref={flatListRef}
data={data}
ListHeaderComponent={
<View style={{height: 120}} collapsable={false} />
}
stickyHeaderIndices={[0]}
renderItem={() => (
<View style={{height: 80}} collapsable={false} />
)}
initialNumToRender={data.length}
viewabilityConfig={{itemVisiblePercentThreshold: 100}}
onViewableItemsChanged={onViewableItemsChanged}
/>,
);
});

const scrollView = nullthrows(
nullthrows(flatListRef.current).getNativeScrollRef(),
);

// The 120px header is pinned over items 0-4.
Fantom.scrollTo(scrollView, {x: 0, y: 400});
// Flush render batches until layout settles.
do {
timers.runAllTimers();
Fantom.runWorkLoop();
} while (timers.getPendingTimerCount() > 0);

const lastCall =
onViewableItemsChanged.mock.calls[
onViewableItemsChanged.mock.calls.length - 1
][0];
const viewableKeys = lastCall.viewableItems.map(
(item: {key: string, ...}) => item.key,
);
expect(viewableKeys).toEqual(['5', '6', '7', '8', '9', '10']);
} finally {
timers.uninstall();
}
});

it('supports viewabilityConfigCallbackPairs', () => {
const root = Fantom.createRoot({
viewportWidth: 400,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import * as Fantom from '@react-native/fantom';
import nullthrows from 'nullthrows';
import * as React from 'react';
import {createRef} from 'react';
import {SectionList, Text} from 'react-native';
import {SectionList, Text, View} from 'react-native';

type Item = {key: string};

Expand Down Expand Up @@ -150,6 +150,82 @@ describe('<SectionList>', () => {
</rn-scrollView>,
);
});

it('reports the items around sticky section headers that are on screen', () => {
// Two sections, each one 50px sticky header followed by five 80px rows:
// header A at 0, rows A1-A5, header B at 450, rows B1-B5.
const sections: Array<SectionBase<Item>> = ['A', 'B'].map(name => ({
key: name,
data: Array.from({length: 5}, (_, i) => ({key: `${name}${i + 1}`})),
}));
const root = Fantom.createRoot({
viewportWidth: 400,
viewportHeight: 600,
});
const onViewableItemsChanged = jest.fn();
const sectionListRef = createRef<SectionList<Item>>();
const timers = Fantom.installTimerMock();
try {
Fantom.runTask(() => {
root.render(
<SectionList
ref={sectionListRef}
stickySectionHeadersEnabled={true}
sections={sections}
renderItem={() => (
<View style={{height: 80}} collapsable={false} />
)}
renderSectionHeader={() => (
<View style={{height: 50}} collapsable={false} />
)}
initialNumToRender={20}
viewabilityConfig={{itemVisiblePercentThreshold: 100}}
onViewableItemsChanged={onViewableItemsChanged}
/>,
);
});

const scrollView = nullthrows(
nullthrows(
nullthrows(sectionListRef.current).getScrollResponder(),
).getNativeScrollRef(),
);

// Header A is pinned over A4. Header B (at 450) and B1-B4 are fully on
// screen.
Fantom.scrollTo(scrollView, {x: 0, y: 254});
// Flush the render batches scheduled by cell layout events.
timers.runAllTimers();
Fantom.runWorkLoop();

const lastCall =
onViewableItemsChanged.mock.calls[
onViewableItemsChanged.mock.calls.length - 1
][0];
const viewableLabels = lastCall.viewableItems.map(
(token: {
index: ?number,
item: Item,
section: {key: string, ...},
...
}) =>
token.index == null
? `header ${token.section.key}`
: token.item.key,
);
expect(viewableLabels).toEqual([
'header A',
'A5',
'header B',
'B1',
'B2',
'B3',
'B4',
]);
} finally {
timers.uninstall();
}
});
});

describe('renders section footer', () => {
Expand Down
74 changes: 70 additions & 4 deletions packages/virtualized-lists/Lists/ViewabilityHelper.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,18 @@ export type ViewToken = {
...
};

/**
* A sticky header pinned to the start of the viewport. Content behind it is
* not visible.
*/
export type PinnedStickyHeader = Readonly<{
// Length of the start of the viewport that the header covers.
coveredLength: number,
// Index of the header when it is a list item, or null for
// `ListHeaderComponent`.
cellIndex: ?number,
}>;

export type ViewabilityConfigCallbackPair = {
viewabilityConfig: ViewabilityConfig,
onViewableItemsChanged: (info: {
Expand Down Expand Up @@ -115,6 +127,7 @@ class ViewabilityHelper {
last: number,
...
},
pinnedStickyHeader?: ?PinnedStickyHeader,
): Array<number> {
const itemCount = props.getItemCount(props.data);
const {itemVisiblePercentThreshold, viewAreaCoveragePercentThreshold} =
Expand All @@ -134,6 +147,8 @@ class ViewabilityHelper {
return viewableIndices;
}
let firstVisible = -1;
const viewportStart = Math.floor(pinnedStickyHeader?.coveredLength ?? 0);
const pinnedCellIndex = pinnedStickyHeader?.cellIndex;
const {first, last} = renderRange || {first: 0, last: itemCount - 1};
if (last >= itemCount) {
console.warn(
Expand All @@ -143,6 +158,10 @@ class ViewabilityHelper {
return [];
}
for (let idx = first; idx <= last; idx++) {
if (idx === pinnedCellIndex) {
// Checked below, where the header is drawn.
continue;
}
const metrics = listMetrics.getCellMetrics(idx, props);
if (!metrics) {
continue;
Expand All @@ -158,6 +177,7 @@ class ViewabilityHelper {
viewablePercentThreshold,
top,
bottom,
viewportStart,
viewportHeight,
metrics.length,
)
Expand All @@ -168,6 +188,41 @@ class ViewabilityHelper {
break;
}
}

if (
pinnedStickyHeader != null &&
pinnedCellIndex != null &&
pinnedCellIndex < itemCount
) {
const metrics = listMetrics.getCellMetrics(pinnedCellIndex, props);
if (metrics) {
// The pinned header ends where the content it covers ends.
const top = Math.floor(
pinnedStickyHeader.coveredLength - metrics.length,
);
const bottom = Math.floor(top + metrics.length);
if (
_isViewable(
viewAreaMode,
viewablePercentThreshold,
top,
bottom,
0,
viewportHeight,
metrics.length,
)
) {
let insertAt = 0;
while (
insertAt < viewableIndices.length &&
viewableIndices[insertAt] < pinnedCellIndex
) {
insertAt++;
}
viewableIndices.splice(insertAt, 0, pinnedCellIndex);
}
}
}
return viewableIndices;
}

Expand Down Expand Up @@ -199,6 +254,7 @@ class ViewabilityHelper {
// Suppression bypasses the normal early returns so an empty result clears
// items reported before an ancestor moved off screen.
suppressViewableItems?: boolean,
pinnedStickyHeader?: ?PinnedStickyHeader,
): void {
const updateGeneration = this._updateGeneration + 1;
const itemCount = props.getItemCount(props.data);
Expand Down Expand Up @@ -229,6 +285,7 @@ class ViewabilityHelper {
viewportHeight,
listMetrics,
renderRange,
pinnedStickyHeader,
);
}
if (
Expand Down Expand Up @@ -340,13 +397,19 @@ function _isViewable(
viewablePercentThreshold: number,
top: number,
bottom: number,
viewportStart: number,
viewportHeight: number,
itemLength: number,
): boolean {
if (_isEntirelyVisible(top, bottom, viewportHeight)) {
if (_isEntirelyVisible(top, bottom, viewportStart, viewportHeight)) {
return true;
} else {
const pixels = _getPixelsVisible(top, bottom, viewportHeight);
const pixels = _getPixelsVisible(
top,
bottom,
viewportStart,
viewportHeight,
);
const percent =
100 * (viewAreaMode ? pixels / viewportHeight : pixels / itemLength);
return percent >= viewablePercentThreshold;
Expand All @@ -356,18 +419,21 @@ function _isViewable(
function _getPixelsVisible(
top: number,
bottom: number,
viewportStart: number,
viewportHeight: number,
): number {
const visibleHeight = Math.min(bottom, viewportHeight) - Math.max(top, 0);
const visibleHeight =
Math.min(bottom, viewportHeight) - Math.max(top, viewportStart);
return Math.max(0, visibleHeight);
}

function _isEntirelyVisible(
top: number,
bottom: number,
viewportStart: number,
viewportHeight: number,
): boolean {
return top >= 0 && bottom <= viewportHeight && bottom > top;
return top >= viewportStart && bottom <= viewportHeight && bottom > top;
}

export default ViewabilityHelper;
Loading
Loading