diff --git a/src/FlashListProps.ts b/src/FlashListProps.ts index 1dba06cdc..19b32a69d 100644 --- a/src/FlashListProps.ts +++ b/src/FlashListProps.ts @@ -435,6 +435,9 @@ export interface FlashListProps * Offset from the top of the list where sticky headers should stick. * This is useful when you have a fixed header or navigation bar at the top of your screen * and want sticky headers to appear below it instead of at the very top. + * + * Items hidden behind this offset are also excluded from `onViewableItemsChanged`, + * since they aren't actually visible to the user. * @default 0 */ offset?: number; diff --git a/src/__tests__/ViewabilityHelper.test.ts b/src/__tests__/ViewabilityHelper.test.ts index 05bd7ac7e..c1b979afa 100644 --- a/src/__tests__/ViewabilityHelper.test.ts +++ b/src/__tests__/ViewabilityHelper.test.ts @@ -251,6 +251,37 @@ describe("ViewabilityHelper", () => { ); }); + it("excludes items hidden behind a top offset (fixed header)", () => { + const viewabilityHelper = new ViewabilityHelper( + null, + viewableIndicesChanged + ); + // Items 0, 1, 2 fit in a 300px list. A 120px fixed header covers item 0 + // entirely and part of item 1, so only 1 and 2 should be viewable. + viewabilityHelper.possiblyViewableIndices = [0, 1, 2]; + updateViewableItems({ viewabilityHelper, topOffset: 120 }); + expect(viewableIndicesChanged).toHaveBeenCalledWith([1, 2], [1, 2], []); + }); + + it("counts an item's visibility against the area below the top offset", () => { + // Item 1 (y: 100..200) sits partly behind the fixed header. With a 120px + // offset, 80 of its 100px are below the header (80% >= 50%, viewable); with a + // 160px offset only 40px are (40% < 50%, not viewable). + const config = { itemVisiblePercentThreshold: 50 }; + + const helperA = new ViewabilityHelper(config, viewableIndicesChanged); + helperA.possiblyViewableIndices = [1]; + updateViewableItems({ viewabilityHelper: helperA, topOffset: 120 }); + expect(viewableIndicesChanged).toHaveBeenCalledWith([1], [1], []); + + viewableIndicesChanged.mockReset(); + + const helperB = new ViewabilityHelper(config, viewableIndicesChanged); + helperB.possiblyViewableIndices = [1]; + updateViewableItems({ viewabilityHelper: helperB, topOffset: 160 }); + expect(viewableIndicesChanged).not.toHaveBeenCalled(); + }); + const updateViewableItems = ({ viewabilityHelper, horizontal, @@ -258,6 +289,7 @@ describe("ViewabilityHelper", () => { listSize, getLayout, runAllTimers, + topOffset, }: { viewabilityHelper: ViewabilityHelper; horizontal?: boolean; @@ -265,6 +297,7 @@ describe("ViewabilityHelper", () => { listSize?: RVDimension; getLayout?: (index: number) => RVLayout | undefined; runAllTimers?: boolean; + topOffset?: number; }) => { viewabilityHelper.updateViewableItems( horizontal ?? false, @@ -273,7 +306,9 @@ describe("ViewabilityHelper", () => { getLayout ?? ((index) => { return { x: 0, y: index * 100, height: 100, width: 300 } as RVLayout; - }) + }), + undefined, + topOffset ?? 0 ); if (runAllTimers ?? true) { jest.runAllTimers(); diff --git a/src/recyclerview/viewability/ViewabilityHelper.ts b/src/recyclerview/viewability/ViewabilityHelper.ts index 16ec1a8af..892ac3c34 100644 --- a/src/recyclerview/viewability/ViewabilityHelper.ts +++ b/src/recyclerview/viewability/ViewabilityHelper.ts @@ -49,7 +49,8 @@ class ViewabilityHelper { scrollOffset: number, listSize: RVDimension, getLayout: (index: number) => RVLayout | undefined, - viewableIndices?: number[] + viewableIndices?: number[], + topOffset = 0 ) { if (viewableIndices !== undefined) { this.possiblyViewableIndices = viewableIndices; @@ -78,7 +79,8 @@ class ViewabilityHelper { listSize, this.viewabilityConfig?.viewAreaCoveragePercentThreshold, this.viewabilityConfig?.itemVisiblePercentThreshold, - getLayout + getLayout, + topOffset ) ); this.viewableIndices = newViewableIndices; @@ -128,7 +130,8 @@ class ViewabilityHelper { listSize: RVDimension, viewAreaCoveragePercentThreshold: number | null | undefined, itemVisiblePercentThreshold: number | null | undefined, - getLayout: (index: number) => RVLayout | undefined + getLayout: (index: number) => RVLayout | undefined, + topOffset: number ) { const itemLayout = getLayout(index); if (itemLayout === undefined) { @@ -137,22 +140,24 @@ class ViewabilityHelper { const itemTop = (horizontal ? itemLayout.x : itemLayout.y) - scrollOffset; const itemSize = horizontal ? itemLayout.width : itemLayout.height; const listMainSize = horizontal ? listSize.width : listSize.height; + // `topOffset` is the height occluded at the top of the list (e.g. a fixed + // header via `stickyHeaderConfig.offset`), so the visible region starts there. const pixelsVisible = - Math.min(itemTop + itemSize, listMainSize) - Math.max(itemTop, 0); + Math.min(itemTop + itemSize, listMainSize) - Math.max(itemTop, topOffset); // Always consider item fully viewable if it is fully visible, regardless of the `viewAreaCoveragePercentThreshold` if (pixelsVisible === itemSize) { return true; } // Skip checking item if it's not visible at all - if (pixelsVisible === 0) { + if (pixelsVisible <= 0) { return false; } const viewAreaMode = viewAreaCoveragePercentThreshold !== null && viewAreaCoveragePercentThreshold !== undefined; const percent = viewAreaMode - ? pixelsVisible / listMainSize + ? pixelsVisible / (listMainSize - topOffset) : pixelsVisible / itemSize; const viewableAreaPercentThreshold = viewAreaMode ? viewAreaCoveragePercentThreshold * 0.01 diff --git a/src/recyclerview/viewability/ViewabilityManager.ts b/src/recyclerview/viewability/ViewabilityManager.ts index 972c20f7b..c9fb6a6b5 100644 --- a/src/recyclerview/viewability/ViewabilityManager.ts +++ b/src/recyclerview/viewability/ViewabilityManager.ts @@ -78,13 +78,17 @@ export default class ViewabilityManager { const scrollOffset = (this.rvManager.getAbsoluteLastScrollOffset() ?? 0) - this.rvManager.firstItemOffset; + // A fixed header at the top of the list (configured via `stickyHeaderConfig.offset`) + // occludes the top of the viewport, so items behind it aren't actually visible. + const topOffset = this.rvManager.props.stickyHeaderConfig?.offset ?? 0; this.viewabilityHelpers.forEach((viewabilityHelper) => { viewabilityHelper.updateViewableItems( this.rvManager.props.horizontal ?? false, scrollOffset, listSize, (index: number) => this.rvManager.getLayout(index), - newViewableIndices + newViewableIndices, + topOffset ); }); };